docs: 文档
This commit is contained in:
parent
8b89e34b18
commit
d0ff68508f
@ -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. 普通藏品按 grade(1/2/3)分组展示,典藏按 category 子分类分组,活动按 activity_type 分组
|
2. 原创藏品按 grade(1/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 按 grade,collection 按 category,activity 按 activity_type)
|
2. 按 type → 各自维度分组(regular 按 grade,collection 按 category,activity 按 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_registry(type='regular', grade=1)|
|
| 铸爱流程 | **受影响**,原创藏品创建时需同步写入 asset_registry(type='regular', grade=1)|
|
||||||
|
|||||||
@ -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, -- 偶像ID,0=全服默认配置
|
||||||
|
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, -- 偶像ID,0=全服默认配置
|
||||||
|
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次+5,30次+15,100次+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; // 偶像ID,0=全服默认
|
||||||
|
bool is_enabled = 2; // 是否开启
|
||||||
|
int64 base_reward = 3; // 每次铸造基础返还水晶数
|
||||||
|
}
|
||||||
|
|
||||||
|
message SetMintRewardConfigResponse {
|
||||||
|
topfans.common.BaseResponse base = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加/修改阶梯奖励
|
||||||
|
message SetMilestoneRequest {
|
||||||
|
int64 star_id = 1; // 偶像ID,0=全服默认
|
||||||
|
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. **重置策略自动化** — 支持按周期(每日/每周)自动重置(当前为手动重置)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user