feat完善三层光栅卡片预览陀螺仪,光栅卡多素材架构升级技术设计,新增资产-素材关联表
This commit is contained in:
parent
39209849cc
commit
00e515b3dd
@ -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)
|
||||
|
||||
@ -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": "解绑成功"})
|
||||
}
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
// 展馆相关路由(需要认证)
|
||||
|
||||
62
backend/pkg/models/material.go
Normal file
62
backend/pkg/models/material.go
Normal file
@ -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" }
|
||||
@ -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调用)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
199
backend/services/assetService/provider/material_provider.go
Normal file
199
backend/services/assetService/provider/material_provider.go
Normal file
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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 管理 + 页面跳转 |
|
||||
266
frontend/components/lenticular/HolographicCard.vue
Normal file
266
frontend/components/lenticular/HolographicCard.vue
Normal file
@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<view class="holo-container">
|
||||
<view class="holo-frame" :style="frameStyle">
|
||||
<!-- WebGL 渲染层 —— Canvas 缓冲 = CSS × DPR,Mipmap + MSAA -->
|
||||
<canvas
|
||||
v-show="webglReady && !hasError"
|
||||
ref="webglCanvas"
|
||||
class="holo-canvas"
|
||||
:style="canvasStyle"
|
||||
@touchstart.stop="onTouchStart"
|
||||
@touchmove.stop.prevent="onTouchMove"
|
||||
@touchend.stop="onTouchEnd"
|
||||
@touchcancel.stop="onTouchEnd"
|
||||
/>
|
||||
|
||||
<!-- CSS 降级层(WebGL 不可用时自动启用) -->
|
||||
<view v-if="!webglReady || hasError" class="holo-fallback" :style="fallbackStyle">
|
||||
<view class="holo-fallback-card" :style="fallbackCardStyle">
|
||||
<image
|
||||
v-if="baseImageSrc"
|
||||
class="holo-fallback-img"
|
||||
:src="baseImageSrc"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="holo-fallback-shimmer" />
|
||||
<view class="holo-fallback-rim" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="showHint && hintText" class="holo-hint" :class="{ 'holo-hint--hidden': !showHint }">
|
||||
<text class="holo-hint-icon">↻</text>
|
||||
<text class="holo-hint-text">{{ hintText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { HolographicEngine } from '@/utils/webgl/holographic-engine.js'
|
||||
import { loadTextureImage } from '@/utils/laser-card/laserPreviewWebgl.js'
|
||||
|
||||
const props = defineProps({
|
||||
baseImageSrc: { type: String, default: '' },
|
||||
aspectRatio: { type: Number, default: 3 / 4 },
|
||||
maxWidth: { type: Number, default: 420 },
|
||||
cornerRadius: { type: Number, default: 24 },
|
||||
effectIntensity: { type: Number, default: 0.85 },
|
||||
dispersionStrength: { type: Number, default: 1.0 },
|
||||
diffractionScale: { type: Number, default: 0.7 },
|
||||
highlightSpeed: { type: Number, default: 0.8 },
|
||||
highlightWidth: { type: Number, default: 1.0 },
|
||||
fresnelPower: { type: Number, default: 3.5 },
|
||||
noiseScale: { type: Number, default: 1.0 },
|
||||
noiseOctaves: { type: Number, default: 6 },
|
||||
safeZoneRadius: { type: Number, default: 0.35 },
|
||||
safeZoneSoftness: { type: Number, default: 0.15 },
|
||||
viewX: { type: Number, default: 0 },
|
||||
viewY: { type: Number, default: 0 },
|
||||
viewZ: { type: Number, default: 0 },
|
||||
hintText: { type: String, default: '移动光标或倾斜设备' },
|
||||
showHint: { type: Boolean, default: true },
|
||||
maxDPR: { type: Number, default: 2 },
|
||||
skipBuiltInTouch: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['simulate', 'ready', 'error', 'fpsUpdate'])
|
||||
|
||||
const webglCanvas = ref(null)
|
||||
const webglReady = ref(false)
|
||||
const hasError = ref(false)
|
||||
let engine = null
|
||||
let resizeObserver = null
|
||||
let fpsInterval = null
|
||||
|
||||
function resolveSize() {
|
||||
const vw = typeof window !== 'undefined' ? window.innerWidth : 375
|
||||
const w = Math.min(props.maxWidth, vw * 0.92)
|
||||
const h = w / props.aspectRatio
|
||||
return { w, h }
|
||||
}
|
||||
|
||||
const frameStyle = computed(() => {
|
||||
const { w, h } = resolveSize()
|
||||
return { width: `${w}px`, height: `${h}px` }
|
||||
})
|
||||
const canvasStyle = computed(() => {
|
||||
const { w, h } = resolveSize()
|
||||
return { width: `${w}px`, height: `${h}px` }
|
||||
})
|
||||
const fallbackStyle = computed(() => {
|
||||
const { w, h } = resolveSize()
|
||||
return { width: `${w}px`, height: `${h}px` }
|
||||
})
|
||||
const fallbackCardStyle = computed(() => {
|
||||
const vx = props.viewX || 0
|
||||
const vy = props.viewY || 0
|
||||
return {
|
||||
borderRadius: `${props.cornerRadius}px`,
|
||||
transform: `perspective(1000px) rotateY(${vx * 15}deg) rotateX(${-vy * 8}deg)`,
|
||||
}
|
||||
})
|
||||
|
||||
function dispatchTouch(clientX, clientY) {
|
||||
if (props.skipBuiltInTouch) return
|
||||
const canvas = webglCanvas.value
|
||||
if (!canvas) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
if (!rect.width || !rect.height) return
|
||||
const cx = rect.left + rect.width / 2
|
||||
const cy = rect.top + rect.height / 2
|
||||
emit('simulate',
|
||||
Math.max(-1, Math.min(1, (clientX - cx) / (rect.width / 2) * 1.1)),
|
||||
Math.max(-1, Math.min(1, (clientY - cy) / (rect.height / 2) * 1.1))
|
||||
)
|
||||
}
|
||||
|
||||
function getT(e) {
|
||||
if (e.touches && e.touches[0]) return e.touches[0]
|
||||
if (e.changedTouches && e.changedTouches[0]) return e.changedTouches[0]
|
||||
return null
|
||||
}
|
||||
function onTouchStart(e) { onTouchMove(e) }
|
||||
function onTouchMove(e) {
|
||||
if (props.skipBuiltInTouch) return
|
||||
const t = getT(e)
|
||||
if (t) dispatchTouch(t.clientX, t.clientY)
|
||||
}
|
||||
function onTouchEnd() {
|
||||
if (props.skipBuiltInTouch) return
|
||||
emit('simulate', 0, 0)
|
||||
}
|
||||
|
||||
function syncEngineParams() {
|
||||
if (!engine) return
|
||||
engine.setEffectParams({
|
||||
effectIntensity: props.effectIntensity,
|
||||
dispersionStrength: props.dispersionStrength,
|
||||
diffractionScale: props.diffractionScale,
|
||||
highlightSpeed: props.highlightSpeed,
|
||||
highlightWidth: props.highlightWidth,
|
||||
fresnelPower: props.fresnelPower,
|
||||
noiseScale: props.noiseScale,
|
||||
noiseOctaves: Math.round(props.noiseOctaves),
|
||||
cardCornerRadius: props.cornerRadius,
|
||||
safeZoneRadius: props.safeZoneRadius,
|
||||
safeZoneSoftness: props.safeZoneSoftness,
|
||||
})
|
||||
engine.setViewAngle(props.viewX, props.viewY, props.viewZ)
|
||||
}
|
||||
|
||||
watch(() => [
|
||||
props.effectIntensity, props.dispersionStrength, props.diffractionScale,
|
||||
props.highlightSpeed, props.highlightWidth, props.fresnelPower,
|
||||
props.noiseScale, props.noiseOctaves, props.cornerRadius,
|
||||
props.safeZoneRadius, props.safeZoneSoftness,
|
||||
props.viewX, props.viewY, props.viewZ,
|
||||
], syncEngineParams)
|
||||
|
||||
watch(() => props.baseImageSrc, async (src) => {
|
||||
if (!engine || !src) return
|
||||
try {
|
||||
const img = await loadTextureImage(src)
|
||||
engine.uploadBaseImage(img)
|
||||
} catch (e) {
|
||||
console.warn('[HolographicCard] load baseImage failed:', e)
|
||||
}
|
||||
})
|
||||
|
||||
function initWebGL() {
|
||||
if (!HolographicEngine.isSupported()) {
|
||||
hasError.value = true
|
||||
emit('error', new Error('WebGL not supported'))
|
||||
return
|
||||
}
|
||||
const canvas = webglCanvas.value
|
||||
if (!canvas) return
|
||||
try {
|
||||
const { w, h } = resolveSize()
|
||||
engine = new HolographicEngine(canvas, {
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
useFBO: false,
|
||||
})
|
||||
engine.setDPR(Math.min(window.devicePixelRatio || 1, props.maxDPR))
|
||||
if (!engine.init()) {
|
||||
hasError.value = true
|
||||
emit('error', new Error('WebGL init failed'))
|
||||
return
|
||||
}
|
||||
syncEngineParams()
|
||||
engine.start()
|
||||
webglReady.value = true
|
||||
emit('ready')
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
const { width, height } = e.contentRect
|
||||
if (engine && width > 0 && height > 0) engine.resize(width, height)
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(canvas.parentElement || canvas)
|
||||
}
|
||||
|
||||
fpsInterval = setInterval(() => {
|
||||
if (engine) emit('fpsUpdate', engine.getFPS())
|
||||
}, 1000)
|
||||
|
||||
if (props.baseImageSrc) {
|
||||
loadTextureImage(props.baseImageSrc)
|
||||
.then(img => engine.uploadBaseImage(img))
|
||||
.catch(e => console.warn('[HolographicCard] init load:', e))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[HolographicCard] init error:', e)
|
||||
hasError.value = true
|
||||
emit('error', e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { nextTick(initWebGL) })
|
||||
|
||||
onUnmounted(() => {
|
||||
if (fpsInterval) { clearInterval(fpsInterval); fpsInterval = null }
|
||||
if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null }
|
||||
if (engine) { engine.destroy(); engine = null }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.holo-container { width: 100%; display: flex; align-items: center; justify-content: center; }
|
||||
.holo-frame { position: relative; max-width: 100%; }
|
||||
.holo-canvas { display: block; border-radius: inherit; }
|
||||
|
||||
/* ---- CSS 降级 ---- */
|
||||
.holo-fallback { position: relative; display: flex; align-items: center; justify-content: center; perspective: 1000px; }
|
||||
.holo-fallback-card {
|
||||
width: 100%; height: 100%; overflow: hidden; position: relative;
|
||||
background-color: #060e20; box-shadow: 0 20px 50px rgba(0,0,0,0.55);
|
||||
border: 1px solid rgba(255,255,255,0.18);
|
||||
will-change: transform; transform-style: preserve-3d;
|
||||
}
|
||||
.holo-fallback-img { width: 100%; height: 100%; position: absolute; top: 0; left: 0; }
|
||||
.holo-fallback-shimmer {
|
||||
position: absolute; inset: 0; pointer-events: none;
|
||||
background: linear-gradient(135deg, rgba(221,183,255,0.08) 0%, rgba(100,200,255,0.15) 25%, transparent 50%, rgba(255,180,220,0.12) 75%, rgba(76,215,246,0.08) 100%);
|
||||
}
|
||||
.holo-fallback-rim {
|
||||
position: absolute; inset: 0;
|
||||
border-top: 1px solid rgba(221,183,255,0.42);
|
||||
box-shadow: inset 0 0 40px rgba(255,255,255,0.05);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---- 提示 ---- */
|
||||
.holo-hint {
|
||||
position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%);
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 8px 16px; background: rgba(0,0,0,0.55); backdrop-filter: blur(12px);
|
||||
border-radius: 20px; transition: opacity 0.4s ease; pointer-events: none;
|
||||
}
|
||||
.holo-hint--hidden { opacity: 0; }
|
||||
.holo-hint-icon { font-size: 16px; color: rgba(255,255,255,0.8); }
|
||||
.holo-hint-text { font-size: 12px; color: rgba(255,255,255,0.75); }
|
||||
</style>
|
||||
142
frontend/composables/useHolographicPreview.js
Normal file
142
frontend/composables/useHolographicPreview.js
Normal file
@ -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,
|
||||
},
|
||||
}
|
||||
@ -133,12 +133,40 @@
|
||||
<view class="content-wrapper">
|
||||
<!-- 藏品卡片区域 -->
|
||||
<view class="card-section">
|
||||
<view class="card-wrapper">
|
||||
<view class="card-wrapper" :class="{ 'card-wrapper--lenticular': isLenticularAsset }">
|
||||
<image class="card-frame" src="/static/square/gerenzhongxincangpinkuang.png" mode="aspectFit">
|
||||
</image>
|
||||
<image class="card-image" :src="coverUrl" mode="aspectFill"></image>
|
||||
<!-- <view class="card-badge"></view> -->
|
||||
<view v-if="isLenticularAsset" class="detail-lenticular-slot">
|
||||
<LenticularCard
|
||||
class="detail-lenticular-card"
|
||||
:layers="lenticularLayers"
|
||||
:transforms="layerTransforms"
|
||||
:gyro-source="gyroSourceLabel"
|
||||
:skip-built-in-touch="true"
|
||||
tilt-hint-text="倾斜手机查看光栅效果"
|
||||
:shimmer-mid-opacity="0.16"
|
||||
@simulate="simulate"
|
||||
/>
|
||||
</view>
|
||||
<image v-else class="card-image" :src="coverUrl" mode="aspectFill"></image>
|
||||
<!-- 贴纸叠加层 -->
|
||||
<image
|
||||
v-for="sticker in activeStickers"
|
||||
:key="sticker.id"
|
||||
class="card-sticker"
|
||||
:src="sticker.src"
|
||||
mode="aspectFit"
|
||||
:style="getStickerStyle(sticker)"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 贴纸合成导出隐藏画布 -->
|
||||
<canvas
|
||||
v-if="activeStickers.length"
|
||||
canvas-id="stickerCompositCanvas"
|
||||
class="export-canvas"
|
||||
:style="{ position: 'fixed', left: '-9999px', top: '-9999px', width: '450px', height: '600px' }"
|
||||
/>
|
||||
|
||||
<!-- 点赞 + 收益 + 倒计时行 -->
|
||||
<view class="card-meta-row">
|
||||
@ -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 {
|
||||
|
||||
@ -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')
|
||||
|
||||
330
frontend/utils/sticker-compositor.js
Normal file
330
frontend/utils/sticker-compositor.js
Normal file
@ -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<string>} 导出后的临时文件路径
|
||||
*/
|
||||
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',
|
||||
}))
|
||||
}
|
||||
421
frontend/utils/webgl/holographic-engine.js
Normal file
421
frontend/utils/webgl/holographic-engine.js
Normal file
@ -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 }
|
||||
}
|
||||
}
|
||||
322
frontend/utils/webgl/holographic-shaders.js
Normal file
322
frontend/utils/webgl/holographic-shaders.js
Normal file
@ -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
|
||||
}
|
||||
2
frontend/utils/webgl/index.js
Normal file
2
frontend/utils/webgl/index.js
Normal file
@ -0,0 +1,2 @@
|
||||
export { HolographicEngine } from './holographic-engine.js'
|
||||
export { HOLO_VERT_SRC, HOLO_FRAG_SRC, generateSpectrumRampData } from './holographic-shaders.js'
|
||||
@ -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;
|
||||
BIN
微信图片_20260514215857_22857_14.jpg
Normal file
BIN
微信图片_20260514215857_22857_14.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
Loading…
Reference in New Issue
Block a user