光栅卡多素材架构升级技术设计
创建日期: 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 偏移量(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
);
索引与约束:
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 定义
// 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 缓存策略
| 层级 |
实现 |
配置 |
| L1(Redis) |
Hash: asset:materials:{asset_id} |
TTL 1 小时,写入时主动 INVALIDATE |
| L2(go-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% |
| 查询响应时间 |
< 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 个工作日 |
十一、关联文档