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

526 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 光栅卡多素材架构升级技术设计
> **创建日期:** 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 偏移量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
);
```
**索引与约束:**
```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` | 距左上角偏移量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 定义
```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 缓存策略
| 层级 | 实现 | 配置 |
|------|------|------|
| L1Redis | Hash: `asset:materials:{asset_id}` | TTL 1 小时,写入时主动 INVALIDATE |
| L2go-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% |
| 查询响应时间 | < 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 数据模型](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 管理 + 页面跳转 |