315 lines
11 KiB
Markdown
315 lines
11 KiB
Markdown
# 光栅卡铸造后写入 asset_registry 方案
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 确保光栅卡铸造完成后:1) 两张图片(main 主图 + bg 背景图)都注册到 `materials` 表;2) `asset_registry` 有记录;3) `asset_material_relations` 正确绑定两组图片
|
||
|
||
**Architecture:**
|
||
- 前端在 `lenticular-result.vue` 的 `selectAsset` 中调用 `submitCraftMintFromPath` 完成上传和铸造
|
||
- `submitCraftMintFromPath` 已经支持:上传主图(main) + 背景图(bg)、注册素材、创建订单、绑定多素材
|
||
- 问题:`lenticular-result.vue` 引用了未定义的 `isCraftLenticular` 和 `craftCoverUrl`,导致铸造流程断裂
|
||
|
||
**Tech Stack:** Vue3 + uni-app 前端,Go 后端,Supabase/PostgreSQL
|
||
|
||
---
|
||
|
||
## 数据库设计解析
|
||
|
||
### 核心表关系
|
||
|
||
```
|
||
asset_registry ← 资产索引表(用户有哪些藏品)
|
||
│ asset_id
|
||
▼
|
||
assets ← 资产表(名称、封面、描述)
|
||
│ id
|
||
▼
|
||
asset_material_relations ← 资产-素材关联表(同一资产有哪些图片)
|
||
│ asset_id, material_id
|
||
▼
|
||
materials ← 素材表(OSS key、MIME、尺寸)
|
||
```
|
||
|
||
### `asset_registry` 表结构(不存图片)
|
||
|
||
```sql
|
||
CREATE TABLE public.asset_registry (
|
||
id bigint,
|
||
asset_id bigint, -- 关联 assets.ID
|
||
asset_type varchar(20), -- 'regular'|'collection'|'activity'
|
||
owner_uid bigint,
|
||
star_id bigint,
|
||
grade integer,
|
||
status integer, -- 0=待处理, 1=已激活
|
||
like_count integer,
|
||
display_status integer,
|
||
created_at bigint,
|
||
updated_at bigint
|
||
);
|
||
```
|
||
|
||
**注意:** `asset_registry` 只存储资产索引和元数据,**不存储任何图片 URL**。
|
||
|
||
### 图片通过 `asset_material_relations` 关联
|
||
|
||
光栅卡有**两张图片**(main 主图 + bg 背景图),通过 `asset_material_relations` 表实现多对一关联:
|
||
|
||
```sql
|
||
-- 同一个 asset_id = 123 的光栅卡,有两条素材记录
|
||
INSERT INTO asset_material_relations (asset_id, material_id, material_type, layer_order) VALUES
|
||
(123, 456, 'main', 0), -- 主图(人物层)
|
||
(123, 789, 'bg', 1); -- 背景图
|
||
|
||
-- materials 表存储实际图片信息
|
||
INSERT INTO materials (id, oss_key, original_name, file_size, mime_type, hash, ...) VALUES
|
||
(456, 'asset/7/88/main/xxx.jpg', 'subject.jpg', 102400, 'image/jpeg', 'abc123', ...),
|
||
(789, 'asset/7/88/bg/xxx.jpg', 'bg.png', 204800, 'image/png', 'def456', ...);
|
||
```
|
||
|
||
### 封面图来源
|
||
|
||
铸造时,前端传入的 `material_url`(main 素材的 oss_key)会被设为 `assets.CoverURL`,用于列表页展示:
|
||
|
||
```
|
||
craftMintSubmit.js → createMintOrderApi({ material_url: main_oss_key })
|
||
↓
|
||
mint_service.go:339 assets.CoverURL = materialURLValue
|
||
↓
|
||
前端列表页通过 assets.CoverURL 获取封面图
|
||
```
|
||
|
||
### 完整数据流
|
||
|
||
```
|
||
submitCraftMintFromPath({ imagePath, bgImagePath, formData })
|
||
│
|
||
├─ uploadImageAndRegisterMaterial(imagePath, 'main') → materials.id=456
|
||
├─ uploadImageAndRegisterMaterial(bgImagePath, 'bg') → materials.id=789
|
||
│
|
||
├─ createMintOrderApi({ material_url: main_oss_key })
|
||
│ ↓
|
||
│ mint_service.go
|
||
│ ├── tx.Create(asset) → CoverURL = main_oss_key
|
||
│ ├── tx.Create(registry) → asset_registry (status=1)
|
||
│ └── 更新订单状态为 SUCCESS
|
||
│
|
||
└─ bindAssetMaterialsApi(asset_id, [
|
||
{ material_id: 456, material_type: 'main', layer_order: 0 },
|
||
{ material_id: 789, material_type: 'bg', layer_order: 1 }
|
||
])
|
||
↓
|
||
asset_material_relations 写入两条记录
|
||
```
|
||
|
||
---
|
||
|
||
## 问题分析
|
||
|
||
### 当前代码问题(`lenticular-result.vue` 第 596-623 行)
|
||
|
||
```javascript
|
||
const selectAsset = async () => {
|
||
// line 596-625 是完整代码块
|
||
const imagePath =
|
||
isCraftLenticular.value // ❌ isCraftLenticular 未定义
|
||
? lenticularLayers.value.find((l) => l.id === 'mid')?.src || craftCoverUrl.value
|
||
: craftCoverUrl.value; // ❌ craftCoverUrl 未定义
|
||
if (!imagePath) { ... }
|
||
|
||
const bgImagePath = isCraftLenticular.value // ❌ isCraftLenticular 未定义
|
||
? lenticularLayers.value.find((l) => l.id === 'base')?.src || ''
|
||
: undefined;
|
||
|
||
try {
|
||
await submitCraftMintFromPath({ imagePath, bgImagePath, formData: craftFormData.value });
|
||
uni.navigateTo({ url: '/pages/castlove/success' });
|
||
return; // line 623 return 后不会执行
|
||
} catch (e) { ... }
|
||
|
||
// 以下代码 dead code(return 后的逻辑)
|
||
if (!isLenticularDisplay.value && selectedIndex.value === -1) { ... }
|
||
if (isUploading.value) { ... }
|
||
// ... 上传到 OSS 并创建订单的旧代码 ...
|
||
};
|
||
```
|
||
|
||
### 现有正确流程(`craftMintSubmit.js` 的 `submitCraftMintFromPath`)
|
||
|
||
```javascript
|
||
// 第 187-306 行
|
||
export async function submitCraftMintFromPath({ imagePath, bgImagePath, formData }) {
|
||
// 1. 获取 OSS 签名
|
||
// 2. 上传主图并注册为 'main' 素材
|
||
// 3. 如果是光栅卡,上传背景图并注册为 'bg' 素材
|
||
// 4. 创建铸造订单(后端自动写入 asset_registry)
|
||
// 5. 绑定多素材到资产(main + bg)
|
||
// 6. 返回
|
||
}
|
||
```
|
||
|
||
铸造流程本身是正确的,问题在前端调用方。
|
||
|
||
---
|
||
|
||
## 文件结构
|
||
|
||
| 文件 | 修改类型 | 职责 |
|
||
|------|----------|------|
|
||
| `frontend/pages/castlove/lenticular/lenticular-result.vue` | 修改 | 修复 `selectAsset` 函数,添加缺失的 computed |
|
||
| `frontend/utils/craftMintSubmit.js` | 已有 | 铸造流程(已正确,无需修改) |
|
||
|
||
---
|
||
|
||
## Task 1: 修复 `lenticular-result.vue` 的 `selectAsset` 函数
|
||
|
||
**Files:**
|
||
- Modify: `frontend/pages/castlove/lenticular/lenticular-result.vue:595-625`
|
||
|
||
- [ ] **Step 1: 确认缺失的 import 并添加**
|
||
|
||
在 `<script setup>` 块顶部已有:
|
||
```javascript
|
||
import { submitCraftMintFromPath } from '@/utils/craftMintSubmit.js';
|
||
import { CASTLOVE_FORM_KEY, GENERATED_IMAGES_KEY, ... } from '@/utils/castloveGenerationFlow.js';
|
||
```
|
||
|
||
需要检查并补充以下 import(参考 `asset-detail.vue` 第 371 行):
|
||
```javascript
|
||
import { CRAFT_LENTICULAR_CN, CRAFT_TAG_LENTICULAR } from '@/utils/castloveMintForm.js';
|
||
import { STUDIO_LENTICULAR } from '@/utils/castloveGenerationFlow.js';
|
||
```
|
||
|
||
- [ ] **Step 2: 添加缺失的 computed 属性**
|
||
|
||
在 `craftFormData` 之后添加:
|
||
|
||
```javascript
|
||
// 光栅卡类型判断
|
||
const isCraftLenticular = computed(() =>
|
||
craftFormData.value?.studio_kind === STUDIO_LENTICULAR
|
||
);
|
||
|
||
// 光栅封面 URL(优先取中间层)
|
||
const craftCoverUrl = computed(() =>
|
||
lenticularLayers.value.find((l) => l.id === 'mid')?.src || ''
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 3: 清理 dead code(line 625-719)**
|
||
|
||
`selectAsset` 函数的 return 后存在大量永不执行的代码(旧的上传 + 创建订单流程),需要删除:
|
||
- 删除 `if (!isLenticularDisplay.value && selectedIndex.value === -1)` 分支
|
||
- 删除 `if (isUploading.value)` 分支
|
||
- 删除 `uni.getStorageSync(CASTLOVE_FORM_KEY)` 及后续所有旧代码
|
||
- 保留 try-catch 和错误处理
|
||
|
||
修复后 `selectAsset` 函数结构:
|
||
|
||
```javascript
|
||
const selectAsset = async () => {
|
||
// 1. 获取主图路径
|
||
const imagePath =
|
||
isCraftLenticular.value
|
||
? craftCoverUrl.value
|
||
: craftCoverUrl.value;
|
||
if (!imagePath) {
|
||
uni.showToast({ title: '缺少作品图', icon: 'none' });
|
||
return;
|
||
}
|
||
|
||
// 2. 获取背景图路径(仅光栅卡)
|
||
const bgImagePath = isCraftLenticular.value
|
||
? lenticularLayers.value.find((l) => l.id === 'base')?.src || ''
|
||
: undefined;
|
||
|
||
console.log('[lenticular-result] handleCraftMint', {
|
||
isCraftLenticular: isCraftLenticular.value,
|
||
imagePath: imagePath?.substring(0, 50),
|
||
bgImagePath: bgImagePath?.substring(0, 50),
|
||
});
|
||
|
||
// 3. 调用铸造流程
|
||
uni.showLoading({ title: '铸造中…', mask: true });
|
||
try {
|
||
await submitCraftMintFromPath({ imagePath, bgImagePath, formData: craftFormData.value });
|
||
uni.hideLoading();
|
||
uni.navigateTo({ url: '/pages/castlove/success' });
|
||
} catch (e) {
|
||
uni.hideLoading();
|
||
uni.showToast({ title: e.message || '铸造失败', icon: 'none' });
|
||
}
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 4: 运行验证**
|
||
|
||
检查 `selectAsset` 函数:
|
||
1. `isCraftLenticular` 和 `craftCoverUrl` 已正确定义
|
||
2. `bgImagePath` 正确取 `base` 层图片
|
||
3. `submitCraftMintFromPath` 调用参数正确
|
||
4. dead code 已删除
|
||
|
||
---
|
||
|
||
## Task 2: 验证铸造全链路
|
||
|
||
**Files:**
|
||
- Test: 手动测试 `lenticular-result.vue` 的铸造流程
|
||
|
||
- [ ] **Step 1: 检查后端是否自动写入 asset_registry**
|
||
|
||
后端 `mint_service.go` 第 369-387 行在创建订单时已自动写入 `asset_registry`:
|
||
```go
|
||
registry := &models.AssetRegistry{
|
||
AssetID: asset.ID,
|
||
AssetType: models.AssetTypeRegular,
|
||
OwnerUID: userID,
|
||
StarID: starID,
|
||
Grade: &grade,
|
||
Status: models.AssetRegistryStatusActive,
|
||
// ...
|
||
}
|
||
tx.Create(registry)
|
||
```
|
||
|
||
- [ ] **Step 2: 确认 bindAssetMaterialsApi 调用后素材正确绑定**
|
||
|
||
`craftMintSubmit.js` 第 268-286 行已调用 `bindAssetMaterialsApi`:
|
||
```javascript
|
||
const materialsToBind = [
|
||
{ material_id: mainResult.materialId, material_type: 'main', layer_order: 0 }
|
||
]
|
||
if (bgMaterialId) {
|
||
materialsToBind.push({ material_id: bgMaterialId, material_type: 'bg', layer_order: 1 })
|
||
}
|
||
bindAssetMaterialsApi(assetId, materialsToBind)
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: 检查清单确认
|
||
|
||
- [ ] `asset_registry` 有记录(asset_type = 'regular',status = 1)
|
||
- [ ] `assets.CoverURL` = main 素材的 oss_key(用于列表封面)
|
||
- [ ] `materials` 表有两条记录(material_type = 'main' 和 'bg')
|
||
- [ ] `asset_material_relations` 有两条绑定记录(同一个 asset_id,layer_order 0 和 1)
|
||
- [ ] 前端 `selectAsset` 无编译错误(isCraftLenticular、craftCoverUrl 已定义)
|
||
- [ ] 铸造后跳转成功页 `/pages/castlove/success`
|
||
|
||
---
|
||
|
||
## 总结
|
||
|
||
**核心理解:**
|
||
- `asset_registry` 是**索引表**,不存图片 — 它通过 `asset_id` 关联到 `assets`,再通过 `asset_material_relations` 关联到 `materials`
|
||
- **同一组光栅卡图片**靠 `asset_material_relations.asset_id` 相同 + `material_type` 不同('main'/'bg')来区分
|
||
- **封面图**来自 `assets.CoverURL`(即 main 素材的 oss_key)
|
||
|
||
**问题根因:** `lenticular-result.vue` 第 596-623 行的 `selectAsset` 函数引用了未定义的 `isCraftLenticular` 和 `craftCoverUrl`,且 return 后存在大量 dead code。
|
||
|
||
**修复方案:**
|
||
1. 添加 `isCraftLenticular` 和 `craftCoverUrl` computed
|
||
2. 清理 dead code(return 后的旧上传/订单代码)
|
||
3. 确保 `bgImagePath` 传入 `base` 层图片给 `submitCraftMintFromPath`
|
||
|
||
**已正确部分:** `submitCraftMintFromPath` 已完整实现:上传主图 + 背景图 → 注册素材 → 创建订单 → 绑定多素材,后端自动写 `asset_registry`。 |