docs: 文档

This commit is contained in:
zheng020 2026-04-22 10:30:27 +08:00
parent 8b89e34b18
commit d0ff68508f
2 changed files with 533 additions and 84 deletions

View File

@ -18,11 +18,11 @@
│ │ │ │
│ ┌─────────────────────────────────────┐ │ │ ┌─────────────────────────────────────┐ │
│ │ │ │ │ │ │ │
│ │ [ 普通 ] [ 典藏 ] [ 活动 ] │ │ ← 类型Tab横向滚动 │ │ [ 原创 ] [ 典藏 ] [ 活动 ] │ │ ← 类型Tab横向滚动
│ │ │ │ │ │ │ │
│ ├─────────────────────────────────────┤ │ │ ├─────────────────────────────────────┤ │
│ │ │ │ │ │ │ │
│ │ 普通 · 等级五 │ │ ← 普通grade 大在上 │ │ 原创 · 等级五 │ │ ← 原创grade 大在上
│ │ ┌────────┐ ┌────────┐ ┌────────┐│ │ │ │ ┌────────┐ ┌────────┐ ┌────────┐│ │
│ │ │ 封面 │ │ 封面 │ │ 封面 ││ │ │ │ │ 封面 │ │ 封面 │ │ 封面 ││ │
│ │ └────────┘ └────────┘ └────────┘│ │ │ │ └────────┘ └────────┘ └────────┘│ │
@ -35,7 +35,7 @@
│ │ │ │ │ │ │ │
│ │ ───────────────────────────────── │ │ │ │ ───────────────────────────────── │ │
│ │ │ │ │ │ │ │
│ │ 普通 · 等级四 │ │ │ │ 原创 · 等级四 │ │
│ │ ┌────────┐ ┌────────┐ │ │ │ │ ┌────────┐ ┌────────┐ │ │
│ │ │ 封面 │ │ 封面 │ │ │ │ │ │ 封面 │ │ 封面 │ │ │
│ │ └────────┘ └────────┘ │ │ │ │ └────────┘ └────────┘ │ │
@ -53,7 +53,7 @@
**切换到"典藏"时:** **切换到"典藏"时:**
``` ```
│ [ 普通 ] [ 典藏 ] [ 活动 ] │ ← 典藏Tab高亮 │ [ 原创 ] [ 典藏 ] [ 活动 ] │ ← 典藏Tab高亮
├─────────────────────────────────────────────┤ ├─────────────────────────────────────────────┤
│ 典藏 · 限定典藏 │ ← 按category分组无grade │ 典藏 · 限定典藏 │ ← 按category分组无grade
│ ┌────────┐ ┌────────┐ ┌────────┐ │ │ ┌────────┐ ┌────────┐ ┌────────┐ │
@ -73,7 +73,7 @@
**切换到"活动"时:** **切换到"活动"时:**
``` ```
│ [ 普通 ] [ 典藏 ] [ 活动 ] │ ← 活动Tab高亮 │ [ 原创 ] [ 典藏 ] [ 活动 ] │ ← 活动Tab高亮
├─────────────────────────────────────────────┤ ├─────────────────────────────────────────────┤
│ 活动 · 生日会 │ ← 按activity_type分组 │ 活动 · 生日会 │ ← 按activity_type分组
│ ┌────────┐ ┌────────┐ │ │ ┌────────┐ ┌────────┐ │
@ -89,9 +89,9 @@
**页面说明:** **页面说明:**
- **无槽位展示区**:新设计完全移除槽位概念,改为纯分组浏览 - **无槽位展示区**:新设计完全移除槽位概念,改为纯分组浏览
- **类型Tab**`[普通] [典藏] [活动]` 三选一,点击切换 - **类型Tab**`[原创] [典藏] [活动]` 三选一,点击切换
- **默认选中"普通"**:进入页面默认展示普通藏品 - **默认选中"原创"**:进入页面默认展示原创藏品
- **普通**:按 grade 从等级五顶部到等级一底部排列grade 大的在上 - **原创**:按 grade 从等级五顶部到等级一底部排列grade 大的在上
- **典藏**:按 category 子分类分组,无 grade - **典藏**:按 category 子分类分组,无 grade
- **活动**:按 activity_type 分组,无 grade - **活动**:按 activity_type 分组,无 grade
- **每组最多显示6个NftCard**超出6个显示"更多>"卡片("更多>"不计入6个之内 - **每组最多显示6个NftCard**超出6个显示"更多>"卡片("更多>"不计入6个之内
@ -103,7 +103,7 @@
``` ```
┌─────────────────────────────────────────────┐ ┌─────────────────────────────────────────────┐
│ ← 返回 普通 · 等级五 │ ← Header │ ← 返回 原创 · 等级五 │ ← Header
├─────────────────────────────────────────────┤ ├─────────────────────────────────────────────┤
│ │ │ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐│
@ -136,23 +136,23 @@
### 1.1 现状问题 ### 1.1 现状问题
- `StarbookContent.vue` 混用通用 `getMyAssetsApi`,无法区分藏品类型(普通/典藏/活动) - `StarbookContent.vue` 混用通用 `getMyAssetsApi`,无法区分藏品类型(原创/典藏/活动)
- `assets` 表只有一套,没有类型区分字段 - `assets` 表只有一套,没有类型区分字段
- 典藏藏品有子分类category需求但目前表结构不支持 - 典藏藏品有子分类category需求但目前表结构不支持
- 普通藏品有等级grade需求但目前表结构不支持 - 原创藏品有等级grade需求但目前表结构不支持
- 活动藏品需要独立的生命周期管理 - 活动藏品需要独立的生命周期管理
- 前端封面 URL 逐个解析(`Promise.all` + 多次 OSS 预签名调用),性能差 - 前端封面 URL 逐个解析(`Promise.all` + 多次 OSS 请求),性能差
- 前端多重生命周期触发(`onMounted` + `onActivated` + `onWatch` + `onShow`),存在重复请求 - 前端多重生命周期触发(`onMounted` + `onActivated` + `onWatch` + `onShow`),存在重复请求
### 1.2 重构目标 ### 1.2 重构目标
1. 建立普通藏品、典藏藏品、活动藏品三套并行的数据表结构 1. 建立原创藏品、典藏藏品、活动藏品三套并行的数据表结构
2. 普通藏品按 grade1/2/3分组展示典藏按 category 子分类分组,活动按 activity_type 分组 2. 原创藏品按 grade1/2/3分组展示典藏按 category 子分类分组,活动按 activity_type 分组
3. 通过 `asset_registry` 统一索引表实现跨类型统一查询 3. 通过 `asset_registry` 统一索引表实现跨类型统一查询
4. 后端批量返回预签名封面 URL前端零额外 OSS 请求 4. 后端直接返回 OSS 公共 URL前端通过该 URL 直接访问 OSS 资源查看封面
5. 星册页面按类型 + 子分类/等级分组展示,每组最多展示 6 条 5. 星册页面按类型 + 子分类/等级分组展示,每组最多展示 6 条
6. "查看更多" 跳转独立页面,支持分页 6. "查看更多" 跳转独立页面,支持分页
7. 普通藏品(铸爱流程)、典藏藏品、活动藏品均可放入星册 7. 原创藏品(铸爱流程)、典藏藏品、活动藏品均可放入星册
--- ---
@ -162,9 +162,9 @@
| 表名 | 说明 | | 表名 | 说明 |
|------|------| |------|------|
| `assets` | 普通藏品(铸爱流程),结构不变 | | `assets` | 原创藏品(铸爱流程),结构不变 |
**说明:** `cover_url_signed`预签名URL不在数据库中存储由 Service 层在响应时动态生成调用OSS生成预签名URL避免URL过期问题 **说明:** `cover_url`OSS 公共 URL直接存储在数据库中前端可直接访问
### 2.2 新建表 ### 2.2 新建表
@ -229,8 +229,8 @@ CREATE TABLE asset_registry (
asset_type VARCHAR(20) NOT NULL, -- 'regular' | 'collection' | 'activity' asset_type VARCHAR(20) NOT NULL, -- 'regular' | 'collection' | 'activity'
owner_uid BIGINT NOT NULL, owner_uid BIGINT NOT NULL,
star_id BIGINT NOT NULL, star_id BIGINT NOT NULL,
-- 普通藏品专属字段(其他类型时为 NULL -- 原创藏品专属字段(其他类型时为 NULL
grade SMALLINT, -- 普通藏品等级1 / 2 / 3仅 regular 时有效) grade SMALLINT, -- 原创藏品等级1 / 2 / 3仅 regular 时有效)
-- 典藏专属字段(其他类型时为 NULL -- 典藏专属字段(其他类型时为 NULL
collection_category VARCHAR(50), -- 典藏子分类(仅 collection 时有效) collection_category VARCHAR(50), -- 典藏子分类(仅 collection 时有效)
-- 活动专属字段(其他类型时为 NULL -- 活动专属字段(其他类型时为 NULL
@ -255,7 +255,7 @@ CREATE INDEX idx_registry_star_activity ON asset_registry (star_id, activity_id)
``` ```
**说明:** **说明:**
- `asset_type='regular'`普通藏品):`grade` 有效1/2/3`collection_category` 为 NULL - `asset_type='regular'`原创藏品):`grade` 有效1/2/3`collection_category` 为 NULL
- `asset_type='collection'`(典藏):`collection_category` 有效,`grade` 为 NULL - `asset_type='collection'`(典藏):`collection_category` 有效,`grade` 为 NULL
- `asset_type='activity'`(活动藏品):`activity_id` / `activity_type` 有效,`grade` 为 NULL - `asset_type='activity'`(活动藏品):`activity_id` / `activity_type` 有效,`grade` 为 NULL
- **同一 `asset_id` 只能属于一种 type**,不能同时注册为 regular 和 collection - **同一 `asset_id` 只能属于一种 type**,不能同时注册为 regular 和 collection
@ -263,27 +263,27 @@ CREATE INDEX idx_registry_star_activity ON asset_registry (star_id, activity_id)
### 2.3 设计说明 ### 2.3 设计说明
1. **三种类型的区分方式:** 1. **三种类型的区分方式:**
- `regular`普通藏品):`grade` 有效1/2/3由点赞数计算得出预留升级接口 - `regular`原创藏品):`grade` 有效1/2/3由点赞数计算得出预留升级接口
- `collection`(典藏):`collection_category` 有效(动态字符串),无 grade - `collection`(典藏):`collection_category` 有效(动态字符串),无 grade
- `activity`(活动藏品):`activity_id` / `activity_type` 有效,无 grade - `activity`(活动藏品):`activity_id` / `activity_type` 有效,无 grade
2. **category / activity_type 为动态字符串**,不预设枚举值,应用层负责校验和展示名称映射 2. **category / activity_type 为动态字符串**,不预设枚举值,应用层负责校验和展示名称映射
3. **registry 表不自增 asset_id**,由各类型表创建时写入,保证主从表一致性 3. **registry 表不自增 asset_id**,由各类型表创建时写入,保证主从表一致性
4. **UNIQUE 约束**`owner_uid + star_id + asset_type + asset_id` 保证用户每种类型下无重复藏品 4. **UNIQUE 约束**`owner_uid + star_id + asset_type + asset_id` 保证用户每种类型下无重复藏品
5. **同一 `asset_id` 只能属于一种 type**普通藏品、典藏、活动藏品互斥,不能重复注册 5. **同一 `asset_id` 只能属于一种 type**原创藏品、典藏、活动藏品互斥,不能重复注册
6. **`grade` 字段在数据库中为 NULL 表示非普通藏品**,在 API 响应中统一转换为 `0`(由 Gateway 转换层处理) 6. **`grade` 字段在数据库中为 NULL 表示非原创藏品**,在 API 响应中统一转换为 `0`(由 Gateway 转换层处理)
--- ---
## 三、藏品创建流程 ## 三、藏品创建流程
### 3.1 普通藏品(铸爱流程) ### 3.1 原创藏品(铸爱流程)
``` ```
用户上传素材 → 铸造订单创建 → assets 表写入 用户上传素材 → 铸造订单创建 → assets 表写入
→ 同时写入 asset_registry (type='regular', grade=1) -- grade 初始为1升级规则预留 → 同时写入 asset_registry (type='regular', grade=1) -- grade 初始为1升级规则预留
``` ```
说明:普通藏品(铸爱流程)纳入星册体系,创建时同步写入 registry`grade` 初始为 1后续按点赞数升级规则待确认 说明:原创藏品(铸爱流程)纳入星册体系,创建时同步写入 registry`grade` 初始为 1后续按点赞数升级规则待确认
### 3.2 典藏藏品 ### 3.2 典藏藏品
@ -307,7 +307,7 @@ CREATE INDEX idx_registry_star_activity ON asset_registry (star_id, activity_id)
--- ---
## 四、普通藏品等级升级机制(预留接口) ## 四、原创藏品等级升级机制(预留接口)
`grade` 初始值为 1升级规则待定由团队讨论确认后实现。设计上预留扩展接口 `grade` 初始值为 1升级规则待定由团队讨论确认后实现。设计上预留扩展接口
@ -330,14 +330,14 @@ UpgradeGrade(assetID int64, fromGrade, toGrade int) error
### 5.1 核心原则 ### 5.1 核心原则
- **后端批量返回预签名封面 URL**,前端不单独调用 OSS 预签名接口 - **后端直接返回 OSS 公共 URL**,前端直接使用该 URL 访问 OSS 资源(无需调用 OSS 预签名接口)
- **单次请求返回首页全量数据**,避免前端多次请求 - **单次请求返回首页全量数据**,避免前端多次请求
### 5.2 接口列表 ### 5.2 接口列表
| 接口 | 方法 | 说明 | | 接口 | 方法 | 说明 |
|------|------|------| |------|------|------|
| `/api/v1/starbook/home` | GET | 星册首页,按 type → category/grade 分组,含普通/典藏/活动三种类型 | | `/api/v1/starbook/home` | GET | 星册首页,按 type → category/grade 分组,含原创/典藏/活动三种类型 |
| `/api/v1/starbook/items` | GET | 某分组的藏品列表(分页) | | `/api/v1/starbook/items` | GET | 某分组的藏品列表(分页) |
### 5.3 详细接口定义 ### 5.3 详细接口定义
@ -356,12 +356,12 @@ UpgradeGrade(assetID int64, fromGrade, toGrade int) error
{ {
"type": "regular", "type": "regular",
"category": "castlove", "category": "castlove",
"category_name": "普通", "category_name": "原创",
"grades": [ "grades": [
{ {
"grade": 5, "grade": 5,
"items": [ "items": [
{ "asset_id": 201, "name": "铸爱藏品-A", "cover_url_signed": "https://...", "like_count": 820, "category": "castlove", "grade": 5 } { "asset_id": 201, "name": "铸爱藏品-A", "cover_url": "https://bucket.oss-cn-shanghai.aliyuncs.com/xxx.jpg", "like_count": 820, "category": "castlove", "grade": 5 }
], ],
"total_count": 3, "total_count": 3,
"has_more": false "has_more": false
@ -379,7 +379,7 @@ UpgradeGrade(assetID int64, fromGrade, toGrade int) error
"category": "limited", "category": "limited",
"category_name": "限定典藏", "category_name": "限定典藏",
"items": [ "items": [
{ "asset_id": 101, "name": "限定典藏-1", "cover_url_signed": "https://...", "like_count": 820, "category": "limited" } { "asset_id": 101, "name": "限定典藏-1", "cover_url": "https://bucket.oss-cn-shanghai.aliyuncs.com/xxx.jpg", "like_count": 820, "category": "limited" }
], ],
"total_count": 5, "total_count": 5,
"has_more": true "has_more": true
@ -400,7 +400,7 @@ UpgradeGrade(assetID int64, fromGrade, toGrade int) error
**说明:** **说明:**
- `star_id` 从认证上下文JWT Token获取不在请求参数中显式传递 - `star_id` 从认证上下文JWT Token获取不在请求参数中显式传递
- `groups`:三种类型的分组逻辑不同 - `groups`:三种类型的分组逻辑不同
- `type='regular'`普通藏品):按 `grade` 分组grade=1/2/3...`category='castlove'` 固定 - `type='regular'`原创藏品):按 `grade` 分组grade=1/2/3...`category='castlove'` 固定
- `type='collection'`(典藏):按 `category` 子分类分组,无 grade - `type='collection'`(典藏):按 `category` 子分类分组,无 grade
- `type='activity'`(活动藏品):按 `activity_type` 分组,无 grade - `type='activity'`(活动藏品):按 `activity_type` 分组,无 grade
- 每组最多返回 6 条,`has_more=true` 表示还有更多(点击"查看更多"跳转分页页) - 每组最多返回 6 条,`has_more=true` 表示还有更多(点击"查看更多"跳转分页页)
@ -429,7 +429,7 @@ UpgradeGrade(assetID int64, fromGrade, toGrade int) error
"asset_id": 201, "asset_id": 201,
"asset_type": "regular", "asset_type": "regular",
"name": "铸爱藏品-A", "name": "铸爱藏品-A",
"cover_url_signed": "https://oss-cn-shanghai.../signed", "cover_url": "https://bucket.oss-cn-shanghai.aliyuncs.com/xxx.jpg",
"like_count": 120, "like_count": 120,
"category": "castlove", "category": "castlove",
"grade": 5, "grade": 5,
@ -457,16 +457,16 @@ UpgradeGrade(assetID int64, fromGrade, toGrade int) error
| Service | 职责 | | Service | 职责 |
|---------|------| |---------|------|
| `AssetService` | 现有流程改造:创建普通藏品时同步写 asset_registry (type='regular', grade=1);预留 grade 升级接口 | | `AssetService` | 现有流程改造:创建原创藏品时同步写 asset_registry (type='regular', grade=1);预留 grade 升级接口 |
| `CollectionService`(新建) | 管理 `collection_assets`,典藏专属逻辑(含 category 子分类) | | `CollectionService`(新建) | 管理 `collection_assets`,典藏专属逻辑(含 category 子分类) |
| `ActivityAssetService`(新建) | 管理 `activity_assets`,活动藏品专属逻辑 | | `ActivityAssetService`(新建) | 管理 `activity_assets`,活动藏品专属逻辑 |
| `StarbookService`(新建) | 星册业务编排,调用各 Service整合数据统一生成预签名 URL | | `StarbookService`(新建) | 星册业务编排,调用各 Service整合数据返回 OSS URL |
### 6.2 StarbookService 核心逻辑 ### 6.2 StarbookService 核心逻辑
1. 从 `asset_registry` 查询当前用户 + 当前 star 下的所有藏品regular + collection + activity 1. 从 `asset_registry` 查询当前用户 + 当前 star 下的所有藏品regular + collection + activity
2. 按 type → 各自维度分组regular 按 gradecollection 按 categoryactivity 按 activity_type 2. 按 type → 各自维度分组regular 按 gradecollection 按 categoryactivity 按 activity_type
3. 批量调用 OSS 生成预签名 URL已有 `generatePresignedURL` 逻辑复用 3. 直接返回 OSS URL无需调用 OSS 预签名服务
4. 返回结构化数据 4. 返回结构化数据
### 6.3 Repository 层 ### 6.3 Repository 层
@ -541,7 +541,7 @@ message GradeSection {
message AssetItem { message AssetItem {
int64 asset_id = 1; int64 asset_id = 1;
string name = 2; string name = 2;
string cover_url_signed = 3; string cover_url = 3;
int32 like_count = 4; int32 like_count = 4;
int64 created_at = 5; int64 created_at = 5;
string category = 6; // regular: 'castlove' / collection: category / activity: activity_type string category = 6; // regular: 'castlove' / collection: category / activity: activity_type
@ -607,10 +607,10 @@ function loadStarbookData() {
| 变更前 | 变更后 | | 变更前 | 变更后 |
|--------|--------| |--------|--------|
| 调用 `getMyAssetsApi`普通藏品) | 调用 `getStarbookHomeApi`(统一,含 regular/collection/activity | | 调用 `getMyAssetsApi`原创藏品) | 调用 `getStarbookHomeApi`(统一,含 regular/collection/activity |
| 手动解析封面 URL`Promise.all` 逐个调 OSS | 后端直接返回 `cover_url_signed` | | 手动解析封面 URL`Promise.all` 逐个调 OSS | 后端直接返回 `cover_url` |
| 前端按 index 硬分组 | 后端按 type → category → grade 分组返回 | | 前端按 index 硬分组 | 后端按 type → category → grade 分组返回 |
| 普通藏品不展示在星册 | 普通藏品type='regular')纳入星册体系 | | 原创藏品不展示在星册 | 原创藏品type='regular')纳入星册体系 |
### 8.3 页面路由 ### 8.3 页面路由
@ -647,7 +647,7 @@ interface GradeSection {
interface AssetItem { interface AssetItem {
asset_id: number asset_id: number
name: string name: string
cover_url_signed: string cover_url: string
like_count: number like_count: number
created_at: number created_at: number
category: string // regular: 'castlove' / collection: 子分类 / activity: activity_type category: string // regular: 'castlove' / collection: 子分类 / activity: activity_type
@ -670,7 +670,7 @@ function toChineseGrade(grade) {
## 九、性能保障 ## 九、性能保障
1. **后端批量预签名**:单次请求一次性生成所有封面 URL 的预签名,前端零额外请求 1. **后端直接返回 OSS URL**:无需调用 OSS 预签名服务,前端直接用该 URL 访问 OSS 资源
2. **Registry 单表索引**三条索引覆盖所有查询路径owner+star, type+star, star+grade[regular] 2. **Registry 单表索引**三条索引覆盖所有查询路径owner+star, type+star, star+grade[regular]
3. **首页按组截断**:每 grade 组最多返回 6 条,`has_more` 标识是否还有更多 3. **首页按组截断**:每 grade 组最多返回 6 条,`has_more` 标识是否还有更多
4. **前端防抖**1 秒内重复触发只执行一次加载 4. **前端防抖**1 秒内重复触发只执行一次加载
@ -684,8 +684,8 @@ function toChineseGrade(grade) {
|------|------|------| |------|------|------|
| 典藏子分类category枚举值 | 待确认 | 动态字符串,设计已支持 | | 典藏子分类category枚举值 | 待确认 | 动态字符串,设计已支持 |
| 活动类型activity_type枚举值 | 待确认 | 动态字符串,设计已支持 | | 活动类型activity_type枚举值 | 待确认 | 动态字符串,设计已支持 |
| 普通藏品 grade 升级规则 | 待确认 | 设计已预留 `UpgradeGrade` 接口 | | 原创藏品 grade 升级规则 | 待确认 | 设计已预留 `UpgradeGrade` 接口 |
| 普通藏品 category 固定值 | 待确认 | 建议 `category='castlove'` | | 原创藏品 category 固定值 | 待确认 | 建议 `category='castlove'` |
--- ---
@ -699,4 +699,4 @@ function toChineseGrade(grade) {
| Service 层 | 新建 CollectionService, ActivityAssetService, StarbookService | | Service 层 | 新建 CollectionService, ActivityAssetService, StarbookService |
| Repository 层 | 新建 CollectionRepository, ActivityAssetRepository, AssetRegistryRepository | | Repository 层 | 新建 CollectionRepository, ActivityAssetRepository, AssetRegistryRepository |
| 前端 | StarbookContent.vue 重构,新增 /pages/starbook/items.vue | | 前端 | StarbookContent.vue 重构,新增 /pages/starbook/items.vue |
| 铸爱流程 | **受影响**普通藏品创建时需同步写入 asset_registrytype='regular', grade=1| | 铸爱流程 | **受影响**原创藏品创建时需同步写入 asset_registrytype='regular', grade=1|

View File

@ -18,9 +18,10 @@
``` ```
收入侧: 消耗侧: 收入侧: 消耗侧:
├── 任务奖励(水晶) ├── 铸造藏品 ├── 任务奖励(水晶) ├── 铸造藏品
├── 展示收益(水晶) ├── 商城购物(预留) ├── 展示收益(水晶) ├── 商城购物
├── 升级奖励(水晶) ├── 应援活动道具(预留) ├── 升级奖励(水晶) ├── 应援活动道具
└── 运营发放(后台手动) └── 后期其他功能 ├── 铸造奖励(水晶) └── 后期其他功能
└── 运营发放(后台手动)
``` ```
--- ---
@ -72,10 +73,10 @@ CREATE TABLE crystal_transaction_records (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
star_id BIGINT NOT NULL, star_id BIGINT NOT NULL,
change_type VARCHAR(30) NOT NULL, -- task_reward/mint_cost/exhibition_revenue/level_up_bonus/manual_adjust change_type VARCHAR(30) NOT NULL, -- task_reward/mint_cost/mint_reward/exhibition_revenue/level_up_bonus/manual_adjust
delta BIGINT NOT NULL, -- 正数=收入,负数=消耗 delta BIGINT NOT NULL, -- 正数=收入,负数=消耗
balance_before BIGINT NOT NULL, -- 变化前余额 balance_before BIGINT NOT NULL, -- 变化前余额快照
balance_after BIGINT NOT NULL, -- 变化后余额 balance_after BIGINT NOT NULL, -- 变化后余额快照
source_id VARCHAR(100), -- 关联业务ID如 task_definition.id, order_id source_id VARCHAR(100), -- 关联业务ID如 task_definition.id, order_id
description VARCHAR(255), -- 可读描述 description VARCHAR(255), -- 可读描述
created_at BIGINT NOT NULL created_at BIGINT NOT NULL
@ -86,6 +87,15 @@ CREATE INDEX ix_crystal_tx_created ON crystal_transaction_records(created_at DES
CREATE INDEX ix_crystal_tx_change_type ON crystal_transaction_records(change_type); CREATE INDEX ix_crystal_tx_change_type ON crystal_transaction_records(change_type);
``` ```
> **Balanced Transaction Entry含余额快照的复式记账**
>
> 每条记录同时存储 `balance_before`(变化前余额快照)和 `balance_after`(变化后余额快照),与 `delta` 构成三重校验:
>
> 1. **自包含审计** — 任意一条记录都能独立验证 `balance_before + delta = balance_after`,不依赖其他记录
> 2. **数据一致性验证** — 可检查记录 N 的 `balance_before` 是否等于记录 N-1 的 `balance_after`,发现数据损坏或业务 bug
> 3. **故障追溯** — 余额出错时,从任意一笔交易往前推算即可定位问题
> 4. **防错误传播** — 若只存 `delta`,计算 `balance_after` 时的 bug 会导致后续所有余额错乱;有 `balance_after` 作为 checkpoint错误被隔离在某一条不会扩散
### 3.2 游戏币交易流水表 (coin_transaction_records) ### 3.2 游戏币交易流水表 (coin_transaction_records)
> **预留:** 当前 coin_balance 全为 0未实际使用。建表 + Go模型预留等游戏币真用时再接调用方。 > **预留:** 当前 coin_balance 全为 0未实际使用。建表 + Go模型预留等游戏币真用时再接调用方。
@ -108,7 +118,7 @@ CREATE INDEX ix_coin_tx_user_star ON coin_transaction_records(user_id, star_id);
CREATE INDEX ix_coin_tx_created ON coin_transaction_records(created_at DESC); CREATE INDEX ix_coin_tx_created ON coin_transaction_records(created_at DESC);
``` ```
### 3.3 经验交易流水表 (exp_transaction_records) ### 3.3 经验变化记录表 (exp_transaction_records)
```sql ```sql
CREATE TABLE exp_transaction_records ( CREATE TABLE exp_transaction_records (
@ -116,12 +126,12 @@ CREATE TABLE exp_transaction_records (
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
star_id BIGINT NOT NULL, star_id BIGINT NOT NULL,
change_type VARCHAR(30) NOT NULL, -- task_reward/onboarding_reward/manual_adjust change_type VARCHAR(30) NOT NULL, -- task_reward/onboarding_reward/manual_adjust
delta BIGINT NOT NULL, -- 正数=获得,负数=消耗 delta BIGINT NOT NULL, -- 正数=获得(经验只增不减,溢出部分在升级时已清零)
exp_before BIGINT NOT NULL, exp_before BIGINT NOT NULL,
exp_after BIGINT NOT NULL, exp_after BIGINT NOT NULL,
level_before INT NOT NULL, -- 变化前等级 level_before INT NOT NULL, -- 变化前等级
level_after INT NOT NULL, -- 变化后等级 level_after INT NOT NULL, -- 变化后等级
level_delta INT DEFAULT 0, -- 升级了多少级(正数=升级,负数=降级0=无变化) level_delta INT NOT NULL, -- 正数=升级0=无变化
source_id VARCHAR(100), -- 关联业务ID source_id VARCHAR(100), -- 关联业务ID
description VARCHAR(255), description VARCHAR(255),
created_at BIGINT NOT NULL created_at BIGINT NOT NULL
@ -140,11 +150,11 @@ CREATE TABLE level_change_records (
star_id BIGINT NOT NULL, star_id BIGINT NOT NULL,
level_before INT NOT NULL, level_before INT NOT NULL,
level_after INT NOT NULL, level_after INT NOT NULL,
level_delta INT NOT NULL, -- 通常为 +1可用于跳级 level_delta INT NOT NULL, -- 通常为 +1跳级时可能大于1
trigger_type VARCHAR(30) NOT NULL, -- exp_gain/manual/admin_adjust trigger_type VARCHAR(30) NOT NULL, -- exp_gain/manual/admin_adjust
exp_at_change BIGINT NOT NULL, -- 触发等级变化时的经验值 exp_at_change BIGINT NOT NULL, -- 触发等级变化时的经验值
reward_claimed BOOLEAN DEFAULT false, -- 升级奖励是否已领取(预留) reward_claimed BOOLEAN DEFAULT false, -- 升级奖励是否已领取(预留)
source_id VARCHAR(100), -- 触发来源(如 task_definition.id source_id VARCHAR(100), -- 与触发升级的经验流水 source_id 相同(即触发升级的那笔 exp_transaction_records.source_id
description VARCHAR(255), description VARCHAR(255),
created_at BIGINT NOT NULL created_at BIGINT NOT NULL
); );
@ -201,6 +211,7 @@ INSERT INTO level_thresholds (level, exp_required, crystal_reward, description)
|---|---|---| |---|---|---|
| `task_reward` | 任务奖励 | + | | `task_reward` | 任务奖励 | + |
| `mint_cost` | 铸造消耗 | - | | `mint_cost` | 铸造消耗 | - |
| `mint_reward` | 铸造奖励(基础+阶梯) | + |
| `exhibition_revenue` | 展示收益 | + | | `exhibition_revenue` | 展示收益 | + |
| `level_up_bonus` | 升级奖励由调用方主动发放AddExperience 不自动发) | + | | `level_up_bonus` | 升级奖励由调用方主动发放AddExperience 不自动发) | + |
| `manual_adjust` | 手动调整(运营) | +/- | | `manual_adjust` | 手动调整(运营) | +/- |
@ -211,7 +222,7 @@ INSERT INTO level_thresholds (level, exp_required, crystal_reward, description)
|---|---|---| |---|---|---|
| `task_reward` | 任务奖励 | + | | `task_reward` | 任务奖励 | + |
| `onboarding_reward` | 引导阶段奖励 | + | | `onboarding_reward` | 引导阶段奖励 | + |
| `manual_adjust` | 手动调整 | +/- | | `manual_adjust` | 手动调整 | + |
### 4.3 等级变化触发类型 (level_change_records.trigger_type) ### 4.3 等级变化触发类型 (level_change_records.trigger_type)
@ -229,14 +240,24 @@ INSERT INTO level_thresholds (level, exp_required, crystal_reward, description)
|---|---| |---|---|
| `task_reward` | `task_definitions.id`任务ID | | `task_reward` | `task_definitions.id`任务ID |
| `mint_cost` | `mint_orders.order_id`(铸造订单号) | | `mint_cost` | `mint_orders.order_id`(铸造订单号) |
| `mint_reward` | `mint_orders.order_id`(铸造订单号) |
| `exhibition_revenue` | `exhibition_revenue_records.id` | | `exhibition_revenue` | `exhibition_revenue_records.id` |
| `level_up_bonus` | **与触发升级的经验增加 source_id 相同**(即触发升级的那笔 task/id | | `level_up_bonus` | **与触发升级的经验增加 source_id 相同**(即触发升级的那笔 exp_transaction_records.source_id |
| `manual_adjust` | 运营后台操作记录ID预留 | | `manual_adjust` | 运营后台操作记录ID预留 |
--- ---
## 五、核心方法改造 ## 五、核心方法改造
> **并发控制策略(重要)**
>
> 经济系统对数据一致性要求极高,同一用户对同一偶像的操作可能并发发生(如多个任务同时完成、铸造和展示收益同时触发)。采用 ** pessimistic locking悲观锁** 策略:
>
> - **SELECT FOR UPDATE** — 事务内先锁住 `FanProfile` 行,其他事务必须等待锁释放才能操作
> - **锁粒度**`user_id + star_id` 组合锁(最小化锁竞争)
> - **事务超时** — 设置合理超时(建议 5s避免死锁长时间阻塞
> - **禁止长时间锁** — 只在余额计算和写入期间持锁,不做额外 IO 操作
### 5.1 UpdateCrystalBalance ### 5.1 UpdateCrystalBalance
**现有签名:** **现有签名:**
@ -257,11 +278,66 @@ UpdateCrystalBalance(
``` ```
**内部逻辑(事务内):** **内部逻辑(事务内):**
1. 查询当前余额 `balance_before` 1. `SELECT FOR UPDATE` 锁定 `FanProfile` 行(`user_id = ? AND star_id = ?`**只读一次**
2. 计算 `balance_after = balance_before + delta` 2. 查询当前余额 `balance_before`
3. 写入 `crystal_transaction_records` 3. 计算 `balance_after = balance_before + delta`
4. 更新 `FanProfile.CrystalBalance` 4. 写入 `crystal_transaction_records`
5. 返回新余额 5. 更新 `FanProfile.CrystalBalance`
6. 返回新余额(事务提交后锁自动释放)
**事务边界代码:**
```go
func (s *userService) UpdateCrystalBalance(
userID int64,
starID int64,
delta int64,
changeType string,
sourceID string,
description string,
) (int64, error) {
var newBalance int64
err := s.db.Transaction(func(tx *gorm.DB) error {
// 1. SELECT FOR UPDATE 加行锁
var profile models.FanProfile
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("user_id = ? AND star_id = ?", userID, starID).
First(&profile).Error; err != nil {
return err
}
// 2. 计算余额变化
balanceBefore := profile.CrystalBalance
balanceAfter := balanceBefore + delta
// 3. 写入水晶流水
record := &model.CrystalTransactionRecord{
UserID: userID,
StarID: starID,
ChangeType: changeType,
Delta: delta,
BalanceBefore: balanceBefore,
BalanceAfter: balanceAfter,
SourceID: sourceID,
Description: description,
CreatedAt: time.Now().UnixMilli(),
}
if err := tx.Create(record).Error; err != nil {
return err
}
// 4. 更新 FanProfile
if err := tx.Model(&models.FanProfile{}).
Where("user_id = ? AND star_id = ?", userID, starID).
Update("crystal_balance", balanceAfter).Error; err != nil {
return err
}
newBalance = balanceAfter
return nil
})
return newBalance, err
}
```
--- ---
@ -285,12 +361,12 @@ AddExperience(
``` ```
**返回值说明:** **返回值说明:**
- `newExp` — 新的经验值 - `newExp` — 新的经验值(溢出升级后清零后的值)
- `newLevel` — 新的等级 - `newLevel` — 新的等级
- `levelDelta` — 等级变化量(正数=升级,负数=降级,0=无变化) - `levelDelta` — 等级变化量(正数=升级0=无变化)
**内部逻辑(事务内):** **内部逻辑(事务内):**
1. 读取当前 `FanProfile`(含 experience 和 level**只读一次** 1. `SELECT FOR UPDATE` 锁定 `FanProfile` 行(`user_id = ? AND star_id = ?`**只读一次**
2. 计算 `exp_after = profile.Experience + delta` 2. 计算 `exp_after = profile.Experience + delta`
3. 从 `level_thresholds`(含缓存)计算新等级 3. 从 `level_thresholds`(含缓存)计算新等级
4. 写入 `exp_transaction_records`(含 level_before / level_after / level_delta 4. 写入 `exp_transaction_records`(含 level_before / level_after / level_delta
@ -486,7 +562,7 @@ message AddExperienceResponse {
topfans.common.BaseResponse base = 1; topfans.common.BaseResponse base = 1;
int64 new_experience = 2; int64 new_experience = 2;
int32 new_level = 3; int32 new_level = 3;
int32 level_delta = 4; // 新增:等级变化量(正数=升级,负数=降级,0=无变化) int32 level_delta = 4; // 新增:等级变化量(正数=升级0=无变化)
} }
``` ```
@ -519,18 +595,16 @@ func (s *userService) AddExperience(
) (newExp int64, newLevel int32, levelDelta int32, err error) { ) (newExp int64, newLevel int32, levelDelta int32, err error) {
err = s.db.Transaction(func(tx *gorm.DB) error { err = s.db.Transaction(func(tx *gorm.DB) error {
// 1. 读取当前 profile只读一次 // 1. SELECT FOR UPDATE 加行锁
var profile models.FanProfile var profile models.FanProfile
if err := tx.Where("user_id = ? AND star_id = ?", userID, starID). if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("user_id = ? AND star_id = ?", userID, starID).
First(&profile).Error; err != nil { First(&profile).Error; err != nil {
return err return err
} }
// 2. 计算新经验值 // 2. 计算新经验值
newExp = profile.Experience + delta newExp = profile.Experience + delta
if newExp < 0 {
newExp = 0
}
// 3. 读取等级阈值(从缓存) // 3. 读取等级阈值(从缓存)
thresholds, err := s.levelThresholdCache.GetAll() thresholds, err := s.levelThresholdCache.GetAll()
@ -612,7 +686,7 @@ backend/
│ └── level_threshold.go # 新增LevelThreshold 模型 │ └── level_threshold.go # 新增LevelThreshold 模型
├── scripts/ ├── scripts/
│ └── v002_economic_tables.sql # 新增:所有新建表的 DDL + level_thresholds 初始数据 │ └── 20260415_economic_tables.sql # 新增:所有新建表的 DDL + level_thresholds 初始数据
├── services/userService/ ├── services/userService/
│ ├── repository/ │ ├── repository/
@ -660,12 +734,12 @@ backend/
## 十二、SQL 建表脚本 ## 十二、SQL 建表脚本
脚本路径:`backend/scripts/v002_economic_tables.sql` 脚本路径:`backend/scripts/20260415_economic_tables.sql`
```sql ```sql
-- ============================================================ -- ============================================================
-- 经济系统建表脚本 -- 经济系统建表脚本
-- 执行方式: psql -h <host> -U <user> -d <db> -f backend/scripts/v002_economic_tables.sql -- 执行方式: psql -h <host> -U <user> -d <db> -f backend/scripts/20260415_economic_tables.sql
-- ============================================================ -- ============================================================
-- 水晶交易流水表 -- 水晶交易流水表
@ -703,18 +777,18 @@ CREATE TABLE IF NOT EXISTS coin_transaction_records (
CREATE INDEX IF NOT EXISTS ix_coin_tx_user_star ON coin_transaction_records(user_id, star_id); CREATE INDEX IF NOT EXISTS ix_coin_tx_user_star ON coin_transaction_records(user_id, star_id);
CREATE INDEX IF NOT EXISTS ix_coin_tx_created ON coin_transaction_records(created_at DESC); CREATE INDEX IF NOT EXISTS ix_coin_tx_created ON coin_transaction_records(created_at DESC);
-- 经验交易流水 -- 经验变化记录
CREATE TABLE IF NOT EXISTS exp_transaction_records ( CREATE TABLE IF NOT EXISTS exp_transaction_records (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
star_id BIGINT NOT NULL, star_id BIGINT NOT NULL,
change_type VARCHAR(30) NOT NULL, change_type VARCHAR(30) NOT NULL,
delta BIGINT NOT NULL, delta BIGINT NOT NULL, -- 正数=获得(经验只增不减,溢出部分在升级时已清零)
exp_before BIGINT NOT NULL, exp_before BIGINT NOT NULL,
exp_after BIGINT NOT NULL, exp_after BIGINT NOT NULL,
level_before INT NOT NULL, level_before INT NOT NULL,
level_after INT NOT NULL, level_after INT NOT NULL,
level_delta INT DEFAULT 0, level_delta INT NOT NULL, -- 正数=升级0=无变化
source_id VARCHAR(100), source_id VARCHAR(100),
description VARCHAR(255), description VARCHAR(255),
created_at BIGINT NOT NULL created_at BIGINT NOT NULL
@ -772,10 +846,385 @@ ON CONFLICT (level) DO NOTHING;
--- ---
## 十三、后续扩展预留 ## 十三、铸造奖励系统
### 13.1 需求概述
在现有经济系统**收入侧**新增"铸造奖励"模块:
- 用户铸造藏品成功后,获得固定水晶返还(后台可配置)
- 累计铸造次数达到阶梯时,额外奖励固定水晶
- 按偶像star_id独立累计管理员可手动重置/开关
### 13.2 数据库设计
#### 13.2.1 铸造奖励配置表mint_reward_config
```sql
CREATE TABLE mint_reward_config (
id BIGSERIAL PRIMARY KEY,
star_id BIGINT NOT NULL, -- 偶像ID0=全服默认配置
base_reward BIGINT NOT NULL DEFAULT 0, -- 每次铸造基础返还水晶数
is_enabled BOOLEAN DEFAULT true, -- 功能开关
updated_at BIGINT NOT NULL,
UNIQUE(star_id)
);
-- 初始数据0=全服默认)
INSERT INTO mint_reward_config (star_id, base_reward, is_enabled, updated_at) VALUES (0, 0, false, UNIX_MILLIS());
```
#### 13.2.2 铸造阶梯奖励配置表mint_milestone_config
```sql
CREATE TABLE mint_milestone_config (
id BIGSERIAL PRIMARY KEY,
star_id BIGINT NOT NULL, -- 偶像ID0=全服默认配置
milestone_count INT NOT NULL, -- 累计次数阈值10/30/100...
bonus_reward BIGINT NOT NULL, -- 达到该阶梯时额外奖励水晶
created_at BIGINT NOT NULL,
UNIQUE(star_id, milestone_count)
);
-- 初始数据示例
INSERT INTO mint_milestone_config (star_id, milestone_count, bonus_reward, created_at) VALUES
(0, 10, 5, UNIX_MILLIS()),
(0, 30, 15, UNIX_MILLIS()),
(0, 100, 50, UNIX_MILLIS());
```
#### 13.2.3 用户铸造累计表user_mint_count
```sql
CREATE TABLE user_mint_count (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
star_id BIGINT NOT NULL,
mint_count INT NOT NULL DEFAULT 0, -- 累计铸造次数
last_milestone INT NOT NULL DEFAULT 0, -- 上次已领取的阶梯(避免重复领取)
updated_at BIGINT NOT NULL,
UNIQUE(user_id, star_id)
);
```
### 13.3 核心逻辑
#### 13.3.1 铸造奖励发放时机
`assetService.CreateMintOrder` 铸造成功后调用,采用异步方式不阻塞铸造主流程:
```go
// 铸造成功后发放奖励
func (s *MintService) CreateMintOrder(...) (orderID string, err error) {
// ... 现有铸造逻辑 ...
// 铸造成功,异步发放奖励
go func() {
if err := s.mintRewardService.GrantMintReward(ctx, userID, starID, orderID); err != nil {
logger.Logger.Error("Failed to grant mint reward", zap.Error(err))
}
}()
return orderID, nil
}
```
#### 13.3.2 奖励计算示例
配置:
- `base_reward = 10`(每次铸造返 10 水晶)
- 阶梯10次+530次+15100次+50
用户铸造路径:
| 铸造次数 | 基础奖励 | 阶梯奖励 | 本次总奖励 |
|---|---|---|---|
| 第1次 | +10 | 0 | +10 |
| 第10次 | +10 | +5达到10阶 | +15 |
| 第30次 | +10 | +15达到30阶 | +25 |
| 第31次 | +10 | 0 | +10 |
### 13.4 管理员操作
#### 13.4.1 功能开关
```go
// 关闭/开启指定偶像的铸造奖励
SetMintRewardConfig(starID int64, isEnabled bool, baseReward int64) error
```
#### 13.4.2 阶梯配置
```go
// 添加/修改阶梯
SetMilestone(starID int64, milestone int, bonus int64) error
// 删除阶梯
RemoveMilestone(starID int64, milestone int) error
```
#### 13.4.3 重置用户累计
```go
// 重置指定用户的累计铸造次数
ResetMintCount(userID, starID int64) error
// 重置指定偶像下所有用户的累计铸造次数
ResetAllMintCount(starID int64) error
```
### 13.5 Proto 接口(管理后台调用)
`asset.proto` 新增以下接口:
```protobuf
// 设置铸造奖励配置(开关 + 基础奖励)
message SetMintRewardConfigRequest {
int64 star_id = 1; // 偶像ID0=全服默认
bool is_enabled = 2; // 是否开启
int64 base_reward = 3; // 每次铸造基础返还水晶数
}
message SetMintRewardConfigResponse {
topfans.common.BaseResponse base = 1;
}
// 添加/修改阶梯奖励
message SetMilestoneRequest {
int64 star_id = 1; // 偶像ID0=全服默认
int32 milestone_count = 2; // 累计次数阈值
int64 bonus_reward = 3; // 达到阶梯时额外奖励水晶
}
message SetMilestoneResponse {
topfans.common.BaseResponse base = 1;
}
// 删除阶梯奖励
message RemoveMilestoneRequest {
int64 star_id = 1;
int32 milestone_count = 2;
}
message RemoveMilestoneResponse {
topfans.common.BaseResponse base = 1;
}
// 重置单个用户铸造累计
message ResetMintCountRequest {
int64 user_id = 1;
int64 star_id = 2;
}
message ResetMintCountResponse {
topfans.common.BaseResponse base = 1;
}
// 重置偶像下所有用户铸造累计
message ResetAllMintCountRequest {
int64 star_id = 1;
}
message ResetAllMintCountResponse {
topfans.common.BaseResponse base = 1;
}
// 查询铸造奖励配置(后台管理用)
message GetMintRewardConfigRequest {
int64 star_id = 1;
}
message GetMintRewardConfigResponse {
topfans.common.BaseResponse base = 1;
MintRewardConfig config = 2;
}
message MintRewardConfig {
int64 star_id = 1;
bool is_enabled = 2;
int64 base_reward = 3;
repeated Milestone milestones = 4;
}
message Milestone {
int32 milestone_count = 1;
int64 bonus_reward = 2;
}
```
### 13.6 事务边界
铸造奖励在 `GrantMintReward` 内独立事务,不与铸造订单共用事务。铸造订单本身成功与否不依赖奖励发放——奖励失败只记录 error log不回滚铸造。
`user_mint_count` 的累加同样需要 `SELECT FOR UPDATE` 锁,保证同一用户对同一偶像的铸造奖励和累计不出现并发问题。
```go
func (s *MintRewardService) GrantMintReward(ctx context.Context, userID, starID int64, orderID string) error {
return s.db.Transaction(func(tx *gorm.DB) error {
// 1. 查询铸造奖励配置:优先查 star_id未配置则查全服默认star_id=0
var config model.MintRewardConfig
err := tx.Where("star_id = ?", starID).First(&config).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// star_id 未配置,使用全服默认
if err := tx.Where("star_id = ?", int64(0)).First(&config).Error; err != nil {
return err // 全服默认也没有,直接返回
}
} else if err != nil {
return err
}
// 2. 检查开关
if !config.IsEnabled || config.BaseReward <= 0 {
return nil
}
// 3. SELECT FOR UPDATE 锁定 FanProfile一次性获取最新余额后续所有奖励基于此快照累加
var profile models.FanProfile
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("user_id = ? AND star_id = ?", userID, starID).
First(&profile).Error; err != nil {
return err
}
currentBalance := profile.CrystalBalance
// 4. 计算本次总奖励:基础奖励 + 所有新达成的阶梯奖励(一次性统一下账)
totalReward := config.BaseReward
// 5. 查询当前铸造累计 + SELECT FOR UPDATE 锁
var mintCount model.UserMintCount
isNew := false
err = tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("user_id = ? AND star_id = ?", userID, starID).
First(&mintCount).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
isNew = true
mintCount = model.UserMintCount{
UserID: userID,
StarID: starID,
MintCount: 0,
LastMilestone: 0,
UpdatedAt: time.Now().UnixMilli(),
}
} else if err != nil {
return err
}
// 累加本次铸造
mintCount.MintCount++
mintCount.UpdatedAt = time.Now().UnixMilli()
// 6. 查询所有未领取的阶梯star_id 配置优先,全服默认补充(同一阶梯只取一次)
// 先按 milestone_count 分组合并star_id>0 的覆盖 star_id=0 的
var milestones []model.MintMilestoneConfig
tx.Raw(`
SELECT COALESCE(MAX(star_id), 0) as star_id, milestone_count, MAX(bonus_reward) as bonus_reward
FROM mint_milestone_config
WHERE star_id IN (0, ?) AND milestone_count <= ? AND milestone_count > ?
GROUP BY milestone_count
HAVING MAX(star_id) = ?
ORDER BY milestone_count ASC
`, starID, mintCount.MintCount, mintCount.LastMilestone, starID).
Scan(&milestones)
// 7. 累加阶梯奖励,并更新 last_milestone
for _, m := range milestones {
totalReward += m.BonusReward
mintCount.LastMilestone = m.MilestoneCount
}
// 8. 如果有奖励,统一下账(一条流水 + 一次余额更新)
if totalReward > 0 {
newBalance := currentBalance + totalReward
crystalRecord := &model.CrystalTransactionRecord{
UserID: userID,
StarID: starID,
ChangeType: "mint_reward",
Delta: totalReward,
BalanceBefore: currentBalance,
BalanceAfter: newBalance,
SourceID: orderID,
Description: "铸造奖励",
CreatedAt: time.Now().UnixMilli(),
}
if err := tx.Create(crystalRecord).Error; err != nil {
return err
}
if err := tx.Model(&models.FanProfile{}).
Where("user_id = ? AND star_id = ?", userID, starID).
Update("crystal_balance", newBalance).Error; err != nil {
return err
}
}
// 9. 保存铸造累计记录
if isNew {
if err := tx.Create(&mintCount).Error; err != nil {
return err
}
} else {
if err := tx.Save(&mintCount).Error; err != nil {
return err
}
}
return nil
})
}
```
> **设计要点:**
> - 步骤3一次性对 `FanProfile` 加锁并获取 `currentBalance` 快照,所有奖励基于同一余额快照累加,避免重复加锁
> - 步骤8统一下账基础奖励 + 阶梯奖励合并为一条 `crystal_transaction_records``delta = totalReward`),减少流水记录数量,同时保证原子性
> - 阶梯奖励的 `bonus_reward` 分开记录在 `mint_milestone_config`,流水记录只记最终 `totalReward`,避免一条铸造产生多条奖励流水
### 13.7 项目文件结构
```
backend/
├── pkg/models/
│ ├── mint_reward.go # 新增MintRewardConfig / MintMilestoneConfig / UserMintCount 模型
├── scripts/
│ └── 20260415_mint_reward_tables.sql # 新增mint_reward_config / mint_milestone_config / user_mint_count DDL
├── services/assetService/
│ ├── repository/
│ │ ├── mint_reward_config_repository.go # 新增
│ │ ├── mint_milestone_config_repository.go # 新增
│ │ └── user_mint_count_repository.go # 新增
│ │
│ ├── client/
│ │ └── user_rpc_client.go # 修改:新增 UpdateCrystalBalance RPC 调用
│ │
│ └── service/
│ ├── mint_service.go # 修改:铸造成功后调用 GrantMintReward
│ └── mint_reward_service.go # 新增:铸造奖励核心逻辑(含 GrantMintReward + 管理员方法)
├── services/userService/
│ └── client/
│ └── user_rpc_client.go # 修改AddExperience / UpdateCrystalBalance 签名
├── proto/
│ ├── user.proto # 修改AddExperienceResponse 增加 new_level, level_delta
│ ├── task.proto # 修改ClaimDailyTaskResponse / ClaimAllDailyTasksResponse
│ │ 增加 new_level, level_delta
│ └── asset.proto # 新增:铸造奖励管理接口 + MintRewardConfig 数据结构
└── pkg/proto/
├── user/
│ ├── user.pb.go # 重新生成
│ └── user.triple.go # 重新生成
├── task/
│ ├── task.pb.go # 重新生成
│ └── task.triple.go # 重新生成
└── asset/
├── asset.pb.go # 新增
└── asset.triple.go # 新增
```
## 十四、后续扩展预留
1. **coin_transaction_records** — 等游戏币有实际用途时启用(当前 delta 写 0 1. **coin_transaction_records** — 等游戏币有实际用途时启用(当前 delta 写 0
2. **level_change_records.reward_claimed** — 升级奖励领取状态(等运营后台需要手动补发时启用) 2. **流水分页查询 API** — `GET /api/economy/crystal-history` 等(当前只记录不查询
3. **流水分页查询 API**`GET /api/economy/crystal-history` 等(当前只记录不查询) 3. **铸造奖励上限** — 单日/单周铸造奖励上限防刷(等风控需求明确后实现
4. **level_thresholds.crystal_reward** — 升级奖励水晶已在表中配置 4. **阶梯百分比奖励** — 当前为固定额外水晶,后续可扩展为 base_reward 的百分比
5. **扩展索引** — 见 3.4 节注释 5. **重置策略自动化** — 支持按周期(每日/每周)自动重置(当前为手动重置)