topfans/docs/specs/2026-05-15-lenticular-card-multi-material-architecture-design.md
2026-05-16 02:42:32 +08:00

18 KiB
Raw Blame History

光栅卡多素材架构升级技术设计

创建日期: 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

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

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 偏移量pxNULL=拉伸模式
    pos_y DOUBLE PRECISION,              -- 距左上角 Y 偏移量pxNULL=拉伸模式
    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
);

索引与约束:

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 距左上角偏移量pxNULL 为拉伸填满容器模式
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 定义

// 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" }

三、数据一致性保障方案

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 扩展

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 缓存策略

层级 实现 配置
L1Redis Hash: asset:materials:{asset_id} TTL 1 小时,写入时主动 INVALIDATE
L2go-cache 热点资产 Top 1000 TTL 5 分钟,访问频率 > 10次/分钟 提升到热点

7.2 查询优化

// 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 迁移脚本核心逻辑

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%
查询响应时间 < 50msP99 > 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 数据模型 现有 assets/mint_orders 表结构
Proto 定义 现有 RPC 消息定义
资产控制器 现有网关层处理逻辑
Redis 配置 现有 Redis 客户端
铸爱提交流程 OSS 上传 + 创建订单
铸造路由管理 Storage Key 管理 + 页面跳转