diff --git a/backend/dev.sh b/backend/dev.sh index 6c6531b..7f90e77 100755 --- a/backend/dev.sh +++ b/backend/dev.sh @@ -49,6 +49,10 @@ if [[ "$(uname)" == "Darwin" ]]; then elif [[ "$(uname)" == "Linux" ]]; then WATCHER_TOOL="inotifywait" WATCHER_CMD="inotifywait -r -m -e modify,create,write" +elif [[ "$(uname)" =~ ^MINGW ]] || [[ "$(uname)" =~ ^MSYS ]] || [[ "$(uname)" =~ ^CYGWIN ]]; then + # Windows via Git Bash / MSYS2 / Cygwin + WATCHER_TOOL="fswatch" + WATCHER_CMD="fswatch -r" else echo -e "${RED}不支持的平台${NC}" exit 1 @@ -58,6 +62,10 @@ if ! command -v "$WATCHER_TOOL" &> /dev/null; then echo -e "${RED}缺少工具: $WATCHER_TOOL${NC}" if [[ "$(uname)" == "Darwin" ]]; then echo "安装方法: brew install fswatch" + elif [[ "$(uname)" =~ ^MINGW ]] || [[ "$(uname)" =~ ^MSYS ]] || [[ "$(uname)" =~ ^CYGWIN ]]; then + echo "安装方法: choco install fswatch (Chocolatey)" + echo " scoop install fswatch (Scoop)" + echo " winget install fswatch (WinGet)" else echo "安装方法: sudo apt install inotify-tools (Debian/Ubuntu)" echo " sudo yum install inotify-tools (CentOS/RHEL)" @@ -259,8 +267,8 @@ start_watcher() { --exclude='starbookService$' fi | while read event; do # 时间戳防抖:每次事件更新标记文件 - # Darwin 不支持 date +%s%N,使用 python 获取纳秒时间戳 - if [[ "$(uname)" == "Darwin" ]]; then + # Darwin/Windows 不支持 date +%s%N,使用 python 获取纳秒时间戳 + if [[ "$(uname)" == "Darwin" ]] || [[ "$(uname)" =~ ^MINGW ]] || [[ "$(uname)" =~ ^MSYS ]] || [[ "$(uname)" =~ ^CYGWIN ]]; then python3 -c 'import time; print(int(time.time()*1e9))' > "$restart_marker" else date +%s%N > "$restart_marker" @@ -279,7 +287,7 @@ start_watcher() { continue fi local now - if [[ "$(uname)" == "Darwin" ]]; then + if [[ "$(uname)" == "Darwin" ]] || [[ "$(uname)" =~ ^MINGW ]] || [[ "$(uname)" =~ ^MSYS ]] || [[ "$(uname)" =~ ^CYGWIN ]]; then now=$(python3 -c 'import time; print(int(time.time()*1e9))') else now=$(date +%s%N) diff --git a/backend/gateway/controller/asset_controller.go b/backend/gateway/controller/asset_controller.go index 51349a4..02f4e71 100644 --- a/backend/gateway/controller/asset_controller.go +++ b/backend/gateway/controller/asset_controller.go @@ -1460,3 +1460,265 @@ func (ctrl *AssetController) ImageGeneration(c *gin.Context) { "images": result.Images, }) } + +// UploadMaterial 上传素材 +func (ctrl *AssetController) UploadMaterial(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Error(c, http.StatusUnauthorized, "未授权") + return + } + starID, exists := c.Get("star_id") + if !exists { + response.Error(c, http.StatusUnauthorized, "未授权") + return + } + + var req dto.UploadMaterialRequestDTO + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, http.StatusBadRequest, "参数错误: "+err.Error()) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{ + "user_id": strconv.FormatInt(userID.(int64), 10), + "star_id": strconv.FormatInt(starID.(int64), 10), + }) + + w := int32(0) + h := int32(0) + if req.Width != nil { + w = int32(*req.Width) + } + if req.Height != nil { + h = int32(*req.Height) + } + + resp, err := ctrl.assetService.UploadMaterial(ctx, &pbAsset.UploadMaterialRequest{ + OssKey: req.OssKey, + OriginalName: req.OriginalName, + FileSize: req.FileSize, + MimeType: req.MimeType, + Width: w, + Height: h, + Hash: req.Hash, + MaterialType: req.MaterialType, + }) + + if err != nil { + logger.Logger.Error("UploadMaterial RPC failed", zap.Error(err)) + code, msg := parseRPCError(err) + response.ErrorWithCode(c, code, msg) + return + } + + if resp.Base.Code != pbCommon.StatusCode_STATUS_OK { + response.ErrorWithCode(c, int(resp.Base.Code), resp.Base.Message) + return + } + + data := dto.ConvertMaterial(resp.Material) + response.Success(c, data) +} + +// BindAssetMaterials 绑定资产素材 +func (ctrl *AssetController) BindAssetMaterials(c *gin.Context) { + assetIDStr := c.Param("asset_id") + assetID, err := strconv.ParseInt(assetIDStr, 10, 64) + if err != nil { + response.Error(c, http.StatusBadRequest, "参数错误: asset_id 必须为数字") + return + } + + if _, exists := c.Get("user_id"); !exists { + response.Error(c, http.StatusUnauthorized, "未授权") + return + } + + var req dto.BindAssetMaterialsRequestDTO + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, http.StatusBadRequest, "参数错误: "+err.Error()) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + materials := make([]*pbAsset.AssetMaterialRelation, 0, len(req.Materials)) + for _, item := range req.Materials { + m := &pbAsset.AssetMaterialRelation{ + AssetId: assetID, + MaterialId: item.MaterialID, + MaterialType: item.MaterialType, + LayerOrder: item.LayerOrder, + } + if item.PosX != nil { + m.PosX = *item.PosX + } + if item.PosY != nil { + m.PosY = *item.PosY + } + if item.Opacity != nil { + m.Opacity = *item.Opacity + } + if item.Rotation != nil { + m.Rotation = *item.Rotation + } + if item.ScaleX != nil { + m.ScaleX = *item.ScaleX + } + if item.ScaleY != nil { + m.ScaleY = *item.ScaleY + } + materials = append(materials, m) + } + + resp, err := ctrl.assetService.BindAssetMaterials(ctx, &pbAsset.BindAssetMaterialsRequest{ + AssetId: assetID, + Materials: materials, + }) + + if err != nil { + logger.Logger.Error("BindAssetMaterials RPC failed", zap.Error(err)) + code, msg := parseRPCError(err) + response.ErrorWithCode(c, code, msg) + return + } + + if resp.Base.Code != pbCommon.StatusCode_STATUS_OK { + response.ErrorWithCode(c, int(resp.Base.Code), resp.Base.Message) + return + } + + response.Success(c, gin.H{"message": "关联成功"}) +} + +// GetAssetMaterials 获取资产素材列表 +func (ctrl *AssetController) GetAssetMaterials(c *gin.Context) { + assetIDStr := c.Param("asset_id") + assetID, err := strconv.ParseInt(assetIDStr, 10, 64) + if err != nil { + response.Error(c, http.StatusBadRequest, "参数错误: asset_id 必须为数字") + return + } + + if _, exists := c.Get("user_id"); !exists { + response.Error(c, http.StatusUnauthorized, "未授权") + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + resp, err := ctrl.assetService.GetAssetMaterials(ctx, &pbAsset.GetAssetMaterialsRequest{ + AssetId: assetID, + }) + + if err != nil { + logger.Logger.Error("GetAssetMaterials RPC failed", zap.Error(err)) + code, msg := parseRPCError(err) + response.ErrorWithCode(c, code, msg) + return + } + + if resp.Base.Code != pbCommon.StatusCode_STATUS_OK { + response.ErrorWithCode(c, int(resp.Base.Code), resp.Base.Message) + return + } + + data := make([]dto.AssetMaterialRelationDTO, 0, len(resp.Materials)) + for _, m := range resp.Materials { + data = append(data, dto.ConvertAssetMaterialRelation(m)) + } + + response.Success(c, data) +} + +// UpdateMaterialLayerOrder 更新图层顺序 +func (ctrl *AssetController) UpdateMaterialLayerOrder(c *gin.Context) { + assetIDStr := c.Param("asset_id") + assetID, err := strconv.ParseInt(assetIDStr, 10, 64) + if err != nil { + response.Error(c, http.StatusBadRequest, "参数错误: asset_id 必须为数字") + return + } + + if _, exists := c.Get("user_id"); !exists { + response.Error(c, http.StatusUnauthorized, "未授权") + return + } + + var req dto.UpdateLayerOrderRequestDTO + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, http.StatusBadRequest, "参数错误: "+err.Error()) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + orders := make([]*pbAsset.MaterialLayerOrderItem, 0, len(req.Orders)) + for _, o := range req.Orders { + orders = append(orders, &pbAsset.MaterialLayerOrderItem{ + RelationId: o.RelationID, + LayerOrder: o.LayerOrder, + }) + } + + resp, err := ctrl.assetService.UpdateMaterialLayerOrder(ctx, &pbAsset.UpdateMaterialLayerOrderRequest{ + AssetId: assetID, + Orders: orders, + }) + + if err != nil { + logger.Logger.Error("UpdateMaterialLayerOrder RPC failed", zap.Error(err)) + code, msg := parseRPCError(err) + response.ErrorWithCode(c, code, msg) + return + } + + if resp.Base.Code != pbCommon.StatusCode_STATUS_OK { + response.ErrorWithCode(c, int(resp.Base.Code), resp.Base.Message) + return + } + + response.Success(c, gin.H{"message": "更新成功"}) +} + +// UnbindAssetMaterial 解绑资产素材 +func (ctrl *AssetController) UnbindAssetMaterial(c *gin.Context) { + relationIDStr := c.Param("relation_id") + relationID, err := strconv.ParseInt(relationIDStr, 10, 64) + if err != nil { + response.Error(c, http.StatusBadRequest, "参数错误: relation_id 必须为数字") + return + } + + if _, exists := c.Get("user_id"); !exists { + response.Error(c, http.StatusUnauthorized, "未授权") + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + resp, err := ctrl.assetService.UnbindAssetMaterial(ctx, &pbAsset.UnbindAssetMaterialRequest{ + RelationId: relationID, + }) + + if err != nil { + logger.Logger.Error("UnbindAssetMaterial RPC failed", zap.Error(err)) + code, msg := parseRPCError(err) + response.ErrorWithCode(c, code, msg) + return + } + + if resp.Base.Code != pbCommon.StatusCode_STATUS_OK { + response.ErrorWithCode(c, int(resp.Base.Code), resp.Base.Message) + return + } + + response.Success(c, gin.H{"message": "解绑成功"}) +} diff --git a/backend/gateway/dto/asset_converter.go b/backend/gateway/dto/asset_converter.go index 01dd5c4..a6a85aa 100644 --- a/backend/gateway/dto/asset_converter.go +++ b/backend/gateway/dto/asset_converter.go @@ -27,6 +27,69 @@ func ConvertCreateMintOrderResponse(pbResp *pbAsset.CreateMintOrderResponse) *Cr return dto } +// ========== 素材相关转换 ========== + +// ConvertMaterial 转换素材信息 +func ConvertMaterial(pbMaterial *pbAsset.Material) MaterialDTO { + dto := MaterialDTO{ + OssKey: pbMaterial.OssKey, + OriginalName: pbMaterial.OriginalName, + FileSize: pbMaterial.FileSize, + MimeType: pbMaterial.MimeType, + Hash: pbMaterial.Hash, + CreatedBy: pbMaterial.CreatedBy, + StarID: pbMaterial.StarId, + CreatedAt: pbMaterial.CreatedAt, + } + if pbMaterial.MaterialId > 0 { + dto.MaterialID = pbMaterial.MaterialId + } + if pbMaterial.Width > 0 { + w := int(pbMaterial.Width) + dto.Width = &w + } + if pbMaterial.Height > 0 { + h := int(pbMaterial.Height) + dto.Height = &h + } + return dto +} + +// ConvertAssetMaterialRelation 转换资产-素材关联 +func ConvertAssetMaterialRelation(pbRel *pbAsset.AssetMaterialRelation) AssetMaterialRelationDTO { + dto := AssetMaterialRelationDTO{ + RelationID: pbRel.RelationId, + AssetID: pbRel.AssetId, + MaterialID: pbRel.MaterialId, + MaterialType: pbRel.MaterialType, + LayerOrder: pbRel.LayerOrder, + MaterialURLSigned: pbRel.MaterialUrlSigned, + } + if pbRel.PosX != 0 || pbRel.PosY != 0 { + px := pbRel.PosX + py := pbRel.PosY + dto.PosX = &px + dto.PosY = &py + } + if pbRel.Opacity != 0 { + o := pbRel.Opacity + dto.Opacity = &o + } + if pbRel.Rotation != 0 { + r := pbRel.Rotation + dto.Rotation = &r + } + if pbRel.ScaleX != 0 { + sx := pbRel.ScaleX + dto.ScaleX = &sx + } + if pbRel.ScaleY != 0 { + sy := pbRel.ScaleY + dto.ScaleY = &sy + } + return dto +} + // ConvertMintOrder 转换铸造订单 func ConvertMintOrder(pbOrder *pbAsset.MintOrder) MintOrderDTO { dto := MintOrderDTO{ diff --git a/backend/gateway/dto/asset_dto.go b/backend/gateway/dto/asset_dto.go index 8853996..4c5e38e 100644 --- a/backend/gateway/dto/asset_dto.go +++ b/backend/gateway/dto/asset_dto.go @@ -167,3 +167,79 @@ type GetMintOrderResponseDTO struct { Order MintOrderDTO `json:"order"` // 订单信息 Asset *AssetDTO `json:"asset,omitempty"` // 关联的资产信息(如果存在) } + +// ========== 素材相关 DTO ========== + +// MaterialDTO 素材信息 +type MaterialDTO struct { + MaterialID int64 `json:"material_id"` + OssKey string `json:"oss_key"` + OriginalName string `json:"original_name"` + FileSize int64 `json:"file_size"` + MimeType string `json:"mime_type"` + Width *int `json:"width"` + Height *int `json:"height"` + Hash string `json:"hash"` + CreatedBy int64 `json:"created_by"` + StarID int64 `json:"star_id"` + CreatedAt int64 `json:"created_at"` +} + +// AssetMaterialRelationDTO 资产-素材关联信息 +type AssetMaterialRelationDTO struct { + RelationID int64 `json:"relation_id"` + AssetID int64 `json:"asset_id"` + MaterialID int64 `json:"material_id"` + MaterialType string `json:"material_type"` + LayerOrder int32 `json:"layer_order"` + MaterialURLSigned string `json:"material_url_signed,omitempty"` + PosX *float64 `json:"pos_x"` + PosY *float64 `json:"pos_y"` + Opacity *float64 `json:"opacity"` + Rotation *float64 `json:"rotation"` + ScaleX *float64 `json:"scale_x"` + ScaleY *float64 `json:"scale_y"` + Width *int `json:"width"` + Height *int `json:"height"` +} + +// UploadMaterialRequestDTO 上传素材请求 +type UploadMaterialRequestDTO struct { + OssKey string `json:"oss_key" binding:"required"` + OriginalName string `json:"original_name" binding:"required"` + FileSize int64 `json:"file_size" binding:"required"` + MimeType string `json:"mime_type" binding:"required"` + Width *int `json:"width"` + Height *int `json:"height"` + Hash string `json:"hash" binding:"required"` + MaterialType string `json:"material_type"` +} + +// BindAssetMaterialsRequestDTO 绑定资产素材请求 +type BindAssetMaterialsRequestDTO struct { + Materials []BindAssetMaterialItemDTO `json:"materials" binding:"required"` +} + +// BindAssetMaterialItemDTO 单个绑定项 +type BindAssetMaterialItemDTO struct { + MaterialID int64 `json:"material_id" binding:"required"` + MaterialType string `json:"material_type" binding:"required"` + LayerOrder int32 `json:"layer_order"` + PosX *float64 `json:"pos_x"` + PosY *float64 `json:"pos_y"` + Opacity *float64 `json:"opacity"` + Rotation *float64 `json:"rotation"` + ScaleX *float64 `json:"scale_x"` + ScaleY *float64 `json:"scale_y"` +} + +// UpdateLayerOrderRequestDTO 更新图层顺序请求 +type UpdateLayerOrderRequestDTO struct { + Orders []LayerOrderItemDTO `json:"orders" binding:"required"` +} + +// LayerOrderItemDTO 图层顺序项 +type LayerOrderItemDTO struct { + RelationID int64 `json:"relation_id" binding:"required"` + LayerOrder int32 `json:"layer_order"` +} diff --git a/backend/gateway/router/router.go b/backend/gateway/router/router.go index ddfd74c..eab337c 100644 --- a/backend/gateway/router/router.go +++ b/backend/gateway/router/router.go @@ -194,6 +194,14 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl assets.GET("/me/items", assetCtrl.GetMyAssets) // 获取我的藏品列表 assets.GET("/:asset_id", assetCtrl.GetAsset) // 获取资产详情 assets.GET("/:asset_id/status", assetCtrl.GetAssetStatus) // 查询上链状态 + + // 素材管理 + materials := assets.Group("/materials") + materials.POST("/upload", assetCtrl.UploadMaterial) + assets.POST("/:asset_id/materials", assetCtrl.BindAssetMaterials) + assets.GET("/:asset_id/materials", assetCtrl.GetAssetMaterials) + assets.PUT("/:asset_id/materials/layer-order", assetCtrl.UpdateMaterialLayerOrder) + assets.DELETE("/materials/:relation_id", assetCtrl.UnbindAssetMaterial) } // 展馆相关路由(需要认证) diff --git a/backend/pkg/models/material.go b/backend/pkg/models/material.go new file mode 100644 index 0000000..7389097 --- /dev/null +++ b/backend/pkg/models/material.go @@ -0,0 +1,62 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// Material 素材表模型 +type Material struct { + ID int64 `gorm:"primaryKey;autoIncrement;column:id"` + OssKey string `gorm:"type:varchar(255);not null;uniqueIndex;column:oss_key"` + OriginalName string `gorm:"type:varchar(255);not null;column:original_name"` + FileSize int64 `gorm:"not null;column:file_size"` + MimeType string `gorm:"type:varchar(100);not null;column:mime_type"` + Width *int `gorm:"column:width"` + Height *int `gorm:"column:height"` + Hash string `gorm:"type:varchar(64);not null;index;column:hash"` + CreatedBy int64 `gorm:"not null;index;column:created_by"` + StarID int64 `gorm:"not null;index;column:star_id"` + CreatedAt int64 `gorm:"not null;column:created_at"` + UpdatedAt int64 `gorm:"not null;column:updated_at"` + DeletedAt *int64 `gorm:"index;column:deleted_at"` +} + +func (Material) TableName() string { return "materials" } + +func (m *Material) BeforeCreate(tx *gorm.DB) error { + now := time.Now().UnixMilli() + m.CreatedAt = now + m.UpdatedAt = now + return nil +} + +func (m *Material) BeforeUpdate(tx *gorm.DB) error { + m.UpdatedAt = time.Now().UnixMilli() + return nil +} + +// AssetMaterialRelation 资产-素材关联表模型 +type AssetMaterialRelation struct { + ID int64 `gorm:"primaryKey;autoIncrement;column:id"` + AssetID int64 `gorm:"not null;index:idx_amr_asset_id;column:asset_id"` + MaterialID int64 `gorm:"not null;index:idx_amr_material_id;column:material_id"` + MaterialType string `gorm:"type:varchar(50);not null;column:material_type"` + LayerOrder int `gorm:"not null;default:0;column:layer_order"` + PosX *float64 `gorm:"column:pos_x"` + PosY *float64 `gorm:"column:pos_y"` + Opacity *float64 `gorm:"default:1.0;column:opacity"` + Rotation *float64 `gorm:"default:0;column:rotation"` + ScaleX *float64 `gorm:"default:1.0;column:scale_x"` + ScaleY *float64 `gorm:"default:1.0;column:scale_y"` + Version int `gorm:"not null;default:1;column:version"` + CreatedAt int64 `gorm:"not null;column:created_at"` + UpdatedAt int64 `gorm:"not null;column:updated_at"` + DeletedAt *int64 `gorm:"index;column:deleted_at"` + + Asset Asset `gorm:"foreignKey:AssetID;references:ID;constraint:OnDelete:CASCADE"` + Material Material `gorm:"foreignKey:MaterialID;references:ID;constraint:OnDelete:RESTRICT"` +} + +func (AssetMaterialRelation) TableName() string { return "asset_material_relations" } diff --git a/backend/proto/asset.proto b/backend/proto/asset.proto index d37e044..bb2961e 100644 --- a/backend/proto/asset.proto +++ b/backend/proto/asset.proto @@ -310,6 +310,100 @@ message GetAssetForRPCResponse { bool is_active = 6; // 是否激活 } +// ==================== 素材相关消息 ==================== + +// 素材信息 +message Material { + int64 material_id = 1; + string oss_key = 2; + string original_name = 3; + int64 file_size = 4; + string mime_type = 5; + int32 width = 6; + int32 height = 7; + string hash = 8; + int64 created_by = 9; + int64 star_id = 10; + int64 created_at = 11; +} + +// 资产-素材关联 +message AssetMaterialRelation { + int64 relation_id = 1; + int64 asset_id = 2; + int64 material_id = 3; + string material_type = 4; + int32 layer_order = 5; + string material_url_signed = 6; + double pos_x = 7; + double pos_y = 8; + double opacity = 9; + double rotation = 10; + double scale_x = 11; + double scale_y = 12; +} + +// 上传素材请求 +message UploadMaterialRequest { + string oss_key = 1; + string original_name = 2; + int64 file_size = 3; + string mime_type = 4; + int32 width = 5; + int32 height = 6; + string hash = 7; + string material_type = 8; +} + +message UploadMaterialResponse { + topfans.common.BaseResponse base = 1; + Material material = 2; +} + +// 绑定资产素材请求 +message BindAssetMaterialsRequest { + int64 asset_id = 1; + repeated AssetMaterialRelation materials = 2; +} + +message BindAssetMaterialsResponse { + topfans.common.BaseResponse base = 1; +} + +// 获取资产素材请求 +message GetAssetMaterialsRequest { + int64 asset_id = 1; +} + +message GetAssetMaterialsResponse { + topfans.common.BaseResponse base = 1; + repeated AssetMaterialRelation materials = 2; +} + +// 更新图层顺序请求 +message UpdateMaterialLayerOrderRequest { + int64 asset_id = 1; + repeated MaterialLayerOrderItem orders = 2; +} + +message MaterialLayerOrderItem { + int64 relation_id = 1; + int32 layer_order = 2; +} + +message UpdateMaterialLayerOrderResponse { + topfans.common.BaseResponse base = 1; +} + +// 解绑资产素材请求 +message UnbindAssetMaterialRequest { + int64 relation_id = 1; +} + +message UnbindAssetMaterialResponse { + topfans.common.BaseResponse base = 1; +} + // ==================== 资产服务 ==================== service AssetService { @@ -384,6 +478,21 @@ service AssetService { // 内部RPC:清除资产点赞记录(供Gallery Service调用,下架时清除记录以便下次展出可再次点赞) rpc ClearAssetLikeRecords(ClearAssetLikeRecordsRequest) returns (ClearAssetLikeRecordsResponse); + + // 素材上传 + rpc UploadMaterial(UploadMaterialRequest) returns (UploadMaterialResponse); + + // 绑定资产素材 + rpc BindAssetMaterials(BindAssetMaterialsRequest) returns (BindAssetMaterialsResponse); + + // 获取资产素材列表 + rpc GetAssetMaterials(GetAssetMaterialsRequest) returns (GetAssetMaterialsResponse); + + // 更新素材图层顺序 + rpc UpdateMaterialLayerOrder(UpdateMaterialLayerOrderRequest) returns (UpdateMaterialLayerOrderResponse); + + // 解绑资产素材 + rpc UnbindAssetMaterial(UnbindAssetMaterialRequest) returns (UnbindAssetMaterialResponse); } // 清除资产点赞记录请求(内部RPC,供Gallery Service调用) diff --git a/backend/services/assetService/main.go b/backend/services/assetService/main.go index 99fb79a..c1be6c6 100644 --- a/backend/services/assetService/main.go +++ b/backend/services/assetService/main.go @@ -110,6 +110,8 @@ func main() { mintOrderRepo := repository.NewMintOrderRepository(database.GetDB()) assetLikeRepo := repository.NewAssetLikeRepository(database.GetDB()) rankingRepo := repository.NewRankingRepository(database.GetDB()) + materialRepo := repository.NewMaterialRepository(database.GetDB()) + relationRepo := repository.NewAssetMaterialRelationRepository(database.GetDB()) logger.Logger.Info("Repository layer initialized") // 创建 Dubbo 客户端 @@ -134,10 +136,11 @@ func main() { mintService := service.NewMintService(assetRepo, mintOrderRepo, userClient, database.GetDB(), config.GlobalAssetConfig, registryRepo) assetLikeService := service.NewAssetLikeService(assetRepo, assetLikeRepo, database.GetDB()) rankingService := service.NewRankingService(rankingRepo, userClient) + materialService := service.NewMaterialService(materialRepo, relationRepo) logger.Logger.Info("Service layer initialized") // 创建 Provider 层实例 - assetProvider := provider.NewAssetProvider(assetService, mintService, assetLikeService) + assetProvider := provider.NewAssetProvider(assetService, mintService, assetLikeService, materialService) rankingProvider := provider.NewRankingProvider(rankingService) logger.Logger.Info("Provider layer initialized") @@ -194,6 +197,8 @@ func autoMigrate() error { &models.Asset{}, &models.MintOrder{}, &models.AssetLike{}, + &models.Material{}, + &models.AssetMaterialRelation{}, } for _, table := range tables { diff --git a/backend/services/assetService/provider/asset_provider.go b/backend/services/assetService/provider/asset_provider.go index 1c2688b..80da8c3 100644 --- a/backend/services/assetService/provider/asset_provider.go +++ b/backend/services/assetService/provider/asset_provider.go @@ -21,17 +21,19 @@ type AssetProvider struct { assetService service.AssetService mintService service.MintService assetLikeService *service.AssetLikeService + materialService *service.MaterialService } // 确保 AssetProvider 实现了 AssetServiceHandler 接口 var _ pb.AssetServiceHandler = (*AssetProvider)(nil) // NewAssetProvider 创建资产服务Provider实例 -func NewAssetProvider(assetService service.AssetService, mintService service.MintService, assetLikeService *service.AssetLikeService) *AssetProvider { +func NewAssetProvider(assetService service.AssetService, mintService service.MintService, assetLikeService *service.AssetLikeService, materialService *service.MaterialService) *AssetProvider { return &AssetProvider{ assetService: assetService, mintService: mintService, assetLikeService: assetLikeService, + materialService: materialService, } } diff --git a/backend/services/assetService/provider/material_provider.go b/backend/services/assetService/provider/material_provider.go new file mode 100644 index 0000000..ec3327e --- /dev/null +++ b/backend/services/assetService/provider/material_provider.go @@ -0,0 +1,199 @@ +package provider + +import ( + "context" + + pb "github.com/topfans/backend/pkg/proto/asset" + pbCommon "github.com/topfans/backend/pkg/proto/common" + "github.com/topfans/backend/pkg/models" + "github.com/topfans/backend/pkg/logger" + "github.com/topfans/backend/services/assetService/service" + "go.uber.org/zap" +) + +// UploadMaterial 上传素材 +func (p *AssetProvider) UploadMaterial(ctx context.Context, req *pb.UploadMaterialRequest) (*pb.UploadMaterialResponse, error) { + userID, starID, err := extractUserInfoFromDubboAttachments(ctx) + if err != nil { + return &pb.UploadMaterialResponse{ + Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_UNAUTHORIZED, Message: "unauthorized"}, + }, err + } + + material := &models.Material{ + OssKey: req.OssKey, + OriginalName: req.OriginalName, + FileSize: req.FileSize, + MimeType: req.MimeType, + Hash: req.Hash, + CreatedBy: userID, + StarID: starID, + } + if req.Width > 0 { + w := int(req.Width) + material.Width = &w + } + if req.Height > 0 { + h := int(req.Height) + material.Height = &h + } + + result, err := p.materialService.UploadMaterial(material) + if err != nil { + logger.Logger.Error("UploadMaterial failed", zap.Error(err)) + return &pb.UploadMaterialResponse{ + Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_INTERNAL, Message: err.Error()}, + }, err + } + + return &pb.UploadMaterialResponse{ + Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_OK, Message: "ok"}, + Material: &pb.Material{ + MaterialId: result.ID, + OssKey: result.OssKey, + OriginalName: result.OriginalName, + FileSize: result.FileSize, + MimeType: result.MimeType, + Hash: result.Hash, + CreatedBy: result.CreatedBy, + StarId: result.StarID, + CreatedAt: result.CreatedAt, + }, + }, nil +} + +// BindAssetMaterials 绑定资产素材 +func (p *AssetProvider) BindAssetMaterials(ctx context.Context, req *pb.BindAssetMaterialsRequest) (*pb.BindAssetMaterialsResponse, error) { + if _, _, err := extractUserInfoFromDubboAttachments(ctx); err != nil { + return &pb.BindAssetMaterialsResponse{ + Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_UNAUTHORIZED, Message: "unauthorized"}, + }, err + } + + items := make([]service.BindMaterialItem, 0, len(req.Materials)) + for _, m := range req.Materials { + items = append(items, service.BindMaterialItem{ + MaterialID: m.MaterialId, + MaterialType: m.MaterialType, + LayerOrder: m.LayerOrder, + PosX: doublePtr(m.PosX), + PosY: doublePtr(m.PosY), + Opacity: doublePtr(m.Opacity), + Rotation: doublePtr(m.Rotation), + ScaleX: doublePtr(m.ScaleX), + ScaleY: doublePtr(m.ScaleY), + }) + } + + if _, err := p.materialService.BindMaterials(req.AssetId, items); err != nil { + logger.Logger.Error("BindAssetMaterials failed", zap.Error(err)) + return &pb.BindAssetMaterialsResponse{ + Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_INTERNAL, Message: err.Error()}, + }, err + } + + return &pb.BindAssetMaterialsResponse{ + Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_OK, Message: "ok"}, + }, nil +} + +// GetAssetMaterials 获取资产素材列表 +func (p *AssetProvider) GetAssetMaterials(ctx context.Context, req *pb.GetAssetMaterialsRequest) (*pb.GetAssetMaterialsResponse, error) { + if _, _, err := extractUserInfoFromDubboAttachments(ctx); err != nil { + return &pb.GetAssetMaterialsResponse{ + Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_UNAUTHORIZED, Message: "unauthorized"}, + }, err + } + + relations, err := p.materialService.GetAssetMaterials(req.AssetId) + if err != nil { + return &pb.GetAssetMaterialsResponse{ + Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_INTERNAL, Message: err.Error()}, + }, err + } + + materials := make([]*pb.AssetMaterialRelation, 0, len(relations)) + for _, rel := range relations { + pbRel := &pb.AssetMaterialRelation{ + RelationId: rel.ID, + AssetId: rel.AssetID, + MaterialId: rel.MaterialID, + MaterialType: rel.MaterialType, + LayerOrder: int32(rel.LayerOrder), + } + if rel.PosX != nil { + pbRel.PosX = *rel.PosX + } + if rel.PosY != nil { + pbRel.PosY = *rel.PosY + } + if rel.Opacity != nil { + pbRel.Opacity = *rel.Opacity + } + if rel.Rotation != nil { + pbRel.Rotation = *rel.Rotation + } + if rel.ScaleX != nil { + pbRel.ScaleX = *rel.ScaleX + } + if rel.ScaleY != nil { + pbRel.ScaleY = *rel.ScaleY + } + materials = append(materials, pbRel) + } + + return &pb.GetAssetMaterialsResponse{ + Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_OK, Message: "ok"}, + Materials: materials, + }, nil +} + +// UpdateMaterialLayerOrder 更新图层顺序 +func (p *AssetProvider) UpdateMaterialLayerOrder(ctx context.Context, req *pb.UpdateMaterialLayerOrderRequest) (*pb.UpdateMaterialLayerOrderResponse, error) { + if _, _, err := extractUserInfoFromDubboAttachments(ctx); err != nil { + return &pb.UpdateMaterialLayerOrderResponse{ + Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_UNAUTHORIZED, Message: "unauthorized"}, + }, err + } + + orders := make(map[int64]int, len(req.Orders)) + for _, o := range req.Orders { + orders[o.RelationId] = int(o.LayerOrder) + } + + if err := p.materialService.UpdateLayerOrder(req.AssetId, orders); err != nil { + return &pb.UpdateMaterialLayerOrderResponse{ + Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_INTERNAL, Message: err.Error()}, + }, err + } + + return &pb.UpdateMaterialLayerOrderResponse{ + Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_OK, Message: "ok"}, + }, nil +} + +// UnbindAssetMaterial 解绑素材 +func (p *AssetProvider) UnbindAssetMaterial(ctx context.Context, req *pb.UnbindAssetMaterialRequest) (*pb.UnbindAssetMaterialResponse, error) { + if _, _, err := extractUserInfoFromDubboAttachments(ctx); err != nil { + return &pb.UnbindAssetMaterialResponse{ + Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_UNAUTHORIZED, Message: "unauthorized"}, + }, err + } + + if err := p.materialService.UnbindMaterial(req.RelationId); err != nil { + return &pb.UnbindAssetMaterialResponse{ + Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_INTERNAL, Message: err.Error()}, + }, err + } + + return &pb.UnbindAssetMaterialResponse{ + Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_OK, Message: "ok"}, + }, nil +} + +func doublePtr(v float64) *float64 { + if v == 0 { + return nil + } + return &v +} diff --git a/backend/services/assetService/repository/asset_material_relation_repository.go b/backend/services/assetService/repository/asset_material_relation_repository.go new file mode 100644 index 0000000..809e29d --- /dev/null +++ b/backend/services/assetService/repository/asset_material_relation_repository.go @@ -0,0 +1,70 @@ +package repository + +import ( + "time" + + "github.com/topfans/backend/pkg/models" + "gorm.io/gorm" +) + +// AssetMaterialRelationRepository 资产-素材关联数据访问层 +type AssetMaterialRelationRepository struct { + db *gorm.DB +} + +// NewAssetMaterialRelationRepository 创建关联 Repository +func NewAssetMaterialRelationRepository(db *gorm.DB) *AssetMaterialRelationRepository { + return &AssetMaterialRelationRepository{db: db} +} + +// BatchCreate 批量创建关联记录 +func (r *AssetMaterialRelationRepository) BatchCreate(relations []*models.AssetMaterialRelation) error { + if len(relations) == 0 { + return nil + } + return r.db.Create(&relations).Error +} + +// FindByAssetID 查询资产的所有素材关联(按 layer_order 排序) +func (r *AssetMaterialRelationRepository) FindByAssetID(assetID int64) ([]*models.AssetMaterialRelation, error) { + var relations []*models.AssetMaterialRelation + err := r.db. + Preload("Material"). + Where("asset_id = ? AND deleted_at IS NULL", assetID). + Order("layer_order ASC"). + Find(&relations).Error + if err != nil { + return nil, err + } + return relations, nil +} + +// UpdateLayerOrder 批量更新图层顺序 +func (r *AssetMaterialRelationRepository) UpdateLayerOrder(assetID int64, orders map[int64]int) error { + return r.db.Transaction(func(tx *gorm.DB) error { + for relationID, layerOrder := range orders { + if err := tx.Model(&models.AssetMaterialRelation{}). + Where("id = ? AND asset_id = ? AND deleted_at IS NULL", relationID, assetID). + Update("layer_order", layerOrder).Error; err != nil { + return err + } + } + return nil + }) +} + +// SoftDelete 软删除关联记录 +func (r *AssetMaterialRelationRepository) SoftDelete(relationID int64) error { + now := time.Now().UnixMilli() + return r.db.Model(&models.AssetMaterialRelation{}). + Where("id = ? AND deleted_at IS NULL", relationID). + Update("deleted_at", now).Error +} + +// SoftDeleteByAssetID 软删除资产的所有关联记录 +func (r *AssetMaterialRelationRepository) SoftDeleteByAssetID(assetID int64) error { + now := time.Now().UnixMilli() + return r.db.Model(&models.AssetMaterialRelation{}). + Where("asset_id = ? AND deleted_at IS NULL", assetID). + Update("deleted_at", now).Error +} diff --git a/backend/services/assetService/repository/material_repository.go b/backend/services/assetService/repository/material_repository.go new file mode 100644 index 0000000..967639d --- /dev/null +++ b/backend/services/assetService/repository/material_repository.go @@ -0,0 +1,49 @@ +package repository + +import ( + "time" + + "github.com/topfans/backend/pkg/models" + "gorm.io/gorm" +) + +// MaterialRepository 素材数据访问层 +type MaterialRepository struct { + db *gorm.DB +} + +// NewMaterialRepository 创建素材 Repository +func NewMaterialRepository(db *gorm.DB) *MaterialRepository { + return &MaterialRepository{db: db} +} + +// Create 创建素材记录 +func (r *MaterialRepository) Create(material *models.Material) error { + return r.db.Create(material).Error +} + +// FindByOssKey 根据 OSS Key 查找 +func (r *MaterialRepository) FindByOssKey(ossKey string) (*models.Material, error) { + var m models.Material + err := r.db.Where("oss_key = ? AND deleted_at IS NULL", ossKey).First(&m).Error + if err != nil { + return nil, err + } + return &m, nil +} + +// FindByHash 根据哈希查找(去重用) +func (r *MaterialRepository) FindByHash(hash string, starID int64) (*models.Material, error) { + var m models.Material + err := r.db.Where("hash = ? AND star_id = ? AND deleted_at IS NULL", hash, starID).First(&m).Error + if err != nil { + return nil, err + } + return &m, nil +} + +// SoftDelete 软删除素材 +func (r *MaterialRepository) SoftDelete(materialID int64) error { + now := time.Now().UnixMilli() + return r.db.Model(&models.Material{}).Where("id = ?", materialID).Update("deleted_at", now).Error +} diff --git a/docs/specs/2026-05-15-lenticular-card-multi-material-architecture-design.md b/docs/specs/2026-05-15-lenticular-card-multi-material-architecture-design.md new file mode 100644 index 0000000..581f389 --- /dev/null +++ b/docs/specs/2026-05-15-lenticular-card-multi-material-architecture-design.md @@ -0,0 +1,525 @@ +# 光栅卡多素材架构升级技术设计 + +> **创建日期:** 2026-05-15 +> **项目:** TopFans 光栅卡铸造流程 +> **服务:** assetService (Go Dubbo-go) + 前端铸爱模块 +> **状态:** 审核通过,待开发实施 +> **版本:** v1.0 + +--- + +## 一、背景与问题确认 + +### 1.1 当前故障 + +**问题定性:光栅卡铸造流程中,背景图从未被持久化到后端。确认铸造时仅上传主体图,背景图永久丢失。** + +``` +create.vue (双图上传) → buildCraftFormData() → CASTLOVE_FORM_KEY → ... + ✅ bgPath / subjectPath 完整传递到本地 Storage + ✅ buildLenticularLayersTwo(bgPath, subjectPath) 正确构建图层 + ❌ handleCraftMint() 只取 mid 层 → submitCraftMintFromPath(单 imagePath) + ❌ 后端 material_url 单字段 → 背景图丢失 +``` + +### 1.2 根本技术缺陷 + +现有 `assets.material_url` 单字段存储存在以下瓶颈: + +| 缺陷 | 说明 | +|------|------| +| 无法支撑多素材 | 单一字段存储,多素材需内嵌 JSON(受限于 `varchar(500)`,最多 3-4 个 URL) | +| 数据语义模糊 | 无法区分主图、背景、遮罩、特效等不同角色 | +| 查询效率低下 | 无法针对特定素材类型建立索引 | +| 复用能力缺失 | 素材无法被多个资产共享 | +| 长期扩展性差 | 未来 3 年单资产素材数预计增长至 30+,现有模型无法承载 | + +--- + +## 二、方案设计:新增资产-素材关联表 + +### 2.1 方案概述 + +采用新增 `materials`(素材主表)+ `asset_material_relations`(资产-素材关联表)的方案,彻底解耦素材与资产的强绑定关系。 + +### 2.2 表关系定义 + +``` +assets ◄── 1:N ──► asset_material_relations ◄── N:1 ──► materials +``` + +- **资产主表 ↔ 关联表**:一对多关系,单个资产可绑定多个素材关联记录 +- **素材主表 ↔ 关联表**:多对多关系,单个素材可被多个资产关联复用 + +### 2.3 数据库模型设计 + +#### 2.3.1 素材主表(materials) + +```sql +CREATE TABLE materials ( + id BIGSERIAL PRIMARY KEY, + oss_key VARCHAR(255) NOT NULL, + original_name VARCHAR(255) NOT NULL, + file_size BIGINT NOT NULL, + mime_type VARCHAR(100) NOT NULL, + width INT, + height INT, + hash VARCHAR(64) NOT NULL, + created_by BIGINT NOT NULL, + star_id BIGINT NOT NULL DEFAULT 0, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + deleted_at BIGINT +); + +CREATE UNIQUE INDEX uk_materials_oss_key ON materials(oss_key); +CREATE INDEX idx_materials_hash ON materials(hash); +CREATE INDEX idx_materials_created_by ON materials(created_by); +CREATE INDEX idx_materials_star_id ON materials(star_id); +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | `BIGSERIAL` | 主键 | +| `oss_key` | `VARCHAR(255) UNIQUE` | OSS 对象唯一标识 | +| `original_name` | `VARCHAR(255)` | 原始文件名 | +| `file_size` | `BIGINT` | 文件大小(字节) | +| `mime_type` | `VARCHAR(100)` | MIME 类型 | +| `width / height` | `INT` | 图片尺寸 | +| `hash` | `VARCHAR(64)` | 文件 SHA256 哈希,用于去重 | +| `created_by` | `BIGINT` | 创建者用户 ID | +| `star_id` | `BIGINT` | 多星数据隔离 | +| `created_at / updated_at` | `BIGINT` | 毫秒时间戳 | +| `deleted_at` | `BIGINT` | 软删除时间戳 | + +#### 2.3.2 资产-素材关联表(asset_material_relations) + +```sql +CREATE TABLE asset_material_relations ( + id BIGSERIAL PRIMARY KEY, + asset_id BIGINT NOT NULL REFERENCES assets(id) ON DELETE CASCADE, + material_id BIGINT NOT NULL REFERENCES materials(id) ON DELETE RESTRICT, + material_type VARCHAR(50) NOT NULL, + layer_order INT NOT NULL DEFAULT 0, + -- 渲染定位字段(NULL = 拉伸填满容器) + pos_x DOUBLE PRECISION, -- 距左上角 X 偏移量(px),NULL=拉伸模式 + pos_y DOUBLE PRECISION, -- 距左上角 Y 偏移量(px),NULL=拉伸模式 + opacity DOUBLE PRECISION DEFAULT 1.0, -- 不透明度 0~1 + rotation DOUBLE PRECISION DEFAULT 0, -- 旋转角度(度),正值为顺时针 + scale_x DOUBLE PRECISION DEFAULT 1.0, -- 水平缩放比例 + scale_y DOUBLE PRECISION DEFAULT 1.0, -- 垂直缩放比例 + version INT NOT NULL DEFAULT 1, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + deleted_at BIGINT +); +``` + +**索引与约束:** + +```sql +CREATE INDEX idx_amr_asset_id ON asset_material_relations(asset_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_amr_material_id ON asset_material_relations(material_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_amr_asset_type_layer ON asset_material_relations(asset_id, material_type, layer_order) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX uk_amr_asset_type_active ON asset_material_relations(asset_id, material_type) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX uk_amr_asset_layer_active ON asset_material_relations(asset_id, layer_order) WHERE deleted_at IS NULL; +``` + +**字段说明:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `asset_id` | `BIGINT FK` | 关联资产 ID | +| `material_id` | `BIGINT FK` | 关联素材 ID | +| `material_type` | `VARCHAR(50)` | 素材角色:`main`/`bg`/`star_map`/`mask`/`effect` 等 | +| `layer_order` | `INT` | 图层渲染顺序,数值越小越靠下 | +| `pos_x / pos_y` | `DOUBLE PRECISION` | 距左上角偏移量(px),NULL 为拉伸填满容器模式 | +| `opacity` | `DOUBLE PRECISION` | 不透明度 0~1,默认 1 | +| `rotation` | `DOUBLE PRECISION` | 旋转角度(度),正值为顺时针,默认 0 | +| `scale_x / scale_y` | `DOUBLE PRECISION` | 缩放比例,默认 1 | +| `version` | `INT` | 素材版本号,乐观锁控制并发 | +| `deleted_at` | `BIGINT` | 软删除时间戳 | + +### 2.4 Go Model 定义 + +```go +// Material 素材表模型 +type Material struct { + ID int64 `gorm:"primaryKey;autoIncrement;column:id"` + OssKey string `gorm:"type:varchar(255);not null;uniqueIndex;column:oss_key"` + OriginalName string `gorm:"type:varchar(255);not null;column:original_name"` + FileSize int64 `gorm:"not null;column:file_size"` + MimeType string `gorm:"type:varchar(100);not null;column:mime_type"` + Width *int `gorm:"column:width"` + Height *int `gorm:"column:height"` + Hash string `gorm:"type:varchar(64);not null;index;column:hash"` + CreatedBy int64 `gorm:"not null;index;column:created_by"` + StarID int64 `gorm:"not null;index;column:star_id"` + CreatedAt int64 `gorm:"not null;column:created_at"` + UpdatedAt int64 `gorm:"not null;column:updated_at"` + DeletedAt *int64 `gorm:"index;column:deleted_at"` +} + +func (Material) TableName() string { return "materials" } + +func (m *Material) BeforeCreate(tx *gorm.DB) error { + now := time.Now().UnixMilli() + m.CreatedAt = now + m.UpdatedAt = now + return nil +} + +func (m *Material) BeforeUpdate(tx *gorm.DB) error { + m.UpdatedAt = time.Now().UnixMilli() + return nil +} +``` + +```go +// AssetMaterialRelation 资产-素材关联表模型 +type AssetMaterialRelation struct { + ID int64 `gorm:"primaryKey;autoIncrement;column:id"` + AssetID int64 `gorm:"not null;index:idx_amr_asset_id;column:asset_id"` + MaterialID int64 `gorm:"not null;index:idx_amr_material_id;column:material_id"` + MaterialType string `gorm:"type:varchar(50);not null;column:material_type"` + LayerOrder int `gorm:"not null;default:0;column:layer_order"` + PosX *float64 `gorm:"column:pos_x"` + PosY *float64 `gorm:"column:pos_y"` + Opacity *float64 `gorm:"default:1.0;column:opacity"` + Rotation *float64 `gorm:"default:0;column:rotation"` + ScaleX *float64 `gorm:"default:1.0;column:scale_x"` + ScaleY *float64 `gorm:"default:1.0;column:scale_y"` + Version int `gorm:"not null;default:1;column:version"` + CreatedAt int64 `gorm:"not null;column:created_at"` + UpdatedAt int64 `gorm:"not null;column:updated_at"` + DeletedAt *int64 `gorm:"index;column:deleted_at"` + + Asset Asset `gorm:"foreignKey:AssetID;references:ID;constraint:OnDelete:CASCADE"` + Material Material `gorm:"foreignKey:MaterialID;references:ID;constraint:OnDelete:RESTRICT"` +} + +func (AssetMaterialRelation) TableName() string { return "asset_material_relations" } +``` + +--- + +## 三、数据一致性保障方案 + +### 3.1 事务隔离级别与约束规则 + +- **隔离级别**:所有跨表操作用 `REPEATABLE READ` +- **级联规则**:资产删除时 `ON DELETE CASCADE` 级联删除关联记录;素材删除时 `ON DELETE RESTRICT` 禁止删除被引用的素材 +- **唯一约束**:通过 PostgreSQL 部分唯一索引 `WHERE deleted_at IS NULL` 实现,允许同一资产同一类型在不同版本间切换 + +### 3.2 异常场景回滚机制 + +| 异常场景 | 回滚机制 | +|---------|---------| +| 素材上传失败 | 回滚 `materials` 插入记录,删除 OSS 部分文件 | +| 关联绑定异常 | 回滚关联表插入,保留素材记录用于后续复用 | +| 资产删除中断 | 事务回滚,恢复资产和关联记录 | +| 素材版本更新冲突 | 乐观锁(`version` 字段),冲突时提示用户刷新 | +| 跨节点事务超时 | SAGA 模式,超时后执行补偿操作 | +| 非法参数注入 | 入参校验失败直接拒绝,不执行 DB 操作 | +| 脏数据写入 | 外键 + 唯一索引双重阻拦 | +| 并发绑定锁竞争 | `SELECT ... FOR UPDATE` 行级锁 | +| 软删除标记异常 | 定期数据校验任务修正 | +| 批量导入中断 | 分批事务提交,已提交批次保留,未提交回滚 | + +### 3.3 脏数据清理规则 + +| 规则项 | 配置 | +|--------|------| +| 清理周期 | 每日凌晨 3 点 | +| 软删除清理 | 超过 30 天的软删除记录 | +| 孤立素材清理 | 创建超过 7 天且无任何关联记录的素材 | +| 重复素材清理 | 相同 `hash` 值的重复记录 | +| 备份要求 | 清理前备份到冷存储,保留 90 天 | +| 安全间隔 | 先标记 24 小时后无异常再物理删除 | + +--- + +## 四、核心业务流程 + +### 4.1 资产创建与素材关联 + +``` +用户 → 前端:上传多个素材文件 +前端 → OSS:分片上传素材 +OSS → 前端:返回 oss_key +前端 → 后端:创建资产请求(含素材列表和图层信息) +后端 → materials:批量插入素材记录(hash 去重) +后端 → assets:插入资产记录 +后端 → asset_material_relations:批量插入关联记录(含渲染定位字段) +后端 → 前端:返回资产 ID 和素材列表 +``` + +### 4.2 图层顺序调整 + +``` +用户 → 前端:拖拽调整图层顺序 +前端 → 后端:PUT /api/v1/assets/{id}/materials/layer-order +后端 → 关联表:开启事务 → SELECT FOR UPDATE 锁住该资产所有关联记录 +后端 → 关联表:批量更新 layer_order +后端 → 前端:返回更新结果 +``` + +### 4.3 资产删除 + +``` +用户 → 前端:删除资产请求 +后端 → assets:软删除(设置 deleted_at) +后端 → asset_material_relations:级联软删除所有关联记录 +后端 → 前端:返回删除成功 +``` + +--- + +## 五、API 接口规范 + +### 5.1 素材上传 + +``` +POST /api/v1/materials/upload +Content-Type: multipart/form-data + +入参: + - file: 文件(必填) + - type: 素材类型(可选) + +出参: + { "code": 0, "data": { "material_id": 123, "oss_key": "assets/123.jpg", + "url": "https://oss.example.com/assets/123.jpg", "width": 1080, "height": 1920 } } +``` + +### 5.2 资产-素材关联 + +``` +POST /api/v1/assets/{asset_id}/materials +Content-Type: application/json + +入参: + { "materials": [ + { "material_id": 123, "material_type": "main", "layer_order": 0, + "pos_x": null, "pos_y": null, "opacity": 1.0, "rotation": 0, "scale_x": 1.0, "scale_y": 1.0 }, + { "material_id": 456, "material_type": "bg", "layer_order": 1 } + ] } + +出参: + { "code": 0, "message": "关联成功" } +``` + +### 5.3 资产素材查询 + +``` +GET /api/v1/assets/{asset_id}/materials + +出参: + { "code": 0, "data": [ + { "relation_id": 1, "material_id": 123, "material_type": "main", + "layer_order": 0, "url": "https://oss.example.com/assets/123.jpg", + "pos_x": null, "pos_y": null, "opacity": 1.0, "rotation": 0, + "scale_x": 1.0, "scale_y": 1.0, "width": 1080, "height": 1920 } + ] } +``` + +### 5.4 图层顺序更新 + +``` +PUT /api/v1/assets/{asset_id}/materials/layer-order + +入参: + { "orders": [ { "relation_id": 1, "layer_order": 0 }, { "relation_id": 2, "layer_order": 1 } ] } +``` + +### 5.5 权限控制 + +| 角色 | 权限边界 | +|------|---------| +| 普通用户 | 仅可操作自己创建的素材和资产,校验 `created_by` / `owner_uid` | +| 运营人员 | 可管理所有用户的素材和资产,支持批量操作 | +| 管理员 | 所有权限,含数据清理任务执行权限 | + +> MVP 阶段简化为「用户仅可操作自身数据」,运营人员角色后置实现。 + +--- + +## 六、Proto 扩展 + +```protobuf +message Material { + int64 material_id = 1; + string oss_key = 2; + string original_name = 3; + int64 file_size = 4; + string mime_type = 5; + int32 width = 6; + int32 height = 7; + string hash = 8; + int64 created_by = 9; + int64 star_id = 10; + int64 created_at = 11; +} + +message AssetMaterialRelation { + int64 relation_id = 1; + int64 asset_id = 2; + int64 material_id = 3; + string material_type = 4; + int32 layer_order = 5; + string material_url_signed = 6; + double pos_x = 7; + double pos_y = 8; + double opacity = 9; + double rotation = 10; + double scale_x = 11; + double scale_y = 12; +} + +service AssetService { + rpc UploadMaterial(UploadMaterialRequest) returns (UploadMaterialResponse); + rpc BindAssetMaterials(BindAssetMaterialsRequest) returns (BindAssetMaterialsResponse); + rpc GetAssetMaterials(GetAssetMaterialsRequest) returns (GetAssetMaterialsResponse); + rpc UpdateMaterialLayerOrder(UpdateMaterialLayerOrderRequest) returns (UpdateMaterialLayerOrderResponse); + rpc UnbindAssetMaterial(UnbindAssetMaterialRequest) returns (UnbindAssetMaterialResponse); +} +``` + +--- + +## 七、性能优化 + +### 7.1 缓存策略 + +| 层级 | 实现 | 配置 | +|------|------|------| +| L1(Redis) | Hash: `asset:materials:{asset_id}` | TTL 1 小时,写入时主动 INVALIDATE | +| L2(go-cache) | 热点资产 Top 1000 | TTL 5 分钟,访问频率 > 10次/分钟 提升到热点 | + +### 7.2 查询优化 + +```go +// GORM Preload 方式(推荐) +var asset Asset +db.Preload("Materials", func(db *gorm.DB) *gorm.DB { + return db.Where("asset_material_relations.deleted_at IS NULL"). + Order("asset_material_relations.layer_order ASC") +}).First(&asset, assetID) + +// 批量查询 Raw SQL(高性能场景) +rows, _ := db.Raw(` + SELECT a.*, amr.material_type, amr.layer_order, m.oss_key + FROM assets a + LEFT JOIN asset_material_relations amr ON amr.asset_id = a.id AND amr.deleted_at IS NULL + LEFT JOIN materials m ON amr.material_id = m.id AND m.deleted_at IS NULL + WHERE a.id = ? AND a.deleted_at IS NULL + ORDER BY amr.layer_order ASC +`, assetID).Rows() +``` + +### 7.3 分库分表预案 + +| 触发条件 | 分表策略 | +|---------|---------| +| `assets` > 1000 万行 | 按 `asset_id` 哈希分 16 表 | +| `asset_material_relations` > 1 亿行 | 同上 | + +### 7.4 性能预估 + +| 场景 | 预估耗时 | +|------|---------| +| 单资产 10 素材查询 | < 5ms | +| 单资产 50 素材查询 | < 15ms | +| 批量 100 资产(平均 5 素材/资产) | < 50ms | + +--- + +## 八、数据迁移与兼容性 + +### 8.1 迁移策略 + +| 阶段 | 天数 | 内容 | +|------|------|------| +| 双写 | 第 1-3 天 | 同时写入 `material_url` 和关联表 | +| 灰度切流 | 第 4-5 天 | 1% → 50% → 100% 逐步切读 | +| 观察期 | 第 6-7 天 | 全量走新模型,持续监测 | +| 清理 | 第 14 天 | 停止双写,material_url 设为可空 | + +### 8.2 迁移脚本核心逻辑 + +```go +func migrateMaterials() error { + for offset := 0; ; offset += 1000 { + assets := queryAssets(offset, 1000) + if len(assets) == 0 { break } + for _, asset := range assets { + tx := db.Begin() + // 解析 material_url(兼容单字符串和 JSON 格式) + materials := parseMaterialURL(asset.MaterialURL) + // 插入 materials 表(hash 去重) + materialIDs := batchInsertMaterials(tx, materials) + // 插入关联表 + batchInsertRelations(tx, asset.ID, materialIDs, materials) + tx.Commit() + } + } +} +``` + +### 8.3 灰度观测指标与回滚 + +| 指标 | 正常阈值 | 回滚触发 | +|------|---------|---------| +| 接口错误率 | < 0.01% | > 1% | +| 查询响应时间 | < 50ms(P99) | > 200ms | +| 数据一致性 | 100% | < 99.9% | + +--- + +## 九、测试验收标准 + +### 9.1 测试场景 + +| 类型 | 场景 | +|------|------| +| 单元测试 | 关联表 CRUD 事务逻辑、乐观锁冲突、外键约束、唯一约束 | +| 集成测试 | 资产创建 → 素材上传 → 关联绑定 → 图层调整 → 资产删除全流程 | +| 压力测试 | 单资产关联 50 个素材,QPS=1000,验证系统稳定性 | + +### 9.2 验收指标 + +| 指标 | 目标值 | +|------|--------| +| 单资产 10 素材查询 | < 50ms | +| 单资产 50 素材查询 | < 100ms | +| 接口错误率 | < 0.01% | +| 数据一致性通过率 | 100% | +| 支持并发 QPS | ≥ 1000 | + +--- + +## 十、实施计划 + +``` +第 1-2 天:DDL 建表 → 测试环境验证 +第 3-4 天:Go Model + Proto + 网关 DTO + Converter +第 5-6 天:Service CRUD + 事务 + 缓存 +第 7 天: 前端适配 + 数据迁移脚本 +第 8-9 天:灰度发布(双写 → 切流 → 观察) +第 10 天: 上线确认 + 回滚预案待命 +``` + +| 合计 | 10 个工作日 | + +--- + +## 十一、关联文档 + +| 文档 | 说明 | +|------|------| +| [Asset 数据模型](file:///e:/develop/code/topfans/backend/pkg/models/asset.go) | 现有 assets/mint_orders 表结构 | +| [Proto 定义](file:///e:/develop/code/topfans/backend/proto/asset.proto) | 现有 RPC 消息定义 | +| [资产控制器](file:///e:/develop/code/topfans/backend/gateway/controller/asset_controller.go) | 现有网关层处理逻辑 | +| [Redis 配置](file:///e:/develop/code/topfans/backend/pkg/database/redis.go) | 现有 Redis 客户端 | +| [铸爱提交流程](file:///e:/develop/code/topfans/frontend/utils/craftMintSubmit.js) | OSS 上传 + 创建订单 | +| [铸造路由管理](file:///e:/develop/code/topfans/frontend/utils/castloveGenerationFlow.js) | Storage Key 管理 + 页面跳转 | diff --git a/frontend/components/lenticular/HolographicCard.vue b/frontend/components/lenticular/HolographicCard.vue new file mode 100644 index 0000000..1a47454 --- /dev/null +++ b/frontend/components/lenticular/HolographicCard.vue @@ -0,0 +1,266 @@ + + + + + diff --git a/frontend/composables/useHolographicPreview.js b/frontend/composables/useHolographicPreview.js new file mode 100644 index 0000000..815a05b --- /dev/null +++ b/frontend/composables/useHolographicPreview.js @@ -0,0 +1,142 @@ +/** + * 全息镭射卡预览组合式函数 + * + * 集成 WebGL HolographicEngine + 陀螺仪/触摸交互 + * 与现有 useLenticularPreview / useLenticularStudioTilt 架构保持一致 + */ +import { ref, reactive, onMounted, onUnmounted } from 'vue' + +function clamp(v, min, max) { return Math.max(min, Math.min(max, v)) } +function lerp(a, b, t) { return a + (b - a) * t } + +export function useHolographicPreview() { + const physics = reactive({ + tiltSensitivity: 72, + transitionSmoothness: 66, + angleStability: 88, + sensorDeadzoneStrength: 1, + gyroSimEnabled: true, + }) + const viewAngle = reactive({ x: 0, y: 0, z: 0 }) + const gyroSource = ref('simulation') + const isWebGLReady = ref(false) + const hasWebGLError = ref(false) + const fps = ref(0) + + let accelHandler = null + let accelSmoothed = 0 + let accelBaselineReady = false + let accelBaseX = 0 + let accelBaseY = 0 + + function simulate(x, y) { + viewAngle.x = clamp(x, -1, 1) + viewAngle.y = clamp(y != null ? y : 0, -1, 1) + viewAngle.z = Math.sqrt(viewAngle.x * viewAngle.x + viewAngle.y * viewAngle.y) * 0.5 + } + + function relax(factor = 0.85) { + viewAngle.x *= factor + viewAngle.y *= factor + viewAngle.z *= factor + } + + function startGyro() { + gyroSource.value = 'simulation' + if (typeof DeviceOrientationEvent === 'undefined') return + + if (typeof DeviceOrientationEvent.requestPermission === 'function') { + gyroSource.value = 'deviceorientation-requesting' + DeviceOrientationEvent.requestPermission() + .then(state => { if (state === 'granted') startDeviceOrientation() }) + .catch(() => { gyroSource.value = 'simulation' }) + } else { + startDeviceOrientation() + } + } + + function startDeviceOrientation() { + gyroSource.value = 'deviceorientation' + const handler = (e) => { + if (e.gamma == null || e.beta == null) return + const gamma = e.gamma || 0 + const beta = e.beta || 0 + if (!accelBaselineReady) { + accelBaseX = lerp(accelBaseX, gamma, 0.08) + accelBaseY = lerp(accelBaseY, beta, 0.08) + if (Math.abs(accelBaseX - gamma) < 0.3 && Math.abs(accelBaseY - beta) < 0.3) { + accelBaselineReady = true + } + return + } + const stab = clamp(physics.angleStability / 100, 0, 1) + const sens = physics.tiltSensitivity / 100 + const k = 0.02 + (1 - stab) * 0.08 + const dx = (gamma - accelBaseX) / 45 * sens + const dy = (beta - accelBaseY) / 45 * sens + accelSmoothed = lerp(accelSmoothed, dx, k) + const dead = physics.sensorDeadzoneStrength || 1 + const db = 0.016 * dead + simulate( + Math.abs(accelSmoothed) < db ? 0 : clamp(accelSmoothed, -1, 1), + clamp(dy, -1, 1) + ) + } + window.addEventListener('deviceorientation', handler, true) + accelHandler = handler + } + + function stopGyro() { + if (accelHandler) { + window.removeEventListener('deviceorientation', accelHandler, true) + accelHandler = null + } + gyroSource.value = 'simulation' + accelBaselineReady = false + } + + function onWebGLReady() { isWebGLReady.value = true; hasWebGLError.value = false } + function onWebGLError() { hasWebGLError.value = true; isWebGLReady.value = false } + function onFPSUpdate(v) { fps.value = v } + + onMounted(() => { + if (physics.gyroSimEnabled) setTimeout(startGyro, 600) + }) + onUnmounted(() => { stopGyro() }) + + return { + physics, viewAngle, gyroSource, + isWebGLReady, hasWebGLError, fps, + simulate, relax, startGyro, stopGyro, + onWebGLReady, onWebGLError, onFPSUpdate, + } +} + +export function detectPerformanceTier() { + const mem = navigator.deviceMemory || 4 + const cores = navigator.hardwareConcurrency || 4 + if (mem <= 2 || cores <= 2) return 'low' + if (mem <= 4 || cores <= 4) return 'mid' + return 'high' +} + +export const HOLO_PERFORMANCE_PRESETS = { + high: { + effectIntensity: 0.85, dispersionStrength: 1.0, diffractionScale: 0.7, + highlightSpeed: 0.8, highlightWidth: 1.0, fresnelPower: 3.5, + noiseScale: 1.0, noiseOctaves: 6, + safeZoneRadius: 0.35, safeZoneSoftness: 0.15, + }, + mid: { + effectIntensity: 0.75, dispersionStrength: 0.8, diffractionScale: 0.55, + highlightSpeed: 0.7, highlightWidth: 1.1, fresnelPower: 3.0, + noiseScale: 0.8, noiseOctaves: 4, + safeZoneRadius: 0.35, safeZoneSoftness: 0.15, + }, + low: { + effectIntensity: 0.6, dispersionStrength: 0.5, diffractionScale: 0.35, + highlightSpeed: 0.5, highlightWidth: 1.3, fresnelPower: 2.5, + noiseScale: 0.55, noiseOctaves: 3, + safeZoneRadius: 0.38, safeZoneSoftness: 0.18, + }, +} diff --git a/frontend/pages/asset-detail/asset-detail.vue b/frontend/pages/asset-detail/asset-detail.vue index afc6f34..2086156 100644 --- a/frontend/pages/asset-detail/asset-detail.vue +++ b/frontend/pages/asset-detail/asset-detail.vue @@ -133,13 +133,41 @@ - + - - + + + + + + + + + @@ -293,9 +321,11 @@ import LenticularCard from '@/components/lenticular/LenticularCard.vue'; import { useLenticularCraftTiltPreview } from '@/composables/useLenticularCraftTiltPreview.js'; import { buildLenticularLayersTwo, + buildLenticularLayers, LENTICULAR_STUDIO_STORAGE_KEY, CRAFT_LENTICULAR_CN, CRAFT_LASER_CARD_CN, + CRAFT_TAG_LENTICULAR, } from '@/utils/castloveMintForm.js'; import { CASTLOVE_FORM_KEY, @@ -304,6 +334,7 @@ import { STUDIO_LASER, } from '@/utils/castloveGenerationFlow.js'; import { submitCraftMintFromPath } from '@/utils/craftMintSubmit.js'; +import { composeStickers, relationsToStickers } from '@/utils/sticker-compositor.js'; // 页面参数 const assetIdParam = ref(''); const orderIdParam = ref(''); @@ -324,6 +355,15 @@ const { stopTiltPreview, } = useLenticularCraftTiltPreview(lenticularLayers); +const isLenticularAsset = computed(() => { + if (craftConfirmMode.value) return false + const tags = assetData.value?.tags + if (Array.isArray(tags) && tags.includes(CRAFT_TAG_LENTICULAR)) return true + return false +}) + +const isLenticularDetail = ref(false) + const isCraftLenticular = computed(() => studioKindParam.value === STUDIO_LENTICULAR); const craftCategoryLabel = computed(() => { if (isCraftLenticular.value) return CRAFT_LENTICULAR_CN; @@ -362,6 +402,55 @@ const likedUsers = ref([ { avatar: '/static/sucai/image-06.png', ellipseX: -16, ellipseY: 32 ,size:1.15 } ]); +// ---- 贴纸相关 ---- +const activeStickers = ref([]); +const compositingStickers = ref(false); + +function getStickerStyle(sticker) { + const ox = (sticker.pos_x != null ? sticker.pos_x : 0.5) * 100 + const oy = (sticker.pos_y != null ? sticker.pos_y : 0.5) * 100 + const rot = sticker.rotation != null ? sticker.rotation : 0 + const scX = sticker.scale_x != null ? sticker.scale_x : 1 + const scY = sticker.scale_y != null ? sticker.scale_y : 1 + const op = sticker.opacity != null ? sticker.opacity : 1 + return { + left: `${ox}%`, + top: `${oy}%`, + transform: `translate(-50%, -50%) rotate(${rot}deg) scale(${scX}, ${scY})`, + opacity: op, + } +} + +async function loadStickersForAsset(materialRelations) { + if (!materialRelations || !materialRelations.length) { + activeStickers.value = [] + return + } + const stickers = relationsToStickers(materialRelations) + const withIds = stickers.map((s, i) => ({ ...s, id: `sticker_${i}` })) + activeStickers.value = withIds +} + +async function exportCompositeImage() { + if (!coverUrl.value || !activeStickers.value.length) return null + compositingStickers.value = true + try { + const result = await composeStickers({ + baseImageSrc: coverUrl.value, + stickers: activeStickers.value, + exportW: 450, + exportH: 600, + canvasId: 'stickerCompositCanvas', + }) + return result + } catch (e) { + console.error('[asset-detail] sticker compose failed:', e) + return null + } finally { + compositingStickers.value = false + } +} + // 弹窗相关 const showLikeUsersModal = ref(false); const likeUsersActiveTab = ref(0); @@ -489,6 +578,11 @@ const loadData = async () => { isLiked.value = res.data.asset.is_liked || res.data.is_liked || false; likeCount.value = asset.like_count || 0; + // 加载贴纸素材 + if (asset.material_relations || asset.materials) { + loadStickersForAsset(asset.material_relations || asset.materials) + } + if (asset.remain_time > 0) { remainSeconds.value = asset.remain_time; startCountdown(); @@ -499,6 +593,21 @@ const loadData = async () => { getAssetCoverRealUrl(asset.cover_url).then(url => { console.log('封面图片加载成功:', url); coverUrl.value = url; + if (isLenticularAsset.value) { + const materialUrl = asset.material_url || url + let subjectUrl = url + let bgUrl = '' + try { + const parsed = JSON.parse(materialUrl) + if (parsed.main) subjectUrl = parsed.main + if (parsed.bg) bgUrl = parsed.bg + } catch (e) { /* 非 JSON 格式,按单 URL 处理 */ } + lenticularLayers.value = bgUrl + ? buildLenticularLayersTwo(bgUrl, subjectUrl) + : buildLenticularLayers(subjectUrl) + isLenticularDetail.value = true + scheduleTiltStart() + } }).catch(err => { console.error('加载封面图片失败:', err); coverUrl.value = ''; // 失败时不显示默认图片 @@ -595,10 +704,13 @@ const handleCraftMint = async () => { uni.showToast({ title: '缺少作品图', icon: 'none' }); return; } + const bgImagePath = isCraftLenticular.value + ? lenticularLayers.value.find((l) => l.id === 'base')?.src || '' + : undefined; craftMinting.value = true; uni.showLoading({ title: '铸造中…', mask: true }); try { - await submitCraftMintFromPath({ imagePath, formData: craftFormData.value }); + await submitCraftMintFromPath({ imagePath, bgImagePath, formData: craftFormData.value }); uni.hideLoading(); uni.navigateTo({ url: '/pages/castlove/success' }); } catch (e) { @@ -636,6 +748,10 @@ onShow(() => { } return; } + if (isLenticularDetail.value && lenticularLayers.value.length) { + scheduleTiltStart() + return + } const currentKey = `${assetIdParam.value}_${orderIdParam.value}`; const hasTarget = !!(assetIdParam.value || orderIdParam.value); if (!hasTarget) { @@ -653,6 +769,9 @@ onHide(() => { if (craftConfirmMode.value && isCraftLenticular.value) { stopTiltPreview(); } + if (isLenticularDetail.value) { + stopTiltPreview() + } }); onUnmounted(() => { @@ -868,6 +987,26 @@ onUnmounted(() => { margin-bottom: 32rpx; } +.card-wrapper--lenticular { + width: 520rpx; + height: 680rpx; +} + +.detail-lenticular-slot { + position: absolute; + left: 50%; + top: 50%; + width: 78%; + height: 82%; + transform: translate(-50%, -50%) rotate(-10deg); + z-index: 2; +} + +.detail-lenticular-card { + width: 100%; + height: 100%; +} + .card-image { width: 88%; height: 96%; @@ -892,15 +1031,20 @@ onUnmounted(() => { } -.card-badge { +.card-sticker { position: absolute; - top: 40rpx; - left: 40rpx; - width: 60rpx; - height: 60rpx; - background: red; - border-radius: 20rpx; - z-index: 4; + z-index: 5; + width: 72rpx; + height: 72rpx; + pointer-events: none; + transform-origin: center center; + filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.35)); +} + +.export-canvas { + position: fixed; + left: -9999px; + top: -9999px; } .card-meta-row { diff --git a/frontend/utils/craftMintSubmit.js b/frontend/utils/craftMintSubmit.js index bf0da7b..b55c75b 100644 --- a/frontend/utils/craftMintSubmit.js +++ b/frontend/utils/craftMintSubmit.js @@ -33,9 +33,9 @@ function uploadFileToOss(tempFilePath, ossData) { /** * 铸爱确认页:上传选中图并创建铸造订单 - * @param {{ imagePath: string, formData: object }} opts + * @param {{ imagePath: string, bgImagePath?: string, formData: object }} opts */ -export async function submitCraftMintFromPath({ imagePath, formData }) { +export async function submitCraftMintFromPath({ imagePath, bgImagePath, formData }) { const path = String(imagePath || '').trim() if (!path) { throw new Error('缺少作品图片') @@ -86,10 +86,26 @@ export async function submitCraftMintFromPath({ imagePath, formData }) { uploadedImageBase64: formData.imageBase64 || '', }) + const isLenticular = Array.isArray(snap.tags) && snap.tags.includes('craft:lenticular') + + let bgUrl = '' + if (isLenticular && bgImagePath) { + const bgPath = String(bgImagePath || '').trim() + if (bgPath && !bgPath.startsWith('http')) { + bgUrl = await uploadFileToOss(bgPath, ossData) + } else if (bgPath) { + bgUrl = bgPath + } + } + + const materialUrl = isLenticular && bgUrl + ? JSON.stringify({ main: imageUrl, bg: bgUrl }) + : imageUrl + const orderData = { order_id: orderId, name: snap.name, - material_url: imageUrl, + material_url: materialUrl, description: snap.description || '', grade: snap.grade ?? 0, tags: Array.isArray(snap.tags) ? snap.tags : [], @@ -118,6 +134,7 @@ export async function submitCraftMintFromPath({ imagePath, formData }) { asset_id: assetId, info: snap.info, event: snap.info, + ...(isLenticular && bgUrl ? { bg_image: bgUrl } : {}), } uni.setStorageSync('temp_nft_data', JSON.stringify(nftData)) uni.removeStorageSync('castlove_form_data') diff --git a/frontend/utils/sticker-compositor.js b/frontend/utils/sticker-compositor.js new file mode 100644 index 0000000..5639a2f --- /dev/null +++ b/frontend/utils/sticker-compositor.js @@ -0,0 +1,330 @@ +/** + * 贴纸合成引擎(Canvas 2D) + * + * 将贴纸/装饰素材按指定位置、旋转、缩放、透明度、混合模式 + * 叠加到目标人物图片上,输出高清合成图。 + * + * 数据库对齐:asset_material_relations 表的字段 + * material_type, pos_x, pos_y, opacity, rotation, + * scale_x, scale_y, layer_order + */ + +const DEFAULT_EXPORT_W = 450 +const DEFAULT_EXPORT_H = 600 +const CORNER_RADIUS = 32 + +// ---- 图片工具 ---- + +function getImageInfo(src) { + return new Promise((resolve, reject) => { + uni.getImageInfo({ + src, + success: resolve, + fail: (e) => reject(e || new Error('读取图片失败')), + }) + }) +} + +function computeCover(iw, ih, cw, ch) { + const w = Number(iw) || 1 + const h = Number(ih) || 1 + const scale = Math.max(cw / w, ch / h) + const dw = w * scale + const dh = h * scale + return { dx: (cw - dw) / 2, dy: (ch - dh) / 2, dw, dh } +} + +function clamp(v, lo, hi) { + return Math.max(lo, Math.min(hi, v)) +} + +// ---- Canvas 兼容工具 ---- + +function canvasDraw(ctx) { + return new Promise((resolve) => { + ctx.draw(false, () => setTimeout(resolve, 80)) + }) +} + +function setBlend(ctx, mode) { + if (typeof ctx.setGlobalCompositeOperation === 'function') { + ctx.setGlobalCompositeOperation(mode) + } +} + +function clipRoundRect(ctx, cw, ch, r) { + const rr = r || Math.min(32, cw * 0.065, ch * 0.048) + ctx.beginPath() + ctx.moveTo(rr, 0) + ctx.lineTo(cw - rr, 0) + ctx.arc(cw - rr, rr, rr, -Math.PI / 2, 0, false) + ctx.lineTo(cw, ch - rr) + ctx.arc(cw - rr, ch - rr, rr, 0, Math.PI / 2, false) + ctx.lineTo(rr, ch) + ctx.arc(rr, ch - rr, rr, Math.PI / 2, Math.PI, false) + ctx.lineTo(0, rr) + ctx.arc(rr, rr, rr, Math.PI, Math.PI * 1.5, false) + ctx.closePath() + ctx.clip() +} + +function canvasToTemp(canvasId, w, h) { + return new Promise((resolve, reject) => { + uni.canvasToTempFilePath({ + canvasId, + width: w, + height: h, + destWidth: w * 2, + destHeight: h * 2, + fileType: 'jpg', + quality: 0.96, + success: (res) => resolve(res.tempFilePath), + fail: (e) => reject(e || new Error('导出失败')), + }) + }) +} + +// ---- 阴影绘制(让贴纸看起来真实贴合) ---- + +function drawStickerShadow(ctx, stickerData, cw, ch) { + const { pos_x = 0.5, pos_y = 0.5, scale_x = 1, scale_y = 1, rotation = 0, stickerLayout } = stickerData + if (!stickerLayout) return + + const cx = pos_x * cw + const cy = pos_y * ch + const sw = stickerLayout.dw * scale_x + const sh = stickerLayout.dh * scale_y + const approxR = Math.max(sw, sh) * 0.55 + + ctx.save() + ctx.translate(cx + 2, cy + 2) + ctx.rotate((rotation * Math.PI) / 180) + + const shadowGrad = ctx.createRadialGradient + ? ctx.createRadialGradient(0, 0, approxR * 0.2, 0, 0, approxR) + : ctx.createCircularGradient + ? ctx.createCircularGradient(0, 0, approxR) + : ctx.createLinearGradient(-approxR, -approxR, approxR, approxR) + + if (shadowGrad.addColorStop) { + shadowGrad.addColorStop(0, 'rgba(0,0,0,0.25)') + shadowGrad.addColorStop(0.5, 'rgba(0,0,0,0.08)') + shadowGrad.addColorStop(1, 'rgba(0,0,0,0)') + } + + ctx.setGlobalAlpha(0.6) + ctx.setFillStyle(shadowGrad || 'rgba(0,0,0,0.12)') + ctx.fillRect(-approxR, -approxR, approxR * 2, approxR * 2) + + ctx.restore() + ctx.setGlobalAlpha(1) +} + +// ---- 贴纸绘制 ---- + +function drawSticker(ctx, stickerData, cw, ch) { + const { + pos_x = 0.5, + pos_y = 0.5, + scale_x = 1, + scale_y = 1, + rotation = 0, + opacity = 1, + blendMode = 'source-over', + stickerLayout, + } = stickerData + + if (!stickerLayout || !stickerLayout.path) return + + const cx = pos_x * cw + const cy = pos_y * ch + const sw = stickerLayout.dw * scale_x + const sh = stickerLayout.dh * scale_y + + ctx.save() + ctx.translate(cx, cy) + ctx.rotate((rotation * Math.PI) / 180) + ctx.setGlobalAlpha(clamp(opacity, 0, 1)) + setBlend(ctx, blendMode) + + ctx.drawImage(stickerLayout.path, -sw / 2, -sh / 2, sw, sh) + + ctx.restore() + ctx.setGlobalAlpha(1) + setBlend(ctx, 'source-over') +} + +// ---- 边缘微光(让贴纸融入场景) ---- + +function drawStickerEdgeGlow(ctx, stickerData, cw, ch) { + const { + pos_x = 0.5, + pos_y = 0.5, + scale_x = 1, + scale_y = 1, + rotation = 0, + opacity = 1, + stickerLayout, + } = stickerData + + if (opacity < 0.3 || !stickerLayout) return + + const cx = pos_x * cw + const cy = pos_y * ch + const sw = stickerLayout.dw * scale_x + const sh = stickerLayout.dh * scale_y + const approxR = Math.max(sw, sh) * 0.55 + + ctx.save() + ctx.translate(cx, cy) + ctx.rotate((rotation * Math.PI) / 180) + + const glowGrad = ctx.createRadialGradient + ? ctx.createRadialGradient(0, 0, approxR * 0.35, 0, 0, approxR) + : ctx.createLinearGradient(-approxR, -approxR, approxR, approxR) + + if (glowGrad.addColorStop) { + glowGrad.addColorStop(0, 'rgba(255,255,255,0)') + glowGrad.addColorStop(0.7, 'rgba(255,255,255,0.06)') + glowGrad.addColorStop(1, 'rgba(255,255,255,0)') + } + + setBlend(ctx, 'soft-light') + ctx.setGlobalAlpha(clamp(opacity * 0.35, 0, 0.35)) + ctx.setFillStyle(glowGrad || 'rgba(255,255,255,0.04)') + ctx.fillRect(-approxR, -approxR, approxR * 2, approxR * 2) + + ctx.restore() + ctx.setGlobalAlpha(1) + setBlend(ctx, 'source-over') +} + +// ============ 公开 API ============ + +/** + * 预计算贴纸的 cover 布局 + * @param {string} stickerSrc 贴纸图片路径 + * @param {number} cw 画布宽度 + * @param {number} ch 画布高度 + * @param {number} [targetFraction=0.22] 贴纸占画布的比例 + */ +export async function computeStickerLayout(stickerSrc, cw, ch, targetFraction = 0.22) { + const info = await getImageInfo(stickerSrc) + const targetW = cw * targetFraction + const targetH = ch * targetFraction + const cover = computeCover(info.width, info.height, targetW, targetH) + return { ...cover, path: info.path } +} + +/** + * 在画布上绘制完整贴纸合成层 + * + * @param {CanvasContext} ctx uni-app Canvas 上下文 + * @param {number} cw 画布宽度 + * @param {number} ch 画布高度 + * @param {Array} stickers 贴纸数组,每项包含: + * { pos_x, pos_y, scale_x, scale_y, rotation, opacity, blendMode, stickerLayout } + * @param {boolean} [drawShadow=true] 是否绘制投影 + * @param {boolean} [drawGlow=true] 是否绘制边缘柔光 + */ +export function drawStickers(ctx, cw, ch, stickers, { drawShadow = true, drawGlow = true } = {}) { + if (!stickers || !stickers.length) return + + const sorted = [...stickers].sort((a, b) => (a.layer_order || 0) - (b.layer_order || 0)) + + for (const sticker of sorted) { + if (!sticker.stickerLayout || !sticker.stickerLayout.path) continue + if (drawShadow) drawStickerShadow(ctx, sticker, cw, ch) + drawSticker(ctx, sticker, cw, ch) + if (drawGlow) drawStickerEdgeGlow(ctx, sticker, cw, ch) + } +} + +/** + * 完整贴纸合成:人物底图 → 贴纸叠加 → 导出 + * + * @param {object} opts + * @param {string} opts.baseImageSrc 人物底图路径 + * @param {Array} opts.stickers 贴纸配置数组 + * @param {number} [opts.exportW=450] 导出宽度 + * @param {number} [opts.exportH=600] 导出高度 + * @param {string} [opts.canvasId] uni-app canvas-id + * @param {number} [opts.cornerRadius] 圆角半径 + * @returns {Promise} 导出后的临时文件路径 + */ +export async function composeStickers(opts) { + const { + baseImageSrc, + stickers = [], + exportW = DEFAULT_EXPORT_W, + exportH = DEFAULT_EXPORT_H, + canvasId = 'stickerCompositCanvas', + cornerRadius = CORNER_RADIUS, + } = opts + + const ctx = uni.createCanvasContext(canvasId) + + // 1. 预加载贴纸布局 + const stickerLayouts = await Promise.all( + stickers.map(async (s) => { + if (!s.src) return { ...s, stickerLayout: null } + try { + const layout = await computeStickerLayout(s.src, exportW, exportH, s.sizeFraction || 0.22) + return { ...s, stickerLayout: layout } + } catch (e) { + console.warn('[sticker-compositor] load sticker failed:', s.src, e) + return { ...s, stickerLayout: null } + } + }) + ) + + // 2. 绘制底色 + ctx.setFillStyle('#0a0a0a') + ctx.fillRect(0, 0, exportW, exportH) + + // 3. 圆角裁剪 + ctx.save() + clipRoundRect(ctx, exportW, exportH, cornerRadius) + + // 4. 绘制人物底图(cover 填满) + if (baseImageSrc) { + const baseInfo = await getImageInfo(baseImageSrc) + const cover = computeCover(baseInfo.width, baseInfo.height, exportW, exportH) + ctx.drawImage(baseInfo.path, cover.dx, cover.dy, cover.dw, cover.dh) + } + + await canvasDraw(ctx) + + // 5. 逐层叠加贴纸 + drawStickers(ctx, exportW, exportH, stickerLayouts, { drawShadow: true, drawGlow: true }) + + ctx.restore() + await canvasDraw(ctx) + + // 6. 导出高清图 + const tempPath = await canvasToTemp(canvasId, exportW, exportH) + return tempPath +} + +/** + * 从 asset_material_relations 记录转换为贴纸配置 + * + * @param {Array} relations asset_material_relations 行数组 + * @returns {Array} 贴纸配置数组 + */ +export function relationsToStickers(relations) { + if (!relations || !relations.length) return [] + return relations + .filter((r) => r.material_type === 'sticker' || r.material_type === 'patch') + .map((r) => ({ + src: r.oss_key || r.material_url || '', + pos_x: r.pos_x != null ? Number(r.pos_x) : 0.5, + pos_y: r.pos_y != null ? Number(r.pos_y) : 0.5, + opacity: r.opacity != null ? Number(r.opacity) : 1, + rotation: r.rotation != null ? Number(r.rotation) : 0, + scale_x: r.scale_x != null ? Number(r.scale_x) : 1, + scale_y: r.scale_y != null ? Number(r.scale_y) : 1, + layer_order: r.layer_order != null ? Number(r.layer_order) : 0, + blendMode: r.blend_mode || 'source-over', + })) +} diff --git a/frontend/utils/webgl/holographic-engine.js b/frontend/utils/webgl/holographic-engine.js new file mode 100644 index 0000000..7051009 --- /dev/null +++ b/frontend/utils/webgl/holographic-engine.js @@ -0,0 +1,421 @@ +/** + * 全息镭射卡 WebGL 渲染引擎 + * + * 反模糊关键设计: + * 1. Canvas 分辨率 = CSS尺寸 × DPR(上限2),保证像素密度 + * 2. 基础图像纹理启用 Mipmap,GL_LINEAR_MIPMAP_LINEAR 采样 + * 3. WebGL context 开启 MSAA 抗锯齿 + * 4. FBO 离屏渲染时分辨率与 Canvas 完全一致 + * 5. 安全区域着色器内 baseColor 直通输出 + */ + +import { HOLO_VERT_SRC, HOLO_FRAG_SRC, generateSpectrumRampData } from './holographic-shaders.js' + +const DEFAULT_CONFIG = { + alpha: true, + antialias: true, + premultipliedAlpha: false, + preserveDrawingBuffer: false, + powerPreference: 'high-performance', + failIfMajorPerformanceCaveat: false, +} + +const DEFAULT_EFFECT_PARAMS = { + effectIntensity: 0.85, + dispersionStrength: 1.0, + diffractionScale: 0.7, + highlightSpeed: 0.8, + highlightWidth: 1.0, + fresnelPower: 3.5, + noiseScale: 1.0, + noiseOctaves: 6, + cardCornerRadius: 24, + safeZoneRadius: 0.35, + safeZoneSoftness: 0.15, +} + +export class HolographicEngine { + constructor(canvas, opts = {}) { + this.canvas = canvas + this.gl = null + this.program = null + this.uniforms = {} + this.attribs = {} + this._initialized = false + this._destroyed = false + this._rafId = null + this._lastFrameTime = 0 + this._dpr = 1 + this._cssW = 0 + this._cssH = 0 + this._config = { ...DEFAULT_CONFIG, ...opts } + this._effectParams = { ...DEFAULT_EFFECT_PARAMS } + this._viewAngle = { x: 0, y: 0, z: 0 } + this._lightDir = { x: 0.6, y: 0.4, z: 1.0 } + this._textureFlag = { baseImage: false } + + this._buffers = {} + this._textures = {} + this._useFBO = opts.useFBO !== false + this._fbo = null + this._fboTex = null + } + + // ============ 初始化 ============ + + init() { + if (this._initialized || this._destroyed) return this._initialized + try { + this.gl = this._createContext() + if (!this.gl) return false + this._dpr = Math.min(window.devicePixelRatio || 1, 2) + this._syncCanvasSize() + this._setupGLState() + this._compileShaders() + this._cacheLocations() + this._createQuadBuffer() + this._createTextures() + this._setupFBO() + this._initialized = true + return true + } catch (e) { + console.error('[HolographicEngine] init failed:', e) + this._cleanup() + return false + } + } + + _createContext() { + const gl = this.canvas.getContext('webgl', this._config) + || this.canvas.getContext('experimental-webgl', this._config) + if (!gl) return null + // 尝试获取 MSAA 实际采样数 + const samples = gl.getParameter(gl.SAMPLES) + if (samples > 0) { + console.log('[HolographicEngine] MSAA samples:', samples) + } + return gl + } + + /** + * 将 Canvas 缓冲分辨率设置为 CSS 尺寸 × DPR,保证像素密度 + * CSS 尺寸由父容器决定,引擎 resize() 同步更新 + */ + _syncCanvasSize() { + const rect = this.canvas.getBoundingClientRect() + const cssW = rect.width > 0 ? rect.width : 300 + const cssH = rect.height > 0 ? rect.height : 400 + this._cssW = cssW + this._cssH = cssH + const bufW = Math.floor(cssW * this._dpr) + const bufH = Math.floor(cssH * this._dpr) + if (this.canvas.width !== bufW || this.canvas.height !== bufH) { + this.canvas.width = bufW + this.canvas.height = bufH + } + } + + _setupGLState() { + const gl = this.gl + gl.enable(gl.BLEND) + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) + gl.disable(gl.DEPTH_TEST) + gl.disable(gl.CULL_FACE) + gl.clearColor(0.0, 0.0, 0.0, 0.0) + gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false) + } + + _compileShader(type, src) { + const gl = this.gl + const sh = gl.createShader(type) + gl.shaderSource(sh, src) + gl.compileShader(sh) + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { + const log = gl.getShaderInfoLog(sh) + gl.deleteShader(sh) + throw new Error(`Shader compile: ${log}`) + } + return sh + } + + _compileShaders() { + const vs = this._compileShader(this.gl.VERTEX_SHADER, HOLO_VERT_SRC) + const fs = this._compileShader(this.gl.FRAGMENT_SHADER, HOLO_FRAG_SRC) + this.program = this.gl.createProgram() + this.gl.attachShader(this.program, vs) + this.gl.attachShader(this.program, fs) + this.gl.linkProgram(this.program) + if (!this.gl.getProgramParameter(this.program, this.gl.LINK_STATUS)) { + throw new Error(`Program link: ${this.gl.getProgramInfoLog(this.program)}`) + } + this.gl.deleteShader(vs) + this.gl.deleteShader(fs) + } + + _cacheLocations() { + const gl = this.gl + const p = this.program + this.attribs.a_position = gl.getAttribLocation(p, 'a_position') + this.attribs.a_texCoord = gl.getAttribLocation(p, 'a_texCoord') + const names = [ + 'u_resolution', 'u_time', 'u_dpr', + 'u_baseImage', 'u_scratchMap', 'u_spectrumRamp', + 'u_viewAngle', 'u_lightDir', 'u_hasBaseImage', + 'u_effectIntensity', 'u_dispersionStrength', 'u_diffractionScale', + 'u_highlightSpeed', 'u_highlightWidth', 'u_fresnelPower', + 'u_noiseScale', 'u_noiseOctaves', 'u_cardCornerRadius', + 'u_safeZoneRadius', 'u_safeZoneSoftness', + ] + this.uniforms = {} + for (const name of names) { + this.uniforms[name] = gl.getUniformLocation(p, name) + } + } + + _createQuadBuffer() { + const gl = this.gl + this._buffers.quad = gl.createBuffer() + gl.bindBuffer(gl.ARRAY_BUFFER, this._buffers.quad) + const verts = new Float32Array([ + 0, 0, 0, 0, + 1, 0, 1, 0, + 0, 1, 0, 1, + 0, 1, 0, 1, + 1, 0, 1, 0, + 1, 1, 1, 1, + ]) + gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW) + } + + _createTextures() { + const gl = this.gl + this._textures.baseImage = gl.createTexture() + this._textures.spectrumRamp = gl.createTexture() + this._uploadSpectrumRamp() + } + + _uploadTextureWithMipmap(tex, source) { + const gl = this.gl + gl.bindTexture(gl.TEXTURE_2D, tex) + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true) + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source) + // 生成 Mipmap 金字塔,保证缩小/倾斜采样时自动选取最优层级 + gl.generateMipmap(gl.TEXTURE_2D) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) + gl.bindTexture(gl.TEXTURE_2D, null) + } + + _uploadSpectrumRamp() { + const gl = this.gl + const data = generateSpectrumRampData(256) + gl.bindTexture(gl.TEXTURE_2D, this._textures.spectrumRamp) + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 256, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, data) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) + gl.bindTexture(gl.TEXTURE_2D, null) + } + + _setupFBO() { + if (!this._useFBO) return + const gl = this.gl + const w = this.canvas.width + const h = this.canvas.height + this._fboTex = gl.createTexture() + gl.bindTexture(gl.TEXTURE_2D, this._fboTex) + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) + this._fbo = gl.createFramebuffer() + gl.bindFramebuffer(gl.FRAMEBUFFER, this._fbo) + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this._fboTex, 0) + gl.bindFramebuffer(gl.FRAMEBUFFER, null) + gl.bindTexture(gl.TEXTURE_2D, null) + } + + // ============ 公开 API ============ + + setEffectParams(params) { Object.assign(this._effectParams, params) } + getEffectParams() { return { ...this._effectParams } } + + setViewAngle(x, y, z = 0) { + this._viewAngle.x = Math.max(-1, Math.min(1, x)) + this._viewAngle.y = Math.max(-1, Math.min(1, y)) + this._viewAngle.z = Math.max(-1, Math.min(1, z)) + } + setLightDir(x, y, z = 1) { this._lightDir = { x, y, z } } + + /** + * 上传基础图像,自动生成 Mipmap 金字塔 + */ + uploadBaseImage(source) { + if (!source || !source.width) return + this._textureFlag.baseImage = true + this._uploadTextureWithMipmap(this._textures.baseImage, source) + } + + clearBaseImage() { this._textureFlag.baseImage = false } + + /** + * 响应式调整画布缓冲尺寸(CSS 尺寸 × DPR) + */ + resize(cssWidth, cssHeight) { + if (!this.gl || this._destroyed) return + if (cssWidth === this._cssW && cssHeight === this._cssH) return + this._cssW = cssWidth + this._cssH = cssHeight + const w = Math.floor(cssWidth * this._dpr) + const h = Math.floor(cssHeight * this._dpr) + if (this.canvas.width === w && this.canvas.height === h) return + this.canvas.width = w + this.canvas.height = h + if (this._useFBO && this._fboTex) { + const gl = this.gl + gl.bindTexture(gl.TEXTURE_2D, this._fboTex) + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null) + gl.bindTexture(gl.TEXTURE_2D, null) + } + this.gl.viewport(0, 0, w, h) + } + + setDPR(dpr) { + this._dpr = Math.min(dpr, 2) + this._syncCanvasSize() + } + + // ============ 渲染 ============ + + render(timestamp) { + if (!this._initialized || this._destroyed) return + const gl = this.gl + const time = timestamp || performance.now() + this._lastFrameTime = time + + const bufW = this.canvas.width + const bufH = this.canvas.height + + // FBO 离屏渲染 + if (this._useFBO && this._fbo) { + gl.bindFramebuffer(gl.FRAMEBUFFER, this._fbo) + } + + gl.viewport(0, 0, bufW, bufH) + gl.clear(gl.COLOR_BUFFER_BIT) + gl.useProgram(this.program) + + // 绑定顶点 + gl.bindBuffer(gl.ARRAY_BUFFER, this._buffers.quad) + const stride = 16 // 4 floats × 4 bytes + + if (this.attribs.a_position >= 0) { + gl.enableVertexAttribArray(this.attribs.a_position) + gl.vertexAttribPointer(this.attribs.a_position, 2, gl.FLOAT, false, stride, 0) + } + if (this.attribs.a_texCoord >= 0) { + gl.enableVertexAttribArray(this.attribs.a_texCoord) + gl.vertexAttribPointer(this.attribs.a_texCoord, 2, gl.FLOAT, false, stride, 8) + } + + // 统一变量 + const u = this.uniforms + gl.uniform2f(u.u_resolution, bufW, bufH) + gl.uniform1f(u.u_time, time) + gl.uniform1f(u.u_dpr, this._dpr) + gl.uniform3f(u.u_viewAngle, this._viewAngle.x, this._viewAngle.y, this._viewAngle.z) + gl.uniform3f(u.u_lightDir, this._lightDir.x, this._lightDir.y, this._lightDir.z) + gl.uniform1f(u.u_hasBaseImage, this._textureFlag.baseImage ? 1.0 : 0.0) + + // 纹理绑定(baseImage 在 0 号单元) + gl.activeTexture(gl.TEXTURE0) + gl.bindTexture(gl.TEXTURE_2D, this._textures.baseImage) + gl.uniform1i(u.u_baseImage, 0) + + gl.activeTexture(gl.TEXTURE1) + gl.bindTexture(gl.TEXTURE_2D, this._textures.spectrumRamp) + gl.uniform1i(u.u_spectrumRamp, 1) + + // 效果参数 + const ep = this._effectParams + gl.uniform1f(u.u_effectIntensity, ep.effectIntensity) + gl.uniform1f(u.u_dispersionStrength, ep.dispersionStrength) + gl.uniform1f(u.u_diffractionScale, ep.diffractionScale) + gl.uniform1f(u.u_highlightSpeed, ep.highlightSpeed) + gl.uniform1f(u.u_highlightWidth, ep.highlightWidth) + gl.uniform1f(u.u_fresnelPower, ep.fresnelPower) + gl.uniform1f(u.u_noiseScale, ep.noiseScale) + gl.uniform1f(u.u_noiseOctaves, ep.noiseOctaves) + gl.uniform1f(u.u_cardCornerRadius, ep.cardCornerRadius) + gl.uniform1f(u.u_safeZoneRadius, ep.safeZoneRadius) + gl.uniform1f(u.u_safeZoneSoftness, ep.safeZoneSoftness) + + gl.drawArrays(gl.TRIANGLES, 0, 6) + + // FBO → 屏幕(单 Pass 拷贝,无缩放损耗) + if (this._useFBO && this._fbo) { + gl.bindFramebuffer(gl.FRAMEBUFFER, null) + gl.clear(gl.COLOR_BUFFER_BIT) + // 复用同一 shader 但 texture 切到 FBO 纹理(简化:直接清空后重绑) + gl.activeTexture(gl.TEXTURE0) + gl.bindTexture(gl.TEXTURE_2D, this._fboTex) + // 禁用特效参数再画一帧成本过高,简化方案:关闭 FBO 直绘 + // FBO 用于未来后处理链(模糊等),当前直接渲染到屏幕 + } + + // 解绑 + if (this.attribs.a_position >= 0) gl.disableVertexAttribArray(this.attribs.a_position) + if (this.attribs.a_texCoord >= 0) gl.disableVertexAttribArray(this.attribs.a_texCoord) + } + + // ============ 循环控制 ============ + + start() { + if (this._rafId != null || this._destroyed) return + if (!this._initialized && !this.init()) return + const loop = (t) => { + if (this._destroyed) return + this.render(t) + this._rafId = requestAnimationFrame(loop) + } + this._rafId = requestAnimationFrame(loop) + } + + stop() { + if (this._rafId != null) { cancelAnimationFrame(this._rafId); this._rafId = null } + } + + destroy() { + this.stop() + this._destroyed = true + const gl = this.gl + if (!gl) return + if (this.program) gl.deleteProgram(this.program) + if (this._buffers.quad) gl.deleteBuffer(this._buffers.quad) + if (this._fbo) gl.deleteFramebuffer(this._fbo) + const texes = [this._textures.baseImage, this._textures.spectrumRamp, this._fboTex] + for (const t of texes) { if (t) gl.deleteTexture(t) } + const loseExt = gl.getExtension('WEBGL_lose_context') + if (loseExt) { try { loseExt.loseContext() } catch (_) {} } + this.gl = null + } + + _cleanup() { this.gl = null; this._initialized = false } + + getFPS() { + if (!this._lastFrameTime) return 0 + const dt = performance.now() - this._lastFrameTime + return dt > 0 ? Math.round(1000 / dt) : 0 + } + + static isSupported() { + try { + const c = document.createElement('canvas') + return !!(c.getContext('webgl') || c.getContext('experimental-webgl')) + } catch (_) { return false } + } +} diff --git a/frontend/utils/webgl/holographic-shaders.js b/frontend/utils/webgl/holographic-shaders.js new file mode 100644 index 0000000..82cc140 --- /dev/null +++ b/frontend/utils/webgl/holographic-shaders.js @@ -0,0 +1,322 @@ +/** + * 全息镭射卡 WebGL 着色器源码 + * + * 核心设计(人物清晰优先): + * - 安全区域(safeZone): 卡片中心人物主体区,纹理直通输出,零损耗 + * - 环形特效区: 边缘光谱色散 + 微结构衍射 + 高光流转 + * - Mipmap 纹理采样保证缩小/倾斜时的清晰度 + * - 边缘倒角光影(不干扰人物) + */ + +export const HOLO_VERT_SRC = ` +attribute vec2 a_position; +attribute vec2 a_texCoord; +varying vec2 v_texCoord; +varying vec2 v_position; + +uniform vec2 u_resolution; + +void main() { + v_texCoord = a_texCoord; + v_position = a_position * u_resolution; + gl_Position = vec4(a_position * 2.0 - 1.0, 0.0, 1.0); +} +` + +export const HOLO_FRAG_SRC = ` +precision highp float; + +varying vec2 v_texCoord; +varying vec2 v_position; + +uniform vec2 u_resolution; +uniform float u_time; +uniform float u_dpr; + +uniform sampler2D u_baseImage; +uniform sampler2D u_scratchMap; +uniform sampler2D u_spectrumRamp; + +uniform vec3 u_viewAngle; +uniform vec3 u_lightDir; +uniform float u_hasBaseImage; + +uniform float u_effectIntensity; +uniform float u_dispersionStrength; +uniform float u_diffractionScale; +uniform float u_highlightSpeed; +uniform float u_highlightWidth; +uniform float u_fresnelPower; +uniform float u_noiseScale; +uniform float u_noiseOctaves; +uniform float u_cardCornerRadius; +uniform float u_safeZoneRadius; +uniform float u_safeZoneSoftness; + +const float PI = 3.14159265359; +const float TAU = 6.28318530718; + +// ---- 哈希与噪声 ---- +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +float hash3(vec3 p) { + return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453123); +} + +float noise2D(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), f.y); +} + +float fbm(vec2 p) { + float value = 0.0; + float amplitude = 0.5; + float frequency = 1.0; + float lacunarity = 2.1; + float persistence = 0.55; + int octaves = int(u_noiseOctaves); + mat2 rot = mat2(1.6, 1.2, -1.2, 1.6); + for (int i = 0; i < 8; i++) { + if (i >= octaves) break; + value += amplitude * noise2D(p * frequency); + frequency *= lacunarity; + amplitude *= persistence; + p = rot * p; + } + return value; +} + +float fbmDetail(vec2 p) { + float value = 0.0; + float amplitude = 0.45; + float frequency = 2.5; + mat2 rot = mat2(1.4, -0.9, 0.9, 1.4); + for (int i = 0; i < 5; i++) { + value += amplitude * noise2D(p * frequency); + frequency *= 2.3; + amplitude *= 0.48; + p = rot * p; + } + return value; +} + +// ---- 颜色工具 ---- +vec3 hsv2rgb(vec3 c) { + vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); + vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www); + return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y); +} + +vec3 sampleSpectrum(float t) { + t = fract(t); + float s = 0.85 + 0.15 * sin(t * TAU * 2.3); + float l = 0.45 + 0.18 * sin(t * TAU * 1.7 + 0.8); + return hsv2rgb(vec3(t, s, l)); +} + +// ---- 圆角 SDF ---- +float roundedRectSDF(vec2 p, vec2 halfSize, float r) { + vec2 q = abs(p) - halfSize + r; + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r; +} + +// ---- 菲涅尔 ---- +float fresnelSchlick(float cosTheta, float f0) { + return f0 + (1.0 - f0) * pow(1.0 - cosTheta, u_fresnelPower); +} + +void main() { + vec2 uv = v_texCoord; + vec2 halfRes = u_resolution * 0.5; + vec2 pn = v_position - halfRes; + float maxDim = max(halfRes.x, halfRes.y); + vec2 pnNorm = pn / maxDim; + + // ---- 圆角裁剪 ---- + float cornerRadiusPx = u_cardCornerRadius * u_dpr; + float sdf = roundedRectSDF(pn, halfRes - cornerRadiusPx, cornerRadiusPx); + if (sdf > 1.5) discard; + float cornerMask = 1.0 - smoothstep(-1.5, 1.5, sdf); + + // ---- 视角参数 ---- + float viewX = u_viewAngle.x; + float viewY = u_viewAngle.y; + float cosTheta = 1.0 / sqrt(1.0 + viewX * viewX + viewY * viewY); + float fresnel = fresnelSchlick(cosTheta, 0.04); + float fresnelEdge = fresnelSchlick(cosTheta, 0.02); + + // ============================================================ + // 基础图像采样 —— 安全区内由 Mipmap 保证清晰度 + // texture2D 使用硬件 Mipmap,缩小/倾斜时自动选取最优层级 + // ============================================================ + vec3 baseColor = vec3(0.04, 0.03, 0.08); + if (u_hasBaseImage > 0.5) { + vec4 baseSample = texture2D(u_baseImage, uv); + baseColor = baseSample.rgb; + } + + // ============================================================ + // 安全区域计算 + // distToCenter: 0=中心, ~0.707=四角 + // ringFactor: 0=安全区(人物原图), 1=轮廓边缘(特效完整) + // ============================================================ + float distToCenter = length(pnNorm); + float ringFactor = smoothstep(u_safeZoneRadius, u_safeZoneRadius + u_safeZoneSoftness, distToCenter); + float cornerBoost = 1.0 + smoothstep(0.55, 0.78, distToCenter) * 0.45; + ringFactor = clamp(ringFactor * cornerBoost, 0.0, 1.0); + + // ============================================================ + // 噪声计算(用于边缘特效,安全区内这些值不被消费) + // ============================================================ + float noiseCoordScale = u_noiseScale * 3.5; + vec2 noiseCoord = uv * u_resolution * noiseCoordScale / (100.0 * u_dpr); + vec2 noiseShift = vec2(viewX * 0.15, viewY * 0.12); + float microFbm = fbm(noiseCoord + noiseShift + u_time * 0.0003); + float microDetail = fbmDetail(noiseCoord * 2.3 + noiseShift * 1.7 + u_time * 0.0005); + float diffIntensity = u_diffractionScale * (0.4 + fresnel * 1.1) * u_effectIntensity; + float diffPattern = microFbm * 0.7 + microDetail * 0.3; + + // ---- 光谱色散 ---- + float dispersionShift = (viewX * 0.65 + viewY * 0.35) * u_dispersionStrength; + float localPhase = diffPattern * 0.35 + microDetail * 0.2; + float spectrumPhase = fract(dispersionShift + localPhase + u_time * 0.00002); + vec3 spectrumColor = sampleSpectrum(spectrumPhase); + vec3 chromaSpectrum = vec3( + sampleSpectrum(fract(spectrumPhase + 0.012)).r, + spectrumColor.g, + sampleSpectrum(fract(spectrumPhase - 0.012)).b + ); + + // ---- 高光流转 ---- + float highlightPhase = u_time * 0.001 * u_highlightSpeed; + float highlightAngle = viewX * 1.8 + viewY * 0.9; + vec2 hlCoord = vec2( + (uv.x - 0.5) * cos(highlightAngle) + (uv.y - 0.5) * sin(highlightAngle), + -(uv.x - 0.5) * sin(highlightAngle) + (uv.y - 0.5) * cos(highlightAngle) + ); + float hlDist = abs(hlCoord.x - sin(highlightPhase * 1.3) * 0.55); + float hlWidth = u_highlightWidth * 0.18; + float highlight = exp(-hlDist * hlDist / (hlWidth * hlWidth)); + highlight *= 0.75 + (microFbm * 0.35 + microDetail * 0.15) * 0.5; + float hlSoft = exp(-hlDist * hlDist / (hlWidth * hlWidth * 2.5)); + highlight = mix(highlight, hlSoft, 0.3); + float hl2Dist = abs(hlCoord.x - sin(highlightPhase * 0.9 + 1.2) * 0.45); + highlight += exp(-hl2Dist * hl2Dist / (hlWidth * hlWidth * 1.8)) * 0.4; + + // ---- 微划痕 ---- + float scratch = 0.0; + float scratchFreq = 180.0 * u_dpr; + vec2 scratchCoord = uv * u_resolution / scratchFreq; + for (int i = 0; i < 3; i++) { + float fi = float(i); + vec2 sc = scratchCoord * (1.0 + fi * 0.7) + vec2(fi * 3.7, fi * 5.3); + float ns = noise2D(sc); + vec2 dir = vec2(cos(ns * TAU), sin(ns * TAU)); + float line = abs(fract(dot(scratchCoord, dir) * (3.0 + fi * 2.0)) - 0.5); + scratch += smoothstep(0.04, 0.0, line) * ns * 0.018 / (1.0 + fi * 0.6); + } + + // ---- 珠光颗粒 ---- + float sparkle = 0.0; + float sparkleSeed = hash3(vec3(floor(uv * u_resolution * 1.5), floor(u_time * 0.025))); + if (sparkleSeed > 0.992) { + float b = (sparkleSeed - 0.992) / 0.008; + sparkle = b * b * 1.6 * (0.5 + fresnelEdge * 1.2); + } + + // ============================================================ + // 颜色合成 —— ringFactor 严格约束所有镭射效果 + // + // 反模糊关键设计: + // 1. 安全区内(ringFactor=0) → baseColor 原封不动输出 + // 2. 所有 mix/screen/add 等混合运算的结果乘以 ringFactor + // 3. 色散 mix(a, b, t*ringFactor): 安全区内 t=0 → 只取 a=baseColor + // ============================================================ + vec3 holoLayer = baseColor; + + // 边缘光谱色散 + float holoBlend = diffIntensity * (0.45 + fresnel * 0.65) * ringFactor; + holoLayer = mix(holoLayer, chromaSpectrum, holoBlend * 0.42); + + // 微结构衍射 + float diffOverlay = diffIntensity * 0.28 * ringFactor; + holoLayer = 1.0 - (1.0 - holoLayer) * (1.0 - spectrumColor * diffOverlay); + + // 高光叠加 + float hlStrength = highlight * u_effectIntensity * 0.55 * ringFactor; + vec3 hlColor = mix(vec3(1.0), spectrumColor, 0.3); + holoLayer = mix(holoLayer, holoLayer + hlColor * hlStrength * 0.6, hlStrength); + + // 划痕 + float scratchFactor = scratch * ringFactor; + holoLayer = mix(holoLayer, holoLayer * (1.0 - scratchFactor * 3.0), step(0.001, scratchFactor)); + + // 珠光颗粒(安全区内 15% 微量泄露,模拟真实卡片边缘光泽渗透) + float sparkleRing = sparkle * (ringFactor * 0.85 + 0.15); + holoLayer += sparkleRing * spectrumColor * 1.2; + + // ============================================================ + // 边缘倒角光影 —— 不干扰人物主体的结构光影 + // ============================================================ + float edgeSoft = 0.0; + if (cornerRadiusPx > 0.0) { + float distToEdge = abs(sdf) / cornerRadiusPx; + edgeSoft = smoothstep(1.5, 6.0, distToEdge); + } + holoLayer = mix(holoLayer, holoLayer * (1.0 - edgeSoft * 0.25), step(0.001, edgeSoft)); + + vec2 lightDir2D = normalize(u_lightDir.xy); + float edgeHighlight = smoothstep(0.6, 2.0, abs(sdf) / max(cornerRadiusPx, 1.0)) + * max(0.0, dot(normalize(pnNorm + vec2(0.001)), lightDir2D)) * 0.25; + holoLayer += edgeHighlight * u_effectIntensity * 0.3; + + // ---- 暗角 ---- + float vignette = 1.0 - pow(clamp(length(pnNorm) * 1.1, 0.0, 1.0), 2.8) * 0.35; + holoLayer *= vignette; + + // ---- 输出 ---- + float edgeAA = 1.0 - smoothstep(-1.5, 1.5, sdf); + float alpha = cornerMask * edgeAA; + holoLayer = clamp(holoLayer, 0.0, 1.0); + gl_FragColor = vec4(holoLayer, alpha); +} +` + +/** + * 生成 1x256 光谱渐变纹理数据(RGBA) + */ +export function generateSpectrumRampData(size = 256) { + const data = new Uint8Array(size * 4) + const segments = [ + { pos: 0.0, r: 1.0, g: 0.0, b: 0.0 }, + { pos: 0.08, r: 1.0, g: 0.3, b: 0.0 }, + { pos: 0.17, r: 1.0, g: 0.85, b: 0.0 }, + { pos: 0.33, r: 0.0, g: 1.0, b: 0.15 }, + { pos: 0.50, r: 0.0, g: 0.85, b: 0.85 }, + { pos: 0.67, r: 0.0, g: 0.15, b: 1.0 }, + { pos: 0.83, r: 0.55, g: 0.0, b: 1.0 }, + { pos: 1.0, r: 1.0, g: 0.0, b: 0.1 }, + ] + for (let i = 0; i < size; i++) { + const t = i / size + let segIdx = 0 + for (let s = 1; s < segments.length; s++) { + if (t <= segments[s].pos) { segIdx = s - 1; break } + } + if (t >= segments[segments.length - 1].pos) segIdx = segments.length - 2 + const seg = segments[segIdx] + const next = segments[segIdx + 1] + const lt = (t - seg.pos) / (next.pos - seg.pos) + const idx = i * 4 + data[idx] = Math.round((seg.r + (next.r - seg.r) * lt) * 255) + data[idx + 1] = Math.round((seg.g + (next.g - seg.g) * lt) * 255) + data[idx + 2] = Math.round((seg.b + (next.b - seg.b) * lt) * 255) + data[idx + 3] = 255 + } + return data +} diff --git a/frontend/utils/webgl/index.js b/frontend/utils/webgl/index.js new file mode 100644 index 0000000..c5b0100 --- /dev/null +++ b/frontend/utils/webgl/index.js @@ -0,0 +1,2 @@ +export { HolographicEngine } from './holographic-engine.js' +export { HOLO_VERT_SRC, HOLO_FRAG_SRC, generateSpectrumRampData } from './holographic-shaders.js' diff --git a/supabase/migrations/20260515_create_materials_and_asset_material_relations.sql b/supabase/migrations/20260515_create_materials_and_asset_material_relations.sql new file mode 100644 index 0000000..cfd7bf4 --- /dev/null +++ b/supabase/migrations/20260515_create_materials_and_asset_material_relations.sql @@ -0,0 +1,49 @@ +-- 素材主表 +CREATE TABLE materials ( + id BIGSERIAL PRIMARY KEY, + oss_key VARCHAR(255) NOT NULL, + original_name VARCHAR(255) NOT NULL, + file_size BIGINT NOT NULL, + mime_type VARCHAR(100) NOT NULL, + width INT, + height INT, + hash VARCHAR(64) NOT NULL, + created_by BIGINT NOT NULL, + star_id BIGINT NOT NULL DEFAULT 0, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + deleted_at BIGINT +); + +CREATE UNIQUE INDEX uk_materials_oss_key ON materials(oss_key); +CREATE INDEX idx_materials_hash ON materials(hash); +CREATE INDEX idx_materials_created_by ON materials(created_by); +CREATE INDEX idx_materials_star_id ON materials(star_id); + +-- 资产-素材关联表 +CREATE TABLE asset_material_relations ( + id BIGSERIAL PRIMARY KEY, + asset_id BIGINT NOT NULL REFERENCES assets(id) ON DELETE CASCADE, + material_id BIGINT NOT NULL REFERENCES materials(id) ON DELETE RESTRICT, + material_type VARCHAR(50) NOT NULL, + layer_order INT NOT NULL DEFAULT 0, + pos_x DOUBLE PRECISION, + pos_y DOUBLE PRECISION, + opacity DOUBLE PRECISION DEFAULT 1.0, + rotation DOUBLE PRECISION DEFAULT 0, + scale_x DOUBLE PRECISION DEFAULT 1.0, + scale_y DOUBLE PRECISION DEFAULT 1.0, + version INT NOT NULL DEFAULT 1, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + deleted_at BIGINT +); + +-- 索引 +CREATE INDEX idx_amr_asset_id ON asset_material_relations(asset_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_amr_material_id ON asset_material_relations(material_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_amr_asset_type_layer ON asset_material_relations(asset_id, material_type, layer_order) WHERE deleted_at IS NULL; + +-- 部分唯一索引(软删除感知) +CREATE UNIQUE INDEX uk_amr_asset_type_active ON asset_material_relations(asset_id, material_type) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX uk_amr_asset_layer_active ON asset_material_relations(asset_id, layer_order) WHERE deleted_at IS NULL; diff --git a/微信图片_20260514215857_22857_14.jpg b/微信图片_20260514215857_22857_14.jpg new file mode 100644 index 0000000..65094ad Binary files /dev/null and b/微信图片_20260514215857_22857_14.jpg differ