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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ↻
+ {{ hintText }}
+
+
+
+
+
+
+
+
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