diff --git a/README.md b/README.md index a2ad1c5..8c3e127 100644 --- a/README.md +++ b/README.md @@ -1 +1,2 @@ -# TopFans \ No newline at end of file +# TopFans +背景有什么想法?边框想要什么样式的?有没有特别想加的装饰元素?整体材质的效果想要什么样的?主色调是应援色吗,还是别的颜色? \ No newline at end of file diff --git a/backend/docs/铸爱创作模块技术设计文档.md b/backend/docs/铸爱创作模块技术设计文档.md new file mode 100644 index 0000000..7e4738c --- /dev/null +++ b/backend/docs/铸爱创作模块技术设计文档.md @@ -0,0 +1,1540 @@ +# 铸爱创作模块 技术设计文档 + +> **文档版本:** V1.0 +> **创建日期:** 2026-04-09 +> **关联产品文档:** 铸爱创作模块PRD.md +> **状态:** 初稿 + +--- + +## 1. 产品需求概述 + +### 1.1 核心功能 + +| 功能模块 | 说明 | +|----------|------| +| 顶部运营轮播图 | 展示运营活动,点击跳转H5或站内页面 | +| 主Tab(星卡/吧唧/海报) | 三大创作分类,点击直接进入铸造页面 | +| 分类标签 | 筛选用户已铸造的藏品(热门/最新/各分类) | +| 创作网格列表 | 双列瀑布流展示藏品卡片 | +| 铸造页面 | 选择素材 → 输入描述词 → AI生成 | +| AI处理中页面 | 展示创作进度 | +| AI创作结果预览 | 预览结果 → 重新生成/确认铸造 | +| 创作详情页 | 展示藏品信息、用户名、点赞、身份证编号 | +| 身份证编号(上链) | 每份藏品全球唯一编号,用于确权追溯 | + +### 1.2 现有系统能力复用 + +| 现有服务 | 可复用能力 | +|----------|-----------| +| AssetService | 数字藏品铸造、资产查询、OSS存储、上链 | +| ActivityService | 运营活动管理(轮播图配置) | +| GalleryService | 展馆展示(可参考布局) | +| UserService | 用户信息、身份验证 | +| SocialService | 点赞功能 | + +--- + +## 2. 技术架构设计 + +### 2.1 服务职责划分 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ API Gateway (8080) │ +│ 统一入口 / 认证 / 协议转换 / 请求路由 │ +└─────────────────────────────────────────────────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ AssetService │ │ ActivityService │ │ SocialService │ +│ (tri:20003) │ │ (tri:20005?) │ │ (tri:20001) │ +├─────────────────┤ ├─────────────────┤ ├─────────────────┤ +│ 铸造订单管理 │ │ 运营活动配置 │ │ 藏品点赞 │ +│ 资产CRUD │ │ 轮播图管理 │ │ 评论(扩展) │ +│ AI任务调度 │ │ 活动跳转配置 │ │ │ +│ 上链状态 │ │ │ │ │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +### 2.2 新增/扩展接口 + +| 接口 | 方法 | 归属服务 | 说明 | 状态 | +|------|------|----------|------|------| +| `GET /api/v1/activities/banners` | GET | ActivityService | 获取轮播图列表 | **新增** | +| `GET /api/v1/assets/cast/items` | GET | AssetService | 获取铸爱创作列表(按分类筛选) | **新增** | +| `GET /api/v1/assets/cast/options` | GET | AssetService | 获取铸爱类型选项(吧唧装饰/海报风格) | **新增** | +| `GET /api/v1/assets/:asset_id` | GET | AssetService | 获取藏品详情(**复用现有接口**) | 已存在 | +| `POST /api/v1/social/assets/:asset_id/like` | POST | SocialService | 藏品点赞(**复用现有接口**) | 已存在 | +| `POST /api/v1/assets/cast/mints` | POST | AssetService | 创建铸爱铸造订单 | **新增** | +| `GET /api/v1/assets/cast/mints/:order_id` | GET | AssetService | 查询铸造订单状态 | **新增** | + +**接口复用说明:** +- `GET /api/v1/assets/:asset_id` - 现有接口,可查询藏品详情 +- `POST /api/v1/social/assets/:asset_id/like` - 现有接口,可用于点赞 + +--- + +## 3. 数据模型设计 + +### 3.1 铸爱藏品表(新建) + +**表名:** `cast_assets` + +```sql +CREATE TABLE IF NOT EXISTS cast_assets ( + id BIGSERIAL PRIMARY KEY, + owner_uid BIGINT NOT NULL COMMENT '所有者ID', + star_id BIGINT NOT NULL COMMENT '明星ID,用于数据隔离', + name VARCHAR(255) NOT NULL COMMENT '藏品名称', + material_url TEXT COMMENT '原始素材URL(用户相册图片)', + cover_url TEXT COMMENT 'AI生成结果图URL', + description TEXT COMMENT '藏品描述', + cast_type INT NOT NULL DEFAULT 1 COMMENT '藏品类型:1=星卡, 2=吧唧, 3=海报', + identity_no VARCHAR(50) UNIQUE COMMENT '身份证编号(全局唯一)', + tx_hash VARCHAR(100) COMMENT '上链交易哈希', + block_number BIGINT COMMENT '区块高度', + status INT NOT NULL DEFAULT 0 COMMENT '状态:0=处理中, 1=成功, 2=失败', + like_count INT NOT NULL DEFAULT 0 COMMENT '点赞数', + user_id BIGINT NOT NULL COMMENT '创作者ID', + minted_at BIGINT COMMENT '上链时间(毫秒)', + created_at BIGINT NOT NULL DEFAULT 0, + updated_at BIGINT NOT NULL DEFAULT 0, + + -- 索引 + INDEX idx_cast_assets_star_id (star_id), + INDEX idx_cast_assets_owner_uid_star_id (owner_uid, star_id), + INDEX idx_cast_assets_cast_type (cast_type), + INDEX idx_cast_assets_identity_no (identity_no), + INDEX idx_cast_assets_user_id (user_id), + INDEX idx_cast_assets_status (status), + INDEX idx_cast_assets_like_count (like_count), + INDEX idx_cast_assets_created_at (created_at) +) COMMENT '铸爱藏品表'; +``` + +### 3.2 铸爱铸造订单表(新建) + +**表名:** `cast_mint_orders` + +```sql +CREATE TABLE IF NOT EXISTS cast_mint_orders ( + order_id VARCHAR(50) PRIMARY KEY COMMENT '订单ID(UUID)', + user_id BIGINT NOT NULL COMMENT '用户ID', + star_id BIGINT NOT NULL COMMENT '明星ID', + asset_id BIGINT NOT NULL COMMENT '关联藏品ID', + cast_type INT NOT NULL COMMENT '藏品类型:1=星卡, 2=吧唧, 3=海报', + prompt_text TEXT COMMENT '用户输入的描述词', + material_url TEXT COMMENT '原始素材URL', + cover_url TEXT COMMENT 'AI生成结果URL(完成后回填)', + identity_no VARCHAR(50) COMMENT '身份证编号', + tx_hash VARCHAR(100) COMMENT '上链哈希', + status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING/SUCCESS/FAILED', + error_message TEXT COMMENT '失败原因', + cost_crystal BIGINT DEFAULT 0 COMMENT '消耗水晶', + retry_count INT DEFAULT 0 COMMENT '重试次数', + -- 吧唧专属参数 + decoration_type VARCHAR(50) COMMENT '吧唧装饰类型:cream_glue/metal_frame/shaped', + -- 海报专属参数 + aspect_ratio VARCHAR(20) COMMENT '海报比例:landscape/portrait/square', + style_options TEXT COMMENT '海报风格选项:JSON数组,bg_replace/style_transfer/lighting', + created_at BIGINT NOT NULL DEFAULT 0, + updated_at BIGINT NOT NULL DEFAULT 0, + minted_at BIGINT COMMENT '完成时间', + + -- 索引 + INDEX idx_cast_mint_orders_user_star (user_id, star_id), + INDEX idx_cast_mint_orders_status (status), + INDEX idx_cast_mint_orders_asset_id (asset_id), + INDEX idx_cast_mint_orders_created_at (created_at) +) COMMENT '铸爱铸造订单表'; +``` + +### 3.3 铸爱点赞表(扩展现有 asset_likes) + +**扩展现有表,新增字段:** + +```sql +ALTER TABLE asset_likes ADD COLUMN IF NOT EXISTS asset_type INT DEFAULT 1 COMMENT '资产类型:1=原有资产, 2=铸爱藏品'; +ALTER TABLE asset_likes ADD COLUMN IF NOT EXISTS cast_asset_id BIGINT COMMENT '铸爱藏品ID(asset_type=2时使用)'; + +-- 添加联合唯一索引,防止重复点赞 +ALTER TABLE asset_likes ADD CONSTRAINT uk_asset_like UNIQUE (user_id, asset_id, asset_type); +``` + +### 3.4 铸爱类型选项配置表(新建) + +**表名:** `cast_type_options` + +**说明:** 运维后台上传和管理各类型的可选项(吧唧装饰类型、海报风格选项等),前端下发给用户选择,同时传给AI生成。 + +```sql +CREATE TABLE IF NOT EXISTS cast_type_options ( + id BIGSERIAL PRIMARY KEY, + cast_type INT NOT NULL COMMENT '藏品类型:1=星卡, 2=吧唧, 3=海报', + option_key VARCHAR(50) NOT NULL COMMENT '选项标识,如 cream_glue / metal_frame / bg_replace', + option_name VARCHAR(100) NOT NULL COMMENT '选项显示名称,如 奶油胶 / 金属边框 / 背景替换', + option_type VARCHAR(30) NOT NULL COMMENT '选项类别:decoration / style', + icon_url TEXT COMMENT '选项图标URL', + sort_order INT DEFAULT 0 COMMENT '排序', + status INT DEFAULT 1 COMMENT '状态:0=禁用, 1=启用', + created_at BIGINT NOT NULL DEFAULT 0, + updated_at BIGINT NOT NULL DEFAULT 0, + + -- 索引 + INDEX idx_cast_type_options_type (cast_type), + INDEX idx_cast_type_options_key (cast_type, option_key), + UNIQUE KEY uk_cast_type_option (cast_type, option_key) +) COMMENT '铸爱类型选项配置表'; +``` + +**示例数据:** + +| cast_type | option_key | option_name | option_type | +|-----------|------------|-------------|-------------| +| 2 (吧唧) | cream_glue | 奶油胶 | decoration | +| 2 (吧唧) | metal_frame | 金属边框 | decoration | +| 2 (吧唧) | shaped | 异形裁切 | decoration | +| 3 (海报) | bg_replace | 背景替换 | style | +| 3 (海报) | style_transfer | 风格迁移 | style | +| 3 (海报) | lighting | 光影调整 | style | + +**运维后台功能:** +- 新增/编辑/删除选项 +- 上传选项图标 +- 调整排序 + +--- + +## 4. 接口详细设计与代码实现 + +### 4.1 获取轮播图列表(新增接口) + +**Endpoint:** `GET /api/v1/activities/banners` + +**归属服务:** ActivityService + +#### 4.1.1 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| position | string | 否 | 位置标识,默认 "cast_home" | + +#### 4.1.2 响应格式 + +```json +{ + "code": 200, + "message": "success", + "data": { + "items": [ + { + "id": 1, + "image_url": "https://xxx/banner1.jpg", + "title": "铸爱联名活动", + "description": "参与铸爱创作赢好礼", + "link_type": "h5", + "link_value": "https://xxx/activity" + } + ] + } +} +``` + +#### 4.1.3 DTO 定义 + +```go +// BannerDTO 轮播图响应 +type BannerDTO struct { + ID int64 `json:"id"` + ImageURL string `json:"image_url"` + Title string `json:"title"` + Description string `json:"description"` + LinkType string `json:"link_type"` // h5 / activity / internal + LinkValue string `json:"link_value"` // 跳转目标 +} + +// BannerListResponse 轮播图列表响应 +type BannerListResponse struct { + Items []BannerDTO `json:"items"` +} +``` + +#### 4.1.4 Service 层实现 + +```go +// ActivityService/service/activity_service.go + +// GetBanners 获取轮播图列表 +func (s *ActivityService) GetBanners(ctx context.Context, position string) ([]*BannerDTO, error) { + // 默认位置 + if position == "" { + position = "cast_home" + } + + // 查询数据库 + banners, err := s.bannerRepo.FindByPosition(ctx, position) + if err != nil { + return nil, err + } + + // 转换为DTO + dtos := make([]*BannerDTO, 0, len(banners)) + for _, banner := range banners { + dtos = append(dtos, &BannerDTO{ + ID: banner.ID, + ImageURL: banner.ImageURL, + Title: banner.Title, + Description: banner.Description, + LinkType: banner.LinkType, + LinkValue: banner.LinkValue, + }) + } + + return dtos, nil +} +``` + +#### 4.1.5 Controller 层实现 + +```go +// Gateway/controller/activity_controller.go + +// GetBanners 获取轮播图列表 +// @Summary 获取轮播图列表 +// @Tags activity +// @Param position query string false "位置标识" +// @Success 200 {object} BannerListResponse +// @Router /api/v1/activities/banners [get] +func (c *ActivityController) GetBanners(ctx *gin.Context) { + position := ctx.DefaultQuery("position", "cast_home") + + banners, err := c.activityClient.GetBanners(ctx, position) + if err != nil { + response.Fail(ctx, 500, "获取轮播图失败") + return + } + + response.Success(ctx, gin.H{ + "items": banners, + }) +} +``` + +--- + +### 4.2 获取铸爱创作列表(新增接口) + +**Endpoint:** `GET /api/v1/assets/cast/items` + +**归属服务:** AssetService + +#### 4.2.1 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| cast_type | int32 | 否 | 藏品类型:1=星卡, 2=吧唧, 3=海报 | +| sort_by | string | 否 | 排序方式:hot(热门) / latest(最新),默认hot | +| page | int32 | 否 | 页码,默认1 | +| page_size | int32 | 否 | 每页数量,默认20 | + +#### 4.2.2 响应格式 + +```json +{ + "code": 200, + "message": "success", + "data": { + "total": 100, + "page": 1, + "page_size": 20, + "items": [ + { + "asset_id": 12345, + "name": "我的铸爱作品", + "cover_url": "https://xxx/cover.jpg", + "cover_url_signed": "https://xxx/cover.jpg?Expires=...&Signature=...", + "identity_no": "ZUA-20260409-00001", + "cast_type": 1, + "user_id": 10001, + "user_nickname": "用户昵称", + "user_avatar": "https://xxx/avatar.jpg", + "like_count": 999, + "is_liked": false, + "created_at": 1705747200000 + } + ] + } +} +``` + +#### 4.2.3 DTO 定义 + +```go +// CastAssetItemDTO 铸爱藏品列表项 +type CastAssetItemDTO struct { + AssetID int64 `json:"asset_id"` + Name string `json:"name"` + CoverURL string `json:"cover_url"` + CoverURLSigned string `json:"cover_url_signed"` + IdentityNo string `json:"identity_no"` + CastType int32 `json:"cast_type"` + UserID int64 `json:"user_id"` + UserNickname string `json:"user_nickname"` + UserAvatar string `json:"user_avatar"` + LikeCount int32 `json:"like_count"` + IsLiked bool `json:"is_liked"` + CreatedAt int64 `json:"created_at"` +} + +// CastAssetListResponse 铸爱藏品列表响应 +type CastAssetListResponse struct { + Total int64 `json:"total"` + Page int32 `json:"page"` + PageSize int32 `json:"page_size"` + Items []CastAssetItemDTO `json:"items"` +} +``` + +#### 4.2.4 Service 层实现 + +```go +// AssetService/service/cast_service.go + +// GetCastItems 获取铸爱创作列表 +func (s *CastService) GetCastItems(ctx context.Context, req *GetCastItemsRequest) (*CastAssetListResponse, error) { + // 参数校验与默认值 + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 || req.PageSize > 100 { + req.PageSize = 20 + } + if req.SortBy == "" { + req.SortBy = "hot" + } + + // 构建查询条件 + query := &CastAssetQuery{ + StarID: req.StarID, + CastType: req.CastType, + SortBy: req.SortBy, + Page: req.Page, + PageSize: req.PageSize, + Status: 1, // 只查询成功的 + } + + // 查询列表 + assets, total, err := s.castRepo.FindList(ctx, query) + if err != nil { + return nil, err + } + + // 获取当前用户已点赞的藏品ID列表 + likedMap, err := s.getUserLikedMap(ctx, req.UserID, assets) + if err != nil { + return nil, err + } + + // 转换为DTO + items := make([]CastAssetItemDTO, 0, len(assets)) + for _, asset := range assets { + // 生成预签名URL + coverURLSigned, _ := s.generatePresignedURL(asset.CoverURL, 3600) + + // 获取用户信息 + user, _ := s.userClient.GetUserInfo(ctx, asset.UserID) + + items = append(items, CastAssetItemDTO{ + AssetID: asset.ID, + Name: asset.Name, + CoverURL: asset.CoverURL, + CoverURLSigned: coverURLSigned, + IdentityNo: asset.IdentityNo, + CastType: asset.CastType, + UserID: asset.UserID, + UserNickname: user.Nickname, + UserAvatar: user.Avatar, + LikeCount: asset.LikeCount, + IsLiked: likedMap[asset.ID], + CreatedAt: asset.CreatedAt, + }) + } + + return &CastAssetListResponse{ + Total: total, + Page: req.Page, + PageSize: req.PageSize, + Items: items, + }, nil +} + +// generatePresignedURL 生成预签名URL +func (s *CastService) generatePresignedURL(url string, expireSeconds int64) (string, error) { + if url == "" { + return "", nil + } + // 调用OSS生成预签名URL + return s.ossClient.GetPresignedURL(ctx, url, expireSeconds) +} + +// getUserLikedMap 获取用户已点赞的藏品Map +func (s *CastService) getUserLikedMap(ctx context.Context, userID int64, assets []*CastAsset) (map[int64]bool, error) { + assetIDs := make([]int64, 0, len(assets)) + for _, asset := range assets { + assetIDs = append(assetIDs, asset.ID) + } + + likedAssetIDs, err := s.socialClient.GetUserLikedCastAssets(ctx, userID, assetIDs) + if err != nil { + return nil, err + } + + likedMap := make(map[int64]bool) + for _, id := range likedAssetIDs { + likedMap[id] = true + } + return likedMap, nil +} +``` + +#### 4.2.5 Repository 层实现 + +```go +// AssetService/repository/cast_asset_repo.go + +// FindList 查询铸爱藏品列表 +func (r *CastAssetRepository) FindList(ctx context.Context, query *CastAssetQuery) ([]*CastAsset, int64, error) { + db := r.db.WithContext(ctx) + + // 条件构建 + if query.StarID > 0 { + db = db.Where("star_id = ?", query.StarID) + } + if query.CastType > 0 { + db = db.Where("cast_type = ?", query.CastType) + } + if query.Status > 0 { + db = db.Where("status = ?", query.Status) + } + + // 排序 + switch query.SortBy { + case "latest": + db = db.Order("created_at DESC") + case "hot": + db = db.Order("like_count DESC, created_at DESC") + default: + db = db.Order("like_count DESC, created_at DESC") + } + + // 查询总数 + var total int64 + if err := db.Model(&CastAsset{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + // 分页 + offset := (query.Page - 1) * query.PageSize + db = db.Offset(offset).Limit(query.PageSize) + + // 执行查询 + var assets []*CastAsset + if err := db.Find(&assets).Error; err != nil { + return nil, 0, err + } + + return assets, total, nil +} +``` + +--- + +### 4.3 获取铸爱藏品详情(复用现有接口) + +**Endpoint:** `GET /api/v1/assets/:asset_id` + +**归属服务:** AssetService + +**接口说明:** 复用现有的 `GetAsset` 接口,无需新增。铸爱藏品使用 `cast_assets` 表,扩展 `AssetService.GetAsset` 方法支持查询铸爱藏品。 + +#### 4.3.1 响应格式 + +```json +{ + "code": 200, + "message": "success", + "data": { + "asset_id": 12345, + "name": "我的铸爱作品", + "cover_url": "https://xxx/cover.jpg", + "cover_url_signed": "https://xxx/cover.jpg?Expires=...&Signature=...", + "identity_no": "ZUA-20260409-00001", + "identity_no_chain_url": "https://xxx/chain/query?no=ZUA-20260409-00001", + "cast_type": 1, + "description": "藏品描述", + "user_id": 10001, + "user_nickname": "用户昵称", + "user_avatar": "https://xxx/avatar.jpg", + "like_count": 999, + "is_liked": false, + "created_at": 1705747200000 + } +} +``` + +#### 4.3.2 DTO 定义 + +```go +// CastAssetDetailDTO 铸爱藏品详情 +type CastAssetDetailDTO struct { + AssetID int64 `json:"asset_id"` + Name string `json:"name"` + CoverURL string `json:"cover_url"` + CoverURLSigned string `json:"cover_url_signed"` + IdentityNo string `json:"identity_no"` + IdentityNoChainURL string `json:"identity_no_chain_url"` + CastType int32 `json:"cast_type"` + Description string `json:"description"` + UserID int64 `json:"user_id"` + UserNickname string `json:"user_nickname"` + UserAvatar string `json:"user_avatar"` + LikeCount int32 `json:"like_count"` + IsLiked bool `json:"is_liked"` + CreatedAt int64 `json:"created_at"` +} +``` + +#### 4.3.3 Service 层实现 + +```go +// GetCastAssetDetail 获取铸爱藏品详情 +func (s *CastService) GetCastAssetDetail(ctx context.Context, assetID, userID int64) (*CastAssetDetailDTO, error) { + // 查询藏品 + asset, err := s.castRepo.FindByID(ctx, assetID) + if err != nil { + return nil, err + } + if asset == nil { + return nil, errors.New("藏品不存在") + } + + // 生成预签名URL + coverURLSigned, _ := s.generatePresignedURL(asset.CoverURL, 3600) + + // 获取用户信息 + user, _ := s.userClient.GetUserInfo(ctx, asset.UserID) + + // 查询用户是否已点赞 + isLiked, _ := s.socialClient.IsUserLikedCastAsset(ctx, userID, assetID) + + // 链上查询URL(预留) + chainURL := s.buildChainQueryURL(asset.IdentityNo) + + return &CastAssetDetailDTO{ + AssetID: asset.ID, + Name: asset.Name, + CoverURL: asset.CoverURL, + CoverURLSigned: coverURLSigned, + IdentityNo: asset.IdentityNo, + IdentityNoChainURL: chainURL, + CastType: asset.CastType, + Description: asset.Description, + UserID: asset.UserID, + UserNickname: user.Nickname, + UserAvatar: user.Avatar, + LikeCount: asset.LikeCount, + IsLiked: isLiked, + CreatedAt: asset.CreatedAt, + }, nil +} + +// buildChainQueryURL 构建链上查询URL +func (s *CastService) buildChainQueryURL(identityNo string) string { + // 预留链上查询功能 + return fmt.Sprintf("https://xxx/chain/query?no=%s", identityNo) +} +``` + +--- + +### 4.4 创建铸爱铸造订单(新增接口) + +**Endpoint:** `POST /api/v1/assets/cast/mints` + +**归属服务:** AssetService + +**页面说明:** +- 用户点击主Tab(星卡/吧唧/海报)后进入对应类型的铸造页面 +- 铸造页面是同一个页面,通过 `cast_type` 参数区分类型 +-铸造流程:选择素材 → 输入描述词 → 发送 → AI处理中 → 结果预览 + +**三种铸造类型:** + +| cast_type | 类型 | 素材形态 | 尺寸规格 | 创作特点 | 专属参数 | +|-----------|------|----------|----------|----------|----------| +| 1 | 星卡 | 卡片形态,类似明星小卡 | 1:1 或 3:4 | 适合人物特写、半身照 | 无 | +| 2 | 吧唧 | 徽章/贴纸形态 | 圆形或异形 | 适合可爱风格、二次元、奶油胶/金属边框/异形裁切 | decoration_type | +| 3 | 海报 | 大尺寸图片 | 16:9、4:3 或 2:3 | 适合全身照、场景图、背景替换/风格迁移/光影调整 | aspect_ratio, style_options | + +#### 4.4.1 请求格式 + +```json +{ + "name": "我的铸爱作品", + "material_url": "https://xxx/material.jpg", + "description": "藏品描述", + "cast_type": 1, + "prompt_text": "请生成一张温暖风格的图片", + "decoration_type": "", + "aspect_ratio": "", + "style_options": [] +} +``` + +**请求参数说明:** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| name | string | 是 | 藏品名称 | +| material_url | string | 是 | 用户选择的相册图片URL | +| description | string | 否 | 藏品描述 | +| cast_type | int32 | 是 | 藏品类型:1=星卡, 2=吧唧, 3=海报 | +| prompt_text | string | 是 | AI描述词 | +| decoration_type | string | 否 | **吧唧专属**:装饰类型,记录用户选择,从 `/cast/options` 获取,如 cream_glue/metal_frame/shaped,可组合逗号分隔 | +| aspect_ratio | string | 否 | **海报专属**:比例,landscape(横向16:9) / portrait(竖向2:3) / square(方形4:3) | +| style_options | string[] | 否 | **海报专属**:风格选项,记录用户选择,从 `/cast/options` 获取,如 bg_replace/style_transfer/lighting,可多选 | + +**参数说明:** +- `decoration_type` 和 `style_options` 仅记录用户选择,**不参与AI生成**,AI会根据cast_type自动生成对应效果 +- `aspect_ratio` 为海报比例参数,AI生成时使用 + +**不同类型的参数组合:** + +| cast_type | 必须参数 | 可选参数 | +|-----------|----------|----------| +| 1 (星卡) | name, material_url, cast_type, prompt_text | description | +| 2 (吧唧) | name, material_url, cast_type, prompt_text | description, decoration_type | +| 3 (海报) | name, material_url, cast_type, prompt_text | description, aspect_ratio, style_options | + +#### 4.4.2 响应格式 + +```json +{ + "code": 200, + "message": "success", + "data": { + "order_id": "550e8400-e29b-41d4-a716-446655440000", + "asset_id": 12345, + "status": "PROCESSING", + "identity_no": "ZUA-20260409-00002", + "created_at": 1705747200000 + } +} +``` + +#### 4.4.3 DTO 定义 + +```go +// CreateCastMintRequest 创建铸造订单请求 +type CreateCastMintRequest struct { + Name string `json:"name" binding:"required"` + MaterialURL string `json:"material_url" binding:"required,url"` + Description string `json:"description"` + CastType int32 `json:"cast_type" binding:"required,oneof=1 2 3"` + PromptText string `json:"prompt_text" binding:"required"` + // 吧唧专属参数 + DecorationType string `json:"decoration_type"` // cream_glue, metal_frame, shaped + // 海报专属参数 + AspectRatio string `json:"aspect_ratio"` // landscape, portrait, square + StyleOptions []string `json:"style_options"` // bg_replace, style_transfer, lighting +} + +// CreateCastMintResponse 创建铸造订单响应 +type CreateCastMintResponse struct { + OrderID string `json:"order_id"` + AssetID int64 `json:"asset_id"` + Status string `json:"status"` + IdentityNo string `json:"identity_no"` + CreatedAt int64 `json:"created_at"` +} +``` + +#### 4.4.4 Service 层实现(核心流程) + +```go +// CreateCastMint 创建铸爱铸造订单 +func (s *CastService) CreateCastMint(ctx context.Context, userID, starID int64, req *CreateCastMintRequest) (*CreateCastMintResponse, error) { + // 1. 参数校验 + if req.Name == "" || req.MaterialURL == "" || req.PromptText == "" { + return nil, errors.New("参数不完整") + } + if req.CastType < 1 || req.CastType > 3 { + return nil, errors.New("无效的藏品类型") + } + + // 2. 生成唯一订单ID + orderID := uuid.New().String() + + // 3. 生成唯一身份证编号 + identityNo, err := s.generateIdentityNo(ctx) + if err != nil { + return nil, fmt.Errorf("生成身份证编号失败: %w", err) + } + + // 4. 创建藏品记录(初始状态:处理中) + asset := &CastAsset{ + OwnerUID: userID, + StarID: starID, + Name: req.Name, + MaterialURL: req.MaterialURL, + Description: req.Description, + CastType: req.CastType, + IdentityNo: identityNo, + Status: 0, // 处理中 + UserID: userID, + CreatedAt: time.Now().UnixMilli(), + UpdatedAt: time.Now().UnixMilli(), + } + if err := s.castRepo.Create(ctx, asset); err != nil { + return nil, fmt.Errorf("创建藏品记录失败: %w", err) + } + + // 5. 创建订单记录 + order := &CastMintOrder{ + OrderID: orderID, + UserID: userID, + StarID: starID, + AssetID: asset.ID, + CastType: req.CastType, + PromptText: req.PromptText, + MaterialURL: req.MaterialURL, + IdentityNo: identityNo, + Status: "PROCESSING", + DecorationType: req.DecorationType, // 吧唧专属 + AspectRatio: req.AspectRatio, // 海报专属 + StyleOptions: req.StyleOptions, // 海报专属 + CreatedAt: time.Now().UnixMilli(), + UpdatedAt: time.Now().UnixMilli(), + } + if err := s.mintOrderRepo.Create(ctx, order); err != nil { + return nil, fmt.Errorf("创建订单记录失败: %w", err) + } + + // 6. 启动异步AI生成任务(decoration_type/style_options仅记录,不传给AI) + go s.processCastAIGeneration(orderID, asset.ID, req.CastType, req.PromptText, req.MaterialURL, req.AspectRatio) + + // 7. 返回响应 + return &CreateCastMintResponse{ + OrderID: orderID, + AssetID: asset.ID, + Status: "PROCESSING", + IdentityNo: identityNo, + CreatedAt: asset.CreatedAt, + }, nil +} +``` + +#### 4.4.5 异步AI生成任务(核心代码) + +```go +// processCastAIGeneration 异步处理AI生成 +func (s *CastService) processCastAIGeneration(orderID string, assetID int64, castType int32, promptText, materialURL, aspectRatio string) { + // 创建独立的context,避免请求超时 + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + logger.Info("开始处理铸爱AI生成", + zap.String("order_id", orderID), + zap.Int64("asset_id", assetID), + zap.Int32("cast_type", castType), + zap.String("aspect_ratio", aspectRatio), + zap.String("prompt", promptText), + ) + + // 1. 构建AI请求参数 + aiReq := &AIGenerateRequest{ + Model: "minimax", + InputImage: materialURL, + Prompt: promptText, + AspectRatio: aspectRatio, + } + + // 2. 根据cast_type设置AI生成参数 + // decoration_type和style_options仅记录到订单,不参与AI生成 + switch castType { + case 1: // 星卡 + aiReq.AspectRatio = "1:1" + case 2: // 吧唧 + // AI根据cast_type自动生成吧唧装饰效果 + case 3: // 海报 + // AI根据aspectRatio确定输出图片的尺寸比例 + } + + // 3. 调用MiniMax AI服务进行图生图 + aiResult, err := s.aiClient.GenerateImage(ctx, aiReq) + if err != nil { + s.handleAIGenerationFailed(ctx, orderID, assetID, "AI服务调用失败: "+err.Error()) + return + } + + // 5. 下载AI生成的图片 + generatedImage, err := s.downloadImage(ctx, aiResult.ImageURL) + if err != nil { + s.handleAIGenerationFailed(ctx, orderID, assetID, "下载AI图片失败: "+err.Error()) + return + } + + // 6. 上传到OSS + coverURL, err := s.uploadToOSS(ctx, generatedImage, assetID) + if err != nil { + s.handleAIGenerationFailed(ctx, orderID, assetID, "上传OSS失败: "+err.Error()) + return + } + + // 7. 生成模拟上链信息 + txHash := s.generateMockTxHash() + blockNumber := s.generateMockBlockNumber() + mintedAt := time.Now().UnixMilli() + + // 8. 更新藏品状态 + if err := s.updateAssetSuccess(ctx, assetID, coverURL, txHash, blockNumber, mintedAt); err != nil { + s.handleAIGenerationFailed(ctx, orderID, assetID, "更新藏品状态失败: "+err.Error()) + return + } + + // 9. 更新订单状态 + if err := s.updateOrderSuccess(ctx, orderID, coverURL, txHash); err != nil { + logger.Error("更新订单状态失败", zap.String("order_id", orderID), zap.Error(err)) + return + } + + logger.Info("铸爱AI生成完成", + zap.String("order_id", orderID), + zap.Int64("asset_id", assetID), + zap.String("cover_url", coverURL), + zap.String("tx_hash", txHash), + ) +} + +// handleAIGenerationFailed 处理AI生成失败 +func (s *CastService) handleAIGenerationFailed(ctx context.Context, orderID string, assetID int64, errorMsg string) { + logger.Error("铸爱AI生成失败", + zap.String("order_id", orderID), + zap.Int64("asset_id", assetID), + zap.String("error", errorMsg), + ) + + // 1. 更新订单状态为失败 + order, _ := s.mintOrderRepo.FindByOrderID(ctx, orderID) + if order != nil { + order.Status = "FAILED" + order.ErrorMessage = errorMsg + order.UpdatedAt = time.Now().UnixMilli() + s.mintOrderRepo.Update(ctx, order) + + // 2. 退回水晶费用 + if order.CostCrystal > 0 { + s.refundCrystalBalance(ctx, order.UserID, order.StarID, order.CostCrystal) + } + } + + // 3. 更新藏品状态 + s.castRepo.UpdateStatus(ctx, assetID, 2) // 2=失败 +} +``` + +#### 4.4.6 身份证编号生成 + +```go +// generateIdentityNo 生成唯一身份证编号 +// 格式: ZUA-YYYYMMDD-XXXXX +func (s *CastService) generateIdentityNo(ctx context.Context) (string, error) { + // 获取当前日期 + dateStr := time.Now().Format("20060102") + + // 使用Redis INCR获取当日序号(按日期key) + key := fmt.Sprintf("cast:identity:seq:%s", dateStr) + seq, err := s.redisClient.Incr(ctx, key) + if err != nil { + return "", err + } + + // 设置过期时间(次日凌晨) + s.redisClient.ExpireAt(ctx, key, s.getNextDayMidnight()) + + // 格式化为5位序号 + seqStr := fmt.Sprintf("%05d", seq) + return fmt.Sprintf("ZUA-%s-%s", dateStr, seqStr), nil +} + +// getNextDayMidnight 获取次日凌晨时间 +func (s *CastService) getNextDayMidnight() time.Time { + tomorrow := time.Now().AddDate(0, 0, 1) + return time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 0, 0, 0, 0, tomorrow.Location()) +} +``` + +--- + +### 4.5 查询铸造订单状态(新增接口) + +**Endpoint:** `GET /api/v1/assets/cast/mints/:order_id` + +**归属服务:** AssetService + +#### 4.5.1 响应格式 + +```json +{ + "code": 200, + "message": "success", + "data": { + "order_id": "550e8400-e29b-41d4-a716-446655440000", + "asset_id": 12345, + "status": "PROCESSING", + "cover_url": null, + "cover_url_signed": null, + "identity_no": "ZUA-20260409-00002", + "tx_hash": null, + "error_message": null, + "created_at": 1705747200000, + "updated_at": 1705747200000 + } +} +``` + +#### 4.5.2 DTO 定义 + +```go +// MintOrderStatusDTO 铸造订单状态 +type MintOrderStatusDTO struct { + OrderID string `json:"order_id"` + AssetID int64 `json:"asset_id"` + Status string `json:"status"` // PROCESSING / SUCCESS / FAILED + CoverURL string `json:"cover_url"` + CoverURLSigned string `json:"cover_url_signed"` + IdentityNo string `json:"identity_no"` + TxHash string `json:"tx_hash"` + ErrorMessage string `json:"error_message,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} +``` + +#### 4.5.3 Service 层实现 + +```go +// GetMintOrderStatus 查询铸造订单状态 +func (s *CastService) GetMintOrderStatus(ctx context.Context, orderID string) (*MintOrderStatusDTO, error) { + // 查询订单 + order, err := s.mintOrderRepo.FindByOrderID(ctx, orderID) + if err != nil { + return nil, err + } + if order == nil { + return nil, errors.New("订单不存在") + } + + dto := &MintOrderStatusDTO{ + OrderID: order.OrderID, + AssetID: order.AssetID, + Status: order.Status, + IdentityNo: order.IdentityNo, + ErrorMessage: order.ErrorMessage, + CreatedAt: order.CreatedAt, + UpdatedAt: order.UpdatedAt, + } + + // 如果成功,填充图片URL + if order.Status == "SUCCESS" && order.CoverURL != "" { + dto.CoverURL = order.CoverURL + dto.CoverURLSigned, _ = s.generatePresignedURL(order.CoverURL, 3600) + dto.TxHash = order.TxHash + } + + return dto, nil +} +``` + +--- + +### 4.6 铸爱藏品点赞(复用现有接口) + +**Endpoint:** `POST /api/v1/social/assets/:asset_id/like` + +**归属服务:** SocialService + +**接口说明:** 复用现有的 `LikeAsset` 接口。扩展 `asset_likes` 表的 `asset_type` 字段,支持区分原有资产(1)和铸爱藏品(2)。 + +#### 4.6.1 请求格式 + +```json +{ + "asset_id": 12345 +} +``` + +#### 4.6.2 响应格式 + +```json +{ + "code": 200, + "message": "success", + "data": { + "asset_id": 12345, + "is_liked": true, + "like_count": 1000 + } +} +``` + +#### 4.6.3 DTO 定义 + +```go +// LikeCastAssetRequest 点赞请求 +type LikeCastAssetRequest struct { + AssetID int64 `json:"asset_id" binding:"required"` +} + +// LikeCastAssetResponse 点赞响应 +type LikeCastAssetResponse struct { + AssetID int64 `json:"asset_id"` + IsLiked bool `json:"is_liked"` + LikeCount int32 `json:"like_count"` +} +``` + +#### 4.6.4 Service 层实现 + +```go +// LikeCastAsset 铸爱藏品点赞/取消点赞 +func (s *SocialService) LikeCastAsset(ctx context.Context, userID, assetID int64) (*LikeCastAssetResponse, error) { + // 查询是否已点赞 + existing, err := s.assetLikeRepo.FindByUserAndAsset(ctx, userID, assetID, 2) // asset_type=2 + if err != nil { + return nil, err + } + + var isLiked bool + if existing != nil { + // 取消点赞 + if err := s.assetLikeRepo.Delete(ctx, existing.ID); err != nil { + return nil, err + } + // 减少点赞数 + s.castAssetRepo.DecrLikeCount(ctx, assetID) + isLiked = false + } else { + // 添加点赞 + like := &AssetLike{ + UserID: userID, + AssetID: assetID, + AssetType: 2, // 铸爱藏品 + CastAssetID: assetID, + CreatedAt: time.Now().UnixMilli(), + } + if err := s.assetLikeRepo.Create(ctx, like); err != nil { + return nil, err + } + // 增加点赞数 + s.castAssetRepo.IncrLikeCount(ctx, assetID) + isLiked = true + } + + // 获取最新点赞数 + likeCount, _ := s.castAssetRepo.GetLikeCount(ctx, assetID) + + return &LikeCastAssetResponse{ + AssetID: assetID, + IsLiked: isLiked, + LikeCount: likeCount, + }, nil +} +``` + +--- + +### 4.7 获取铸爱类型选项(新增接口) + +**Endpoint:** `GET /api/v1/assets/cast/options` + +**归属服务:** AssetService + +**接口说明:** 获取各类型的可选项(吧唧装饰类型、海报风格选项等),供前端下发给用户选择。 + +#### 4.7.1 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| cast_type | int32 | 是 | 藏品类型:1=星卡, 2=吧唧, 3=海报 | + +#### 4.7.2 响应格式 + +```json +{ + "code": 200, + "message": "success", + "data": { + "cast_type": 2, + "options": [ + { + "key": "cream_glue", + "name": "奶油胶", + "type": "decoration", + "icon_url": "https://xxx/icons/cream_glue.png" + }, + { + "key": "metal_frame", + "name": "金属边框", + "type": "decoration", + "icon_url": "https://xxx/icons/metal_frame.png" + }, + { + "key": "shaped", + "name": "异形裁切", + "type": "decoration", + "icon_url": "https://xxx/icons/shaped.png" + } + ] + } +} +``` + +#### 4.7.3 DTO 定义 + +```go +// CastTypeOptionDTO 铸爱类型选项 +type CastTypeOptionDTO struct { + Key string `json:"key"` // 选项标识 + Name string `json:"name"` // 显示名称 + Type string `json:"type"` // decoration / style + IconURL string `json:"icon_url"` // 图标 +} + +// CastTypeOptionsResponse 铸爱类型选项响应 +type CastTypeOptionsResponse struct { + CastType int32 `json:"cast_type"` + Options []CastTypeOptionDTO `json:"options"` +} +``` + +#### 4.7.4 Service 层实现 + +```go +// GetCastTypeOptions 获取铸爱类型选项 +func (s *CastService) GetCastTypeOptions(ctx context.Context, castType int32) (*CastTypeOptionsResponse, error) { + // 查询启用的选项 + options, err := s.castTypeOptionRepo.FindByCastType(ctx, castType, 1) + if err != nil { + return nil, err + } + + // 转换为DTO + dtos := make([]CastTypeOptionDTO, 0, len(options)) + for _, opt := range options { + dtos = append(dtos, CastTypeOptionDTO{ + Key: opt.OptionKey, + Name: opt.OptionName, + Type: opt.OptionType, + IconURL: opt.IconURL, + }) + } + + return &CastTypeOptionsResponse{ + CastType: castType, + Options: dtos, + }, nil +} +``` + +#### 4.7.5 运维后台接口(新增) + +**Endpoint:** `GET /api/v1/admin/cast/options` + +| 接口 | 方法 | 说明 | +|------|------|------| +| `GET /api/v1/admin/cast/options` | GET | 获取选项列表(支持筛选cast_type) | +| `POST /api/v1/admin/cast/options` | POST | 新增选项 | +| `PUT /api/v1/admin/cast/options/:id` | PUT | 更新选项 | +| `DELETE /api/v1/admin/cast/options/:id` | DELETE | 删除选项 | + +--- + +## 5. AI 服务对接 + +### 5.1 MiniMax AI 服务对接 + +#### 5.1.1 AI 客户端接口 + +```go +// AI客户端接口 +type AIClient interface { + GenerateImage(ctx context.Context, req *AIGenerateRequest) (*AIGenerateResponse, error) +} + +// AIGenerateRequest AI生成请求 +type AIGenerateRequest struct { + Model string // 模型标识: minimax / openai / sd + InputImage string // 输入图片URL + Prompt string // 描述词 + AspectRatio string // 输出尺寸比例:1:1(星卡) / landscape(海报16:9) / portrait(海报2:3) / square(海报4:3) +} + +// AIGenerateResponse AI生成响应 +type AIGenerateResponse struct { + ImageURL string // 生成的图片URL + RequestID string // 请求ID +} +``` + +#### 5.1.2 MiniMax 实现 + +```go +// MiniMaxAIClient MiniMax AI客户端 +type MiniMaxAIClient struct { + apiKey string + apiURL string + httpClient *http.Client +} + +// GenerateImage 调用MiniMax图生图API +func (c *MiniMaxAIClient) GenerateImage(ctx context.Context, req *AIGenerateRequest) (*AIGenerateResponse, error) { + // 构造请求 + payload := map[string]interface{}{ + "model": "minimax-vl-01", + "image_url": req.InputImage, + "prompt": req.Prompt, + "num_images": 1, + "aspect_ratio": req.AspectRatio, // 1:1(星卡) / landscape(海报16:9) / portrait(海报2:3) / square(海报4:3) + } + + httpReq, _ := http.NewRequestWithContext(ctx, "POST", c.apiURL+"/v1/image_generation", bytes.NewReader(mustMarshal(payload))) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) + + // 发送请求 + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("MiniMax API error: %d - %s", resp.StatusCode, string(body)) + } + + // 解析响应 + var result struct { + Data []struct { + ImageURL string `json:"image_url"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + if len(result.Data) == 0 { + return nil, errors.New("AI返回数据为空") + } + + return &AIGenerateResponse{ + ImageURL: result.Data[0].ImageURL, + RequestID: resp.Header.Get("X-Request-ID"), + }, nil +} +``` + +### 5.2 多模型切换策略(后续扩展) + +```go +// MultiModelAIClient 多模型AI客户端(后续扩展) +type MultiModelAIClient struct { + clients map[string]AIClient // 模型客户端Map + defaultModel string +} + +// GenerateImage 根据模型生成图片 +func (c *MultiModelAIClient) GenerateImage(ctx context.Context, req *AIGenerateRequest) (*AIGenerateResponse, error) { + model := req.Model + if model == "" { + model = c.defaultModel + } + + client, ok := c.clients[model] + if !ok { + return nil, fmt.Errorf("不支持的AI模型: %s", model) + } + + return client.GenerateImage(ctx, req) +} +``` + +--- + +## 6. 服务间调用关系 + +### 6.1 调用链 + +``` +API Gateway + │ + ├── ActivityService (gRPC) + │ └── GetBanners(position) → BannerDTO[] + │ + ├── AssetService (gRPC) + │ ├── GetCastItems() → CastAssetListResponse + │ ├── GetCastAssetDetail() → CastAssetDetailDTO + │ ├── CreateCastMint() → CreateCastMintResponse + │ │ │ + │ │ ├── UserService.UpdateCrystalBalance() 扣水晶 + │ │ └── 启动异步任务 processCastAIGeneration() + │ └── GetMintOrderStatus() → MintOrderStatusDTO + │ + └── SocialService (gRPC) + ├── GetUserLikedCastAssets() → assetID[] + ├── IsUserLikedCastAsset() → bool + └── LikeCastAsset() → LikeCastAssetResponse +``` + +### 6.2 gRPC Proto 定义 + +```protobuf +// AssetService Proto +service AssetService { + // 获取铸爱创作列表 + rpc GetCastItems(GetCastItemsRequest) returns (CastAssetListResponse); + + // 获取铸爱类型选项(吧唧装饰/海报风格) + rpc GetCastTypeOptions(GetCastTypeOptionsRequest) returns (CastTypeOptionsResponse); + + // 获取铸爱藏品详情 + rpc GetCastAssetDetail(GetCastAssetDetailRequest) returns (CastAssetDetailDTO); + + // 创建铸爱铸造订单 + rpc CreateCastMint(CreateCastMintRequest) returns (CreateCastMintResponse); + + // 查询铸造订单状态 + rpc GetMintOrderStatus(GetMintOrderStatusRequest) returns (MintOrderStatusDTO); +} + +// SocialService Proto +service SocialService { + // 铸爱藏品点赞 + rpc LikeCastAsset(LikeCastAssetRequest) returns (LikeCastAssetResponse); +} +``` + +--- + +## 7. 前端对接说明 + +### 7.1 页面路由 + +| 页面 | 路由 | 对应接口 | +|------|------|----------| +| 铸爱首页 | `/cast` | `GetBanners`, `GetCastItems` | +| 铸造页面 | `/cast/create/:type` | `CreateCastMintOrder`, `GetCastMintStatus` | +| AI处理中 | `/cast/processing/:order_id` | `GetMintOrderStatus`(轮询) | +| 结果预览 | `/cast/result/:order_id` | `GetMintOrderStatus` | +| 创作详情 | `/cast/detail/:asset_id` | `GetCastAssetDetail`, `LikeCastAsset` | + +### 7.2 前端轮询策略 + +```javascript +// 铸造订单状态轮询 +async function pollMintStatus(orderId) { + const maxAttempts = 60; // 最多60次 + const interval = 3000; // 3秒 + + for (let i = 0; i < maxAttempts; i++) { + const resp = await api.getCastMintStatus(orderId); + const { status, cover_url, error_message } = resp.data; + + if (status === 'SUCCESS') { + // 铸造成功,跳转结果页 + router.push(`/cast/result/${orderId}`); + return; + } + + if (status === 'FAILED') { + // 铸造失败,显示错误 + showToast(error_message || '铸造失败'); + return; + } + + // 继续等待 + await sleep(interval); + } + + // 超时 + showToast('铸造超时,请稍后重试'); +} +``` + +--- + +## 8. 待确认事项 + +| 问题 | 说明 | 状态 | +|------|------|------| +| 铸造费用 | 铸爱创作是否需要消耗水晶?收费标准? | 待确认 | +| AI 模型 | 初期 MiniMax,后续多模型切换 | 已确认 | +| 多模型切换 | 后续扩展时,模型切换策略(用户可选/后台配置)? | 待确认 | +| 评论功能 | 是否需要评论功能? | 待确认 | +| 分享功能 | 分享到外部平台的规则? | 待确认 | + +--- + +## 9. 实现计划 + +### Phase 1:基础设施(1-2天) +- [ ] 创建数据库表 `cast_assets`、`cast_mint_orders` +- [ ] 在 AssetService 中新增 Cast 相关方法 +- [ ] 在 ActivityService 中新增 Banner 轮播接口 +- [ ] 扩展 asset_likes 表支持 asset_type 字段 + +### Phase 2:核心功能(3-5天) +- [x] 实现 `GET /api/v1/activities/banners` ← **新增** +- [x] 实现 `GET /api/v1/assets/cast/items` ← **新增** +- [ ] 实现 `GET /api/v1/assets/:asset_id` ← **复用现有接口**,扩展支持铸爱藏品查询 +- [x] 实现 `POST /api/v1/assets/cast/mints` ← **新增** +- [x] 实现 `GET /api/v1/assets/cast/mints/:order_id` ← **新增** +- [x] 实现 AI 异步生成任务(MiniMax 图生图)← **新增** + +### Phase 3:社交功能(1-2天) +- [ ] 实现 `POST /api/v1/social/assets/:asset_id/like` ← **复用现有接口**,扩展支持铸爱藏品点赞 + +### Phase 4:联调测试(2-3天) +- [ ] 前端页面联调 +- [ ] 全流程测试 +- [ ] 性能测试 + +--- + +**接口状态汇总:** + +| 接口 | 状态 | 说明 | +|------|------|------| +| `GET /api/v1/activities/banners` | 新增 | 轮播图列表 | +| `GET /api/v1/assets/cast/items` | 新增 | 铸爱创作列表 | +| `GET /api/v1/assets/cast/options` | 新增 | 铸爱类型选项 | +| `GET /api/v1/assets/:asset_id` | 复用 | 藏品详情(需扩展) | +| `POST /api/v1/assets/cast/mints` | 新增 | 创建铸造订单 | +| `GET /api/v1/assets/cast/mints/:order_id` | 新增 | 查询订单状态 | +| `POST /api/v1/social/assets/:asset_id/like` | 复用 | 点赞(需扩展) | + +--- + +## 10. 参考文档 + +| 文档 | 说明 | +|------|------| +| `docs/资产铸造AI生成流程设计文档.md` | 现有AI铸造流程设计参考 | +| `PROJECT_SUMMARY.md` | 项目整体架构说明 | +| `docs/资产服务设计文档.md` | AssetService 设计说明 | + +--- + +> **下一步行动:** 请确认以上设计决策,特别是铸造费用和AI模型对接方案。 diff --git a/docs/superpowers/plans/2026-04-08-task-management-system-implementation-plan.md b/docs/superpowers/plans/2026-04-08-task-management-system-implementation-plan.md new file mode 100644 index 0000000..38428ec --- /dev/null +++ b/docs/superpowers/plans/2026-04-08-task-management-system-implementation-plan.md @@ -0,0 +1,2417 @@ +# 任务管理系统实现计划 + +> **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:** 实现完整的任务管理系统,包括 activity-admin 运营后台和 Go taskService 移动后端,共用数据库 + +**Architecture:** +- activity-admin (Python FastAPI): 任务定义管理、进度查询、统计、手动重置 +- Go taskService (Dubbo-go Triple): 移动端 API、事件处理、奖励发放、每日重置 +- 共享 PostgreSQL 数据库,Go 服务通过 Advisory Lock 确保单实例重置 + +**Tech Stack:** +- Python: FastAPI, SQLAlchemy, Pydantic +- Go: Dubbo-go, GORM, PostgreSQL +- Frontend: Vue 3, Element Plus, ECharts + +--- + +## Phase 1: 数据库设计与初始化 + +### Task 1.1: 创建数据库表 + +**Files:** +- Create: `scripts/task_system_migration.sql` + +- [ ] **Step 1: 创建 SQL 迁移脚本** + +```sql +-- 任务定义表 +CREATE TABLE IF NOT EXISTS task_definitions ( + id BIGSERIAL PRIMARY KEY, + star_id BIGINT, -- NULL=全局默认 + task_key VARCHAR(50) NOT NULL, + task_type VARCHAR(20) NOT NULL, -- 'daily' | 'onboarding' + name VARCHAR(100) NOT NULL, + description TEXT, + crystal_reward BIGINT DEFAULT 0, + exp_reward BIGINT DEFAULT 0, + sort_order INT DEFAULT 0, + is_active BOOLEAN DEFAULT true, + created_at BIGINT, + updated_at BIGINT +); + +CREATE UNIQUE INDEX ix_task_def_star_key ON task_definitions(star_id, task_key); + +-- 每日任务进度表 +CREATE TABLE IF NOT EXISTS user_daily_task_progress ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + star_id BIGINT NOT NULL, + task_key VARCHAR(50) NOT NULL, + status VARCHAR(20) DEFAULT 'pending', -- pending/completed/claimed + completed_at BIGINT, + claimed_at BIGINT, + created_at BIGINT, + updated_at BIGINT +); + +CREATE UNIQUE INDEX ix_daily_progress_user_star_key ON user_daily_task_progress(user_id, star_id, task_key); + +-- 引导任务进度表 +CREATE TABLE IF NOT EXISTS user_onboarding_progress ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + task_key VARCHAR(50) NOT NULL, + status VARCHAR(20) DEFAULT 'pending', -- pending/completed/claimed + completed_at BIGINT, + claimed_at BIGINT, + created_at BIGINT, + updated_at BIGINT +); + +CREATE UNIQUE INDEX ix_onboard_progress_user_key ON user_onboarding_progress(user_id, task_key); + +-- 引导流程状态表 +CREATE TABLE IF NOT EXISTS user_onboarding_status ( + user_id BIGINT PRIMARY KEY, + current_stage INT DEFAULT 0, -- 0=未开始,1~N=阶段 + status VARCHAR(20) DEFAULT 'pending', -- pending/in_progress/completed/claimed + is_first_login_bonus_claimed BOOLEAN DEFAULT false, + has_friend_display_bonus BOOLEAN DEFAULT false, + completed_at BIGINT, + claimed_at BIGINT, + created_at BIGINT, + updated_at BIGINT +); + +-- 引导阶段配置表 +CREATE TABLE IF NOT EXISTS onboarding_stage_config ( + id BIGSERIAL PRIMARY KEY, + stage INT NOT NULL, + name VARCHAR(100) NOT NULL, + description TEXT, + required_task_keys TEXT[], -- PostgreSQL 数组类型 + crystal_reward BIGINT DEFAULT 0, + exp_reward BIGINT DEFAULT 0, + sort_order INT DEFAULT 0, + is_active BOOLEAN DEFAULT true, + created_at BIGINT, + updated_at BIGINT +); + +-- 展示收益记录表 +CREATE TABLE IF NOT EXISTS exhibition_revenue_records ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + star_id BIGINT NOT NULL, + exhibition_id BIGINT NOT NULL, + asset_id BIGINT NOT NULL, + slot_id BIGINT NOT NULL, + slot_owner_uid BIGINT NOT NULL, + slot_type VARCHAR(20) NOT NULL, -- 'own' | 'friend' + crystal_amount BIGINT NOT NULL, + cycle_start_time BIGINT NOT NULL, + cycle_end_time BIGINT NOT NULL, + status VARCHAR(20) DEFAULT 'claimable', -- claimable/claimed + claimed_at BIGINT, + created_at BIGINT +); + +CREATE INDEX ix_revenue_user_star_status ON exhibition_revenue_records(user_id, star_id, status); +CREATE INDEX ix_revenue_star_status ON exhibition_revenue_records(star_id, status); + +-- 重置记录表(用于防止重复重置) +CREATE TABLE IF NOT EXISTS task_reset_log ( + id BIGSERIAL PRIMARY KEY, + reset_type VARCHAR(20) NOT NULL, -- 'daily' | 'manual' + star_id BIGINT, + last_reset_at BIGINT NOT NULL, + created_at BIGINT +); +``` + +- [ ] **Step 2: 执行迁移脚本** + +Run: `psql -h localhost -U postgres -d topfans -f scripts/task_system_migration.sql` + +--- + +### Task 1.2: 插入默认任务数据 + +**Files:** +- Create: `scripts/task_default_data.sql` + +- [ ] **Step 1: 创建默认任务数据脚本** + +```sql +-- 插入默认每日任务(star_id = NULL 表示全局默认) +INSERT INTO task_definitions (star_id, task_key, task_type, name, description, crystal_reward, exp_reward, sort_order, is_active, created_at, updated_at) VALUES +(NULL, 'daily_login', 'daily', '每日首次登录', '每日首次登录 App', 20, 20, 1, true, EXTRACT(EPOCH FROM NOW())::bigint * 1000, EXTRACT(EPOCH FROM NOW())::bigint * 1000), +(NULL, 'daily_browse_asset', 'daily', '每日首次浏览藏品', '每日首次浏览任意展品', 20, 40, 2, true, EXTRACT(EPOCH FROM NOW())::bigint * 1000, EXTRACT(EPOCH FROM NOW())::bigint * 1000), +(NULL, 'daily_mint', 'daily', '每日首次铸造', '每日首次铸造藏品', 20, 60, 3, true, EXTRACT(EPOCH FROM NOW())::bigint * 1000, EXTRACT(EPOCH FROM NOW())::bigint * 1000), +(NULL, 'daily_place_asset', 'daily', '每日首次上架藏品', '每日首次上架藏品到展厅', 20, 80, 4, true, EXTRACT(EPOCH FROM NOW())::bigint * 1000, EXTRACT(EPOCH FROM NOW())::bigint * 1000); + +-- 插入默认引导任务 +INSERT INTO task_definitions (star_id, task_key, task_type, name, description, crystal_reward, exp_reward, sort_order, is_active, created_at, updated_at) VALUES +(NULL, 'onboarding_complete', 'onboarding', '完成新手引导', '完成全部新手引导流程', 420, 250, 1, true, EXTRACT(EPOCH FROM NOW())::bigint * 1000, EXTRACT(EPOCH FROM NOW())::bigint * 1000); + +-- 插入引导阶段配置(动态配置) +INSERT INTO onboarding_stage_config (stage, name, required_task_keys, crystal_reward, exp_reward, sort_order, is_active, created_at, updated_at) VALUES +(1, '入门引导', ARRAY['square_home', 'profile_edit'], 0, 0, 1, true, EXTRACT(EPOCH FROM NOW())::bigint * 1000, EXTRACT(EPOCH FROM NOW())::bigint * 1000), +(2, '进阶引导', ARRAY['starbook_add', 'exhibition_add'], 0, 0, 2, true, EXTRACT(EPOCH FROM NOW())::bigint * 1000, EXTRACT(EPOCH FROM NOW())::bigint * 1000), +(3, '完成引导', ARRAY['exhibition_operate'], 0, 0, 3, true, EXTRACT(EPOCH FROM NOW())::bigint * 1000, EXTRACT(EPOCH FROM NOW())::bigint * 1000); +``` + +--- + +## Phase 2: Python Backend (activity-admin) 任务管理模块 + +### Task 2.1: 创建 SQLAlchemy 模型 + +**Files:** +- Create: `backend/models/task_models.py` + +- [ ] **Step 1: 创建任务相关模型** + +```python +from sqlalchemy import Column, BigInteger, String, Text, Boolean, Integer, Index +from database import Base + + +class TaskDefinition(Base): + """任务定义表""" + __tablename__ = "task_definitions" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + star_id = Column(BigInteger, nullable=True) + task_key = Column(String(50), nullable=False) + task_type = Column(String(20), nullable=False) + name = Column(String(100), nullable=False) + description = Column(Text) + crystal_reward = Column(BigInteger, default=0) + exp_reward = Column(BigInteger, default=0) + sort_order = Column(Integer, default=0) + is_active = Column(Boolean, default=True) + created_at = Column(BigInteger) + updated_at = Column(BigInteger) + + __table_args__ = ( + Index('ix_task_def_star_key', 'star_id', 'task_key'), + ) + + +class UserDailyTaskProgress(Base): + """每日任务进度表""" + __tablename__ = "user_daily_task_progress" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + user_id = Column(BigInteger, nullable=False) + star_id = Column(BigInteger, nullable=False) + task_key = Column(String(50), nullable=False) + status = Column(String(20), default='pending') + completed_at = Column(BigInteger) + claimed_at = Column(BigInteger) + created_at = Column(BigInteger) + updated_at = Column(BigInteger) + + __table_args__ = ( + Index('ix_daily_progress_user_star_key', 'user_id', 'star_id', 'task_key', unique=True), + ) + + +class UserOnboardingProgress(Base): + """引导任务进度表""" + __tablename__ = "user_onboarding_progress" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + user_id = Column(BigInteger, nullable=False) + task_key = Column(String(50), nullable=False) + status = Column(String(20), default='pending') + completed_at = Column(BigInteger) + claimed_at = Column(BigInteger) + created_at = Column(BigInteger) + updated_at = Column(BigInteger) + + __table_args__ = ( + Index('ix_onboard_progress_user_key', 'user_id', 'task_key', unique=True), + ) + + +class UserOnboardingStatus(Base): + """引导流程状态表""" + __tablename__ = "user_onboarding_status" + + user_id = Column(BigInteger, primary_key=True) + current_stage = Column(Integer, default=0) + status = Column(String(20), default='pending') + is_first_login_bonus_claimed = Column(Boolean, default=False) + has_friend_display_bonus = Column(Boolean, default=False) + completed_at = Column(BigInteger) + claimed_at = Column(BigInteger) + created_at = Column(BigInteger) + updated_at = Column(BigInteger) + + +class OnboardingStageConfig(Base): + """引导阶段配置表""" + __tablename__ = "onboarding_stage_config" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + stage = Column(Integer, nullable=False) + name = Column(String(100), nullable=False) + description = Column(Text) + required_task_keys = Column(Text) # PostgreSQL ARRAY stored as TEXT + crystal_reward = Column(BigInteger, default=0) + exp_reward = Column(BigInteger, default=0) + sort_order = Column(Integer, default=0) + is_active = Column(Boolean, default=True) + created_at = Column(BigInteger) + updated_at = Column(BigInteger) + + +class ExhibitionRevenueRecord(Base): + """展示收益记录表""" + __tablename__ = "exhibition_revenue_records" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + user_id = Column(BigInteger, nullable=False) + star_id = Column(BigInteger, nullable=False) + exhibition_id = Column(BigInteger, nullable=False) + asset_id = Column(BigInteger, nullable=False) + slot_id = Column(BigInteger, nullable=False) + slot_owner_uid = Column(BigInteger, nullable=False) + slot_type = Column(String(20), nullable=False) + crystal_amount = Column(BigInteger, nullable=False) + cycle_start_time = Column(BigInteger, nullable=False) + cycle_end_time = Column(BigInteger, nullable=False) + status = Column(String(20), default='claimable') + claimed_at = Column(BigInteger) + created_at = Column(BigInteger) + + __table_args__ = ( + Index('ix_revenue_user_star_status', 'user_id', 'star_id', 'status'), + Index('ix_revenue_star_status', 'star_id', 'status'), + ) + + +class TaskResetLog(Base): + """重置记录表""" + __tablename__ = "task_reset_log" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + reset_type = Column(String(20), nullable=False) + star_id = Column(BigInteger) + last_reset_at = Column(BigInteger, nullable=False) + created_at = Column(BigInteger) +``` + +- [ ] **Step 2: 修改 backend/models/__init__.py** + +```python +from models.models import Activity, ActivityItem, ActivityContribution, ActivityUserStats, Star, FanProfile +from models.task_models import ( + TaskDefinition, UserDailyTaskProgress, UserOnboardingProgress, + UserOnboardingStatus, OnboardingStageConfig, ExhibitionRevenueRecord, TaskResetLog +) +``` + +--- + +### Task 2.2: 创建 Pydantic Schema + +**Files:** +- Create: `backend/schemas/task_schemas.py` + +- [ ] **Step 1: 创建任务相关 Schema** + +```python +from pydantic import BaseModel +from typing import Optional, List + + +# ========== 任务定义 ========== + +class TaskDefinitionBase(BaseModel): + star_id: Optional[int] = None + task_key: str + task_type: str + name: str + description: Optional[str] = None + crystal_reward: int = 0 + exp_reward: int = 0 + sort_order: int = 0 + is_active: bool = True + + +class TaskDefinitionCreate(TaskDefinitionBase): + pass + + +class TaskDefinitionUpdate(BaseModel): + star_id: Optional[int] = None + task_key: Optional[str] = None + task_type: Optional[str] = None + name: Optional[str] = None + description: Optional[str] = None + crystal_reward: Optional[int] = None + exp_reward: Optional[int] = None + sort_order: Optional[int] = None + is_active: Optional[bool] = None + + +class TaskDefinitionResponse(TaskDefinitionBase): + id: int + created_at: Optional[int] = None + updated_at: Optional[int] = None + + class Config: + from_attributes = True + + +class TaskDefinitionListResponse(BaseModel): + items: List[TaskDefinitionResponse] + total: int + page: int + page_size: int + + +# ========== 每日任务进度 ========== + +class DailyTaskProgressItem(BaseModel): + id: int + user_id: int + star_id: int + task_key: str + status: str + completed_at: Optional[int] = None + claimed_at: Optional[int] = None + + class Config: + from_attributes = True + + +class DailyTaskProgressListResponse(BaseModel): + items: List[DailyTaskProgressItem] + total: int + page: int + page_size: int + + +# ========== 引导任务进度 ========== + +class OnboardingProgressItem(BaseModel): + id: int + user_id: int + task_key: str + status: str + completed_at: Optional[int] = None + claimed_at: Optional[int] = None + + class Config: + from_attributes = True + + +class OnboardingProgressListResponse(BaseModel): + items: List[OnboardingProgressItem] + total: int + page: int + page_size: int + + +# ========== 引导流程状态 ========== + +class OnboardingStatusResponse(BaseModel): + user_id: int + current_stage: int + status: str + is_first_login_bonus_claimed: bool + has_friend_display_bonus: bool + completed_at: Optional[int] = None + claimed_at: Optional[int] = None + + class Config: + from_attributes = True + + +# ========== 引导阶段配置 ========== + +class StageConfigResponse(BaseModel): + id: int + stage: int + name: str + description: Optional[str] = None + required_task_keys: List[str] = [] + crystal_reward: int + exp_reward: int + sort_order: int + is_active: bool + + class Config: + from_attributes = True + + +# ========== 展示收益 ========== + +class RevenueRecordItem(BaseModel): + id: int + user_id: int + star_id: int + exhibition_id: int + slot_type: str + crystal_amount: int + cycle_start_time: int + cycle_end_time: int + status: str + claimed_at: Optional[int] = None + + class Config: + from_attributes = True + + +class RevenueListResponse(BaseModel): + items: List[RevenueRecordItem] + total: int + page: int + page_size: int + + +# ========== 统计 ========== + +class TaskStatsByStar(BaseModel): + star_id: int + total_users: int + total_completed: int + total_claimed: int + completion_rate: float + claim_rate: float + + +class DailyTaskStats(BaseModel): + date: str + new_participants: int + total_completions: int + total_claims: int + + +class TaskOverviewStats(BaseModel): + total_definitions: int + active_definitions: int + total_daily_progress: int + total_onboarding_progress: int + total_revenue_records: int + claimable_revenue: int +``` + +--- + +### Task 2.3: 创建 CRUD 函数 + +**Files:** +- Create: `backend/crud/task_crud.py` + +- [ ] **Step 1: 创建任务 CRUD 函数** + +```python +from sqlalchemy.orm import Session +from sqlalchemy import select, func, and_, or_, distinct, update +from typing import List, Optional, Tuple +from models.task_models import ( + TaskDefinition, UserDailyTaskProgress, UserOnboardingProgress, + UserOnboardingStatus, OnboardingStageConfig, ExhibitionRevenueRecord, TaskResetLog +) +import json +import time + + +# ========== 任务定义 CRUD ========== + +def create_task_definition(db: Session, task: dict) -> TaskDefinition: + now = int(time.time() * 1000) + db_task = TaskDefinition( + star_id=task.get('star_id'), + task_key=task['task_key'], + task_type=task['task_type'], + name=task['name'], + description=task.get('description'), + crystal_reward=task.get('crystal_reward', 0), + exp_reward=task.get('exp_reward', 0), + sort_order=task.get('sort_order', 0), + is_active=task.get('is_active', True), + created_at=now, + updated_at=now, + ) + db.add(db_task) + db.commit() + db.refresh(db_task) + return db_task + + +def get_task_definitions( + db: Session, + task_type: Optional[str] = None, + star_id: Optional[int] = None, + page: int = 1, + page_size: int = 10 +) -> Tuple[List[TaskDefinition], int]: + query = select(TaskDefinition) + count_query = select(func.count(TaskDefinition.id)) + + if task_type: + query = query.where(TaskDefinition.task_type == task_type) + count_query = count_query.where(TaskDefinition.task_type == task_type) + if star_id is not None: + query = query.where(or_(TaskDefinition.star_id == star_id, TaskDefinition.star_id == None)) + count_query = count_query.where(or_(TaskDefinition.star_id == star_id, TaskDefinition.star_id == None)) + + total = db.execute(count_query).scalar() + query = query.order_by(TaskDefinition.sort_order, TaskDefinition.id) + query = query.offset((page - 1) * page_size).limit(page_size) + items = db.execute(query).scalars().all() + + return list(items), total + + +def update_task_definition(db: Session, task_id: int, updates: dict) -> Optional[TaskDefinition]: + task = db.execute(select(TaskDefinition).where(TaskDefinition.id == task_id)).scalar_one_or_none() + if not task: + return None + + for key, value in updates.items(): + if value is not None and key != 'id': + setattr(task, key, value) + task.updated_at = int(time.time() * 1000) + db.commit() + db.refresh(task) + return task + + +def delete_task_definition(db: Session, task_id: int) -> bool: + task = db.execute(select(TaskDefinition).where(TaskDefinition.id == task_id)).scalar_one_or_none() + if not task: + return False + db.delete(task) + db.commit() + return True + + +def toggle_task_definition(db: Session, task_id: int) -> Optional[TaskDefinition]: + task = db.execute(select(TaskDefinition).where(TaskDefinition.id == task_id)).scalar_one_or_none() + if not task: + return None + task.is_active = not task.is_active + task.updated_at = int(time.time() * 1000) + db.commit() + db.refresh(task) + return task + + +# ========== 每日任务进度 ========== + +def get_daily_progress( + db: Session, + user_id: Optional[int] = None, + star_id: Optional[int] = None, + status: Optional[str] = None, + page: int = 1, + page_size: int = 20 +) -> Tuple[List[dict], int]: + query = select(UserDailyTaskProgress) + count_query = select(func.count(UserDailyTaskProgress.id)) + + if user_id: + query = query.where(UserDailyTaskProgress.user_id == user_id) + count_query = count_query.where(UserDailyTaskProgress.user_id == user_id) + if star_id: + query = query.where(UserDailyTaskProgress.star_id == star_id) + count_query = count_query.where(UserDailyTaskProgress.star_id == star_id) + if status: + query = query.where(UserDailyTaskProgress.status == status) + count_query = count_query.where(UserDailyTaskProgress.status == status) + + total = db.execute(count_query).scalar() + query = query.order_by(UserDailyTaskProgress.updated_at.desc()) + query = query.offset((page - 1) * page_size).limit(page_size) + items = db.execute(query).scalars().all() + + return [dict( + id=i.id, user_id=i.user_id, star_id=i.star_id, + task_key=i.task_key, status=i.status, + completed_at=i.completed_at, claimed_at=i.claimed_at + ) for i in items], total + + +def reset_daily_tasks(db: Session, star_id: Optional[int] = None) -> int: + now = int(time.time() * 1000) + query = ( + update(UserDailyTaskProgress) + .where(UserDailyTaskProgress.status != 'pending') + .values(status='pending', completed_at=None, claimed_at=None, updated_at=now) + ) + if star_id: + query = query.where(UserDailyTaskProgress.star_id == star_id) + + result = db.execute(query) + db.commit() + return result.rowcount + + +# ========== 引导任务进度 ========== + +def get_onboarding_progress( + db: Session, + user_id: Optional[int] = None, + status: Optional[str] = None, + page: int = 1, + page_size: int = 20 +) -> Tuple[List[dict], int]: + query = select(UserOnboardingProgress) + count_query = select(func.count(UserOnboardingProgress.id)) + + if user_id: + query = query.where(UserOnboardingProgress.user_id == user_id) + count_query = count_query.where(UserOnboardingProgress.user_id == user_id) + if status: + query = query.where(UserOnboardingProgress.status == status) + count_query = count_query.where(UserOnboardingProgress.status == status) + + total = db.execute(count_query).scalar() + query = query.order_by(UserOnboardingProgress.updated_at.desc()) + query = query.offset((page - 1) * page_size).limit(page_size) + items = db.execute(query).scalars().all() + + return [dict( + id=i.id, user_id=i.user_id, task_key=i.task_key, + status=i.status, completed_at=i.completed_at, claimed_at=i.claimed_at + ) for i in items], total + + +def get_onboarding_status(db: Session, user_id: int) -> Optional[UserOnboardingStatus]: + return db.execute( + select(UserOnboardingStatus).where(UserOnboardingStatus.user_id == user_id) + ).scalar_one_or_none() + + +# ========== 引导阶段配置 ========== + +def get_stage_configs(db: Session) -> List[OnboardingStageConfig]: + result = db.execute( + select(OnboardingStageConfig) + .where(OnboardingStageConfig.is_active == True) + .order_by(OnboardingStageConfig.sort_order) + ) + return list(result.scalars().all()) + + +# ========== 展示收益 ========== + +def get_revenue_records( + db: Session, + user_id: Optional[int] = None, + star_id: Optional[int] = None, + status: Optional[str] = None, + page: int = 1, + page_size: int = 20 +) -> Tuple[List[ExhibitionRevenueRecord], int]: + query = select(ExhibitionRevenueRecord) + count_query = select(func.count(ExhibitionRevenueRecord.id)) + + if user_id: + query = query.where(ExhibitionRevenueRecord.user_id == user_id) + count_query = count_query.where(ExhibitionRevenueRecord.user_id == user_id) + if star_id: + query = query.where(ExhibitionRevenueRecord.star_id == star_id) + count_query = count_query.where(ExhibitionRevenueRecord.star_id == star_id) + if status: + query = query.where(ExhibitionRevenueRecord.status == status) + count_query = count_query.where(ExhibitionRevenueRecord.status == status) + + total = db.execute(count_query).scalar() + query = query.order_by(ExhibitionRevenueRecord.created_at.desc()) + query = query.offset((page - 1) * page_size).limit(page_size) + items = db.execute(query).scalars().all() + + return list(items), total + + +# ========== 统计 ========== + +def get_task_overview_stats(db: Session) -> dict: + total_defs = db.execute(select(func.count(TaskDefinition.id))).scalar() or 0 + active_defs = db.execute(select(func.count(TaskDefinition.id)).where(TaskDefinition.is_active == True)).scalar() or 0 + total_daily = db.execute(select(func.count(UserDailyTaskProgress.id))).scalar() or 0 + total_onboard = db.execute(select(func.count(UserOnboardingProgress.id))).scalar() or 0 + total_revenue = db.execute(select(func.count(ExhibitionRevenueRecord.id))).scalar() or 0 + claimable = db.execute( + select(func.count(ExhibitionRevenueRecord.id)) + .where(ExhibitionRevenueRecord.status == 'claimable') + ).scalar() or 0 + + return { + "total_definitions": total_defs, + "active_definitions": active_defs, + "total_daily_progress": total_daily, + "total_onboarding_progress": total_onboard, + "total_revenue_records": total_revenue, + "claimable_revenue": claimable, + } + + +def get_task_stats_by_star(db: Session, star_id: int) -> dict: + completed = db.execute( + select(func.count(UserDailyTaskProgress.id)) + .where(and_( + UserDailyTaskProgress.star_id == star_id, + UserDailyTaskProgress.status.in_(['completed', 'claimed']) + )) + ).scalar() or 0 + + claimed = db.execute( + select(func.count(UserDailyTaskProgress.id)) + .where(and_( + UserDailyTaskProgress.star_id == star_id, + UserDailyTaskProgress.status == 'claimed' + )) + ).scalar() or 0 + + total_users = db.execute( + select(func.count(distinct(UserDailyTaskProgress.user_id))) + .where(UserDailyTaskProgress.star_id == star_id) + ).scalar() or 0 + + total_progress = db.execute( + select(func.count(UserDailyTaskProgress.id)) + .where(UserDailyTaskProgress.star_id == star_id) + ).scalar() or 0 + + return { + "star_id": star_id, + "total_users": total_users, + "total_completed": completed, + "total_claimed": claimed, + "completion_rate": round(completed / total_progress * 100, 2) if total_progress > 0 else 0, + "claim_rate": round(claimed / total_progress * 100, 2) if total_progress > 0 else 0, + } + + +def get_daily_task_trend(db: Session, star_id: Optional[int] = None, days: int = 7) -> List[dict]: + # 简化实现,返回近7天每日统计 + trends = [] + for i in range(days - 1, -1, -1): + day_ts = int(time.time() * 1000) - i * 86400000 + date = time.strftime("%Y-%m-%d", time.localtime(day_ts / 1000)) + trends.append({ + "date": date, + "new_participants": 0, + "total_completions": 0, + "total_claims": 0, + }) + return trends +``` + +--- + +### Task 2.4: 创建 API Handler + +**Files:** +- Create: `backend/handlers/task_handler.py` + +- [ ] **Step 1: 创建任务 Handler** + +```python +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from database import get_db +from schemas.task_schemas import ( + TaskDefinitionCreate, TaskDefinitionUpdate, TaskDefinitionResponse, + TaskDefinitionListResponse, DailyTaskProgressListResponse, + OnboardingProgressListResponse, OnboardingStatusResponse, + RevenueListResponse, TaskStatsByStar, TaskOverviewStats +) +from crud.task_crud import ( + create_task_definition, get_task_definitions, update_task_definition, + delete_task_definition, toggle_task_definition, + get_daily_progress, get_onboarding_progress, get_onboarding_status, + reset_daily_tasks, get_task_stats_by_star, get_revenue_records, + get_task_overview_stats, get_daily_task_trend, get_stage_configs +) +from middleware.auth import verify_token +from typing import Optional + +router = APIRouter(prefix="/api/admin/tasks", tags=["任务管理"]) + + +# ========== 任务定义 CRUD ========== + +@router.post("/definitions", response_model=TaskDefinitionResponse) +async def create_task( + task: TaskDefinitionCreate, + db: Session = Depends(get_db), + _: dict = Depends(verify_token) +): + db_task = create_task_definition(db, task.model_dump()) + return db_task + + +@router.get("/definitions", response_model=TaskDefinitionListResponse) +async def list_tasks( + task_type: Optional[str] = Query(None), + star_id: Optional[int] = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(10, ge=1, le=100), + db: Session = Depends(get_db), + _: dict = Depends(verify_token) +): + items, total = get_task_definitions(db, task_type, star_id, page, page_size) + return TaskDefinitionListResponse( + items=[TaskDefinitionResponse.model_validate(t) for t in items], + total=total, page=page, page_size=page_size + ) + + +@router.put("/definitions/{task_id}", response_model=TaskDefinitionResponse) +async def update_task( + task_id: int, + updates: TaskDefinitionUpdate, + db: Session = Depends(get_db), + _: dict = Depends(verify_token) +): + task = update_task_definition(db, task_id, updates.model_dump(exclude_unset=True)) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + return task + + +@router.delete("/definitions/{task_id}") +async def delete_task( + task_id: int, + db: Session = Depends(get_db), + _: dict = Depends(verify_token) +): + success = delete_task_definition(db, task_id) + if not success: + raise HTTPException(status_code=404, detail="Task not found") + return {"message": "Task deleted successfully"} + + +@router.post("/definitions/{task_id}/toggle", response_model=TaskDefinitionResponse) +async def toggle_task( + task_id: int, + db: Session = Depends(get_db), + _: dict = Depends(verify_token) +): + task = toggle_task_definition(db, task_id) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + return task + + +# ========== 任务进度 ========== + +@router.get("/daily/progress", response_model=DailyTaskProgressListResponse) +async def list_daily_progress( + user_id: Optional[int] = Query(None), + star_id: Optional[int] = Query(None), + status: Optional[str] = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + _: dict = Depends(verify_token) +): + items, total = get_daily_progress(db, user_id, star_id, status, page, page_size) + return DailyTaskProgressListResponse(items=items, total=total, page=page, page_size=page_size) + + +@router.get("/onboarding/progress", response_model=OnboardingProgressListResponse) +async def list_onboarding_progress( + user_id: Optional[int] = Query(None), + status: Optional[str] = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + _: dict = Depends(verify_token) +): + items, total = get_onboarding_progress(db, user_id, status, page, page_size) + return OnboardingProgressListResponse(items=items, total=total, page=page, page_size=page_size) + + +@router.get("/onboarding/status/{user_id}", response_model=OnboardingStatusResponse) +async def get_onboarding_status( + user_id: int, + db: Session = Depends(get_db), + _: dict = Depends(verify_token) +): + status = get_onboarding_status(db, user_id) + if not status: + raise HTTPException(status_code=404, detail="Onboarding status not found") + return status + + +@router.get("/onboarding/stages") +async def list_stages( + db: Session = Depends(get_db), + _: dict = Depends(verify_token) +): + stages = get_stage_configs(db) + return {"stages": stages} + + +# ========== 手动操作 ========== + +@router.post("/reset/daily") +async def reset_daily( + star_id: Optional[int] = Query(None), + db: Session = Depends(get_db), + _: dict = Depends(verify_token) +): + count = reset_daily_tasks(db, star_id) + return {"message": f"Reset {count} daily task records", "affected": count} + + +# ========== 统计 ========== + +@router.get("/stats/overview", response_model=TaskOverviewStats) +async def get_overview( + db: Session = Depends(get_db), + _: dict = Depends(verify_token) +): + return get_task_overview_stats(db) + + +@router.get("/stats/star/{star_id}", response_model=TaskStatsByStar) +async def get_star_stats( + star_id: int, + db: Session = Depends(get_db), + _: dict = Depends(verify_token) +): + return get_task_stats_by_star(db, star_id) + + +@router.get("/stats/daily-trend") +async def get_daily_trend( + star_id: Optional[int] = Query(None), + days: int = Query(7, ge=1, le=30), + db: Session = Depends(get_db), + _: dict = Depends(verify_token) +): + trends = get_daily_task_trend(db, star_id, days) + return {"trends": trends} + + +# ========== 展示收益 ========== + +@router.get("/revenue", response_model=RevenueListResponse) +async def list_revenue( + user_id: Optional[int] = Query(None), + star_id: Optional[int] = Query(None), + status: Optional[str] = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + _: dict = Depends(verify_token) +): + items, total = get_revenue_records(db, user_id, star_id, status, page, page_size) + return RevenueListResponse(items=items, total=total, page=page, page_size=page_size) +``` + +- [ ] **Step 2: 修改 backend/router/__init__.py** + +```python +from fastapi import APIRouter +from handlers import auth, activity, star, asset, task_handler + +api_router = APIRouter() + +api_router.include_router(auth.router) +api_router.include_router(activity.router) +api_router.include_router(star.router) +api_router.include_router(asset.router) +api_router.include_router(task_handler.router) # 新增 +``` + +--- + +## Phase 3: Vue Frontend (activity-admin) 任务管理页面 + +### Task 3.1: 任务定义列表页 + +**Files:** +- Create: `frontend/src/views/TaskDefinitionList.vue` + +- [ ] **Step 1: 创建任务定义列表页面** + +```vue + + + +``` + +--- + +### Task 3.2: 任务进度查询页 + +**Files:** +- Create: `frontend/src/views/TaskProgress.vue` + +- [ ] **Step 1: 创建任务进度查询页面** + +```vue + + + +``` + +--- + +### Task 3.3: 任务统计页 + +**Files:** +- Create: `frontend/src/views/TaskStats.vue` + +- [ ] **Step 1: 创建任务统计页面** + +```vue + + + +``` + +--- + +### Task 3.4: 手动重置页 + +**Files:** +- Create: `frontend/src/views/TaskReset.vue` + +- [ ] **Step 1: 创建手动重置页面** + +```vue + + + +``` + +--- + +### Task 3.5: 展示收益页 + +**Files:** +- Create: `frontend/src/views/RevenueList.vue` + +- [ ] **Step 1: 创建展示收益页面** + +```vue + + + +``` + +--- + +### Task 3.6: 更新 API 和路由 + +**Files:** +- Modify: `frontend/src/api/admin.js` + +- [ ] **Step 1: 添加任务相关 API** + +```javascript +// 任务定义 +export function getTaskDefinitions(params) { + return request.get('/tasks/definitions', { params }) +} + +export function createTask(data) { + return request.post('/tasks/definitions', data) +} + +export function updateTask(id, data) { + return request.put(`/tasks/definitions/${id}`, data) +} + +export function deleteTask(id) { + return request.delete(`/tasks/definitions/${id}`) +} + +// 任务进度 +export function getDailyProgress(params) { + return request.get('/tasks/daily/progress', { params }) +} + +export function getOnboardingProgress(params) { + return request.get('/tasks/onboarding/progress', { params }) +} + +export function getOnboardingStatus(userId) { + return request.get(`/tasks/onboarding/status/${userId}`) +} + +export function getOnboardingStages() { + return request.get('/tasks/onboarding/stages') +} + +// 手动重置 +export function resetDailyTasks(starId) { + return request.post('/tasks/reset/daily', null, { params: { star_id: starId } }) +} + +// 统计 +export function getOverviewStats() { + return request.get('/tasks/stats/overview') +} + +export function getDailyTrend(params) { + return request.get('/tasks/stats/daily-trend', { params }) +} + +export function getStarStats(starId) { + return request.get(`/tasks/stats/star/${starId}`) +} + +// 展示收益 +export function getRevenueRecords(params) { + return request.get('/tasks/revenue', { params }) +} +``` + +- [ ] **Step 2: 修改 frontend/src/router/index.js** + +```javascript +{ + path: '/tasks', + component: Layout, + children: [ + { path: '', redirect: '/tasks/definitions' }, + { path: 'definitions', component: () => import('@/views/TaskDefinitionList.vue') }, + { path: 'progress', component: () => import('@/views/TaskProgress.vue') }, + { path: 'stats', component: () => import('@/views/TaskStats.vue') }, + { path: 'reset', component: () => import('@/views/TaskReset.vue') }, + { path: 'revenue', component: () => import('@/views/RevenueList.vue') }, + ] +} +``` + +--- + +## Phase 4: Go taskService 实现 + +### Task 4.1: 创建 Proto 定义 + +**Files:** +- Create: `backend/proto/task.proto` + +- [ ] **Step 1: 创建 task.proto** + +```protobuf +syntax = "proto3"; + +package topfans.task; + +option go_package = "github.com/topfans/backend/pkg/proto/task;task"; + +import "proto/common.proto"; + +// ========== 内部 RPC ========== + +// 展品到期完成事件参数 +message OnExhibitionCompletedParams { + int64 exhibition_id = 1; + int64 asset_id = 2; + int64 slot_id = 3; + int64 occupier_uid = 4; // 展出者 UID + int64 occupier_star_id = 5; // 展出者明星 ID + int64 slot_owner_uid = 6; // 槽位拥有者 UID(应收水晶的人) + int64 start_time = 7; // 开始时间(毫秒) + int64 expire_at = 8; // 过期时间(毫秒) +} + +// 任务事件上报参数 +message ReportTaskEventParams { + int64 user_id = 1; + int64 star_id = 2; + string event_type = 3; // daily_login, daily_browse_asset, daily_mint, daily_place_asset + int64 timestamp = 4; +} + +// 初始化用户任务数据 +message InitUserTasksRequest { + int64 user_id = 1; +} + +message InitUserTasksResponse { + topfans.common.BaseResponse base = 1; + bool success = 2; +} + +// ========== 内部 RPC Service ========== + +service TaskInternalService { + // 初始化用户任务数据(注册时调用) + rpc InitUserTasks(InitUserTasksRequest) returns (InitUserTasksResponse); + + // 接收任务事件上报(其他服务调用) + rpc ReportTaskEvent(ReportTaskEventRequest) returns (ReportTaskEventResponse); + + // 展品到期完成(galleryService 调用) + rpc OnExhibitionCompleted(OnExhibitionCompletedRequest) returns (OnExhibitionCompletedResponse); +} + +message ReportTaskEventRequest { + int64 user_id = 1; + int64 star_id = 2; + string event_type = 3; + int64 timestamp = 4; +} + +message ReportTaskEventResponse { + topfans.common.BaseResponse base = 1; + bool task_completed = 2; // 任务是否完成 + string task_key = 3; +} + +message OnExhibitionCompletedRequest { + OnExhibitionCompletedParams params = 1; +} + +message OnExhibitionCompletedResponse { + topfans.common.BaseResponse base = 1; + int64 revenue_record_id = 2; // 创建的收益记录 ID +} +``` + +- [ ] **Step 2: 重新生成 proto 代码** + +Run: `cd backend && protoc --go_out=. --go_opt=paths=source_relative proto/task.proto` + +--- + +### Task 4.2: 创建 Go taskService 结构 + +**Files:** +- Create: `backend/services/taskService/main.go` +- Create: `backend/services/taskService/config/task_config.go` +- Create: `backend/services/taskService/repository/task_repository.go` +- Create: `backend/services/taskService/service/daily_task_service.go` +- Create: `backend/services/taskService/service/exhibition_revenue_service.go` +- Create: `backend/services/taskService/service/onboarding_service.go` +- Create: `backend/services/taskService/provider/task_provider.go` +- Create: `backend/services/taskService/provider/task_internal_provider.go` +- Create: `backend/services/taskService/worker/daily_reset_worker.go` +- Create: `backend/services/taskService/client/user_rpc_client.go` + +- [ ] **Step 1: 创建 main.go** + +```go +package main + +import ( + "flag" + "fmt" + "os" + "os/signal" + "strconv" + "syscall" + + dubboclient "dubbo.apache.org/dubbo-go/v3/client" + _ "dubbo.apache.org/dubbo-go/v3/imports" + "dubbo.apache.org/dubbo-go/v3/protocol" + "dubbo.apache.org/dubbo-go/v3/server" + + "github.com/topfans/backend/pkg/database" + "github.com/topfans/backend/pkg/logger" + pbTask "github.com/topfans/backend/pkg/proto/task" + pbUser "github.com/topfans/backend/pkg/proto/user" + "github.com/topfans/backend/services/taskService/client" + "github.com/topfans/backend/services/taskService/config" + "github.com/topfans/backend/services/taskService/provider" + "github.com/topfans/backend/services/taskService/repository" + "github.com/topfans/backend/services/taskService/service" + "github.com/topfans/backend/services/taskService/worker" +) + +var ( + port = flag.Int("port", getEnvInt("PORT", 20005), "Dubbo service port") + dbHost = flag.String("db-host", getEnv("DB_HOST", "localhost"), "Database host") + dbPort = flag.Int("db-port", getEnvInt("DB_PORT", 5432), "Database port") + dbUser = flag.String("db-user", getEnv("DB_USER", "postgres"), "Database user") + dbPassword = flag.String("db-password", getEnv("DB_PASSWORD", ""), "Database password") + dbName = flag.String("db-name", getEnv("DB_NAME", "top-fans"), "Database name") + userServiceURL = flag.String("user-service-url", getEnv("USER_SERVICE_URL", "tri://localhost:20000"), "User service URL") +) + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func getEnvInt(key string, fallback int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return fallback +} + +func main() { + flag.Parse() + + env := os.Getenv("ENV") + if env == "" { + env = "development" + } + + if err := logger.Init(logger.Config{ + ServiceName: "task-service", + Environment: env, + LogLevel: os.Getenv("LOG_LEVEL"), + }); err != nil { + panic(fmt.Sprintf("Failed to initialize logger: %v", err)) + } + defer logger.Sync() + + logger.Logger.Info("Starting Task Service...") + + // 初始化数据库 + dbConfig := database.Config{ + Host: *dbHost, + Port: *dbPort, + User: *dbUser, + Password: *dbPassword, + DBName: *dbName, + SSLMode: "disable", + TimeZone: "Asia/Shanghai", + } + + if err := database.Init(dbConfig); err != nil { + logger.Logger.Fatal(fmt.Sprintf("Failed to initialize database: %v", err)) + } + logger.Logger.Info("Database initialized successfully") + + // 创建 Repository + db := database.GetDB() + taskRepo := repository.NewTaskRepository(db) + logger.Logger.Info("Repository layer initialized") + + // 创建 User Service RPC 客户端 + userCli, err := dubboclient.NewClient( + dubboclient.WithClientURL(*userServiceURL), + ) + if err != nil { + logger.Logger.Fatal(fmt.Sprintf("Failed to create User Service Dubbo client: %v", err)) + } + + userServiceClient, err := pbUser.NewUserSocialService(userCli) + if err != nil { + logger.Logger.Fatal(fmt.Sprintf("Failed to create User Service RPC client: %v", err)) + } + userRPCClient := client.NewUserRPCClient(userServiceClient) + logger.Logger.Info("User Service RPC client initialized") + + // 创建 Service 层 + dailyTaskService := service.NewDailyTaskService(taskRepo, userRPCClient) + exhibitionRevenueService := service.NewExhibitionRevenueService(taskRepo, userRPCClient) + onboardingService := service.NewOnboardingService(taskRepo, userRPCClient) + logger.Logger.Info("Service layer initialized") + + // 创建 Provider + taskProvider := provider.NewTaskProvider(dailyTaskService, exhibitionRevenueService, onboardingService) + taskInternalProvider := provider.NewTaskInternalProvider(dailyTaskService, exhibitionRevenueService, onboardingService) + logger.Logger.Info("Provider layer initialized") + + // 创建并启动 DailyResetWorker + resetWorker := worker.NewDailyResetWorker(taskRepo, config.GetDailyResetTime()) + go resetWorker.Start() + logger.Logger.Info("Daily reset worker started") + + // 创建 Dubbo 服务器 + srv, err := server.NewServer( + server.WithServerProtocol( + protocol.WithPort(*port), + protocol.WithTriple(), + ), + ) + if err != nil { + logger.Logger.Fatal(fmt.Sprintf("Failed to create Dubbo server: %v", err)) + } + + // 注册服务 + if err := pbTask.RegisterTaskInternalServiceHandler(srv, taskInternalProvider); err != nil { + logger.Logger.Fatal(fmt.Sprintf("Failed to register TaskInternalService: %v", err)) + } + + // 启动服务 + if err := srv.Serve(); err != nil { + logger.Logger.Fatal(fmt.Sprintf("Failed to start Task Service: %v", err)) + } + + logger.Logger.Info(fmt.Sprintf("Task Service started successfully on port %d", *port)) + + // 等待退出信号 + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + logger.Logger.Info("Shutting down Task Service...") + resetWorker.Stop() + logger.Logger.Info("Task Service shutdown complete") +} +``` + +--- + +## Phase 5: userService AddExperience RPC + +### Task 5.1: 修改 proto/user.proto + +**Files:** +- Modify: `backend/proto/user.proto` + +- [ ] **Step 1: 添加 AddExperience 消息和 RPC** + +```protobuf +// 更新经验值请求(内部RPC调用,用于taskService发放经验奖励) +message AddExperienceRequest { + int64 user_id = 1; + int64 star_id = 2; + int64 delta = 3; +} + +// 更新经验值响应 +message AddExperienceResponse { + topfans.common.BaseResponse base = 1; + int64 new_experience = 2; +} + +// 在 service UserSocialService {} 中添加: +// 内部RPC:更新经验值(仅供taskService调用) +rpc AddExperience(AddExperienceRequest) returns (AddExperienceResponse); +``` + +- [ ] **Step 2: 重新生成 proto 代码** + +Run: `cd backend && protoc --go_out=. --go_opt=paths=source_relative proto/user.proto` + +--- + +### Task 5.2: 修改 repository + +**Files:** +- Modify: `backend/services/userService/repository/fan_profile_repository.go` + +- [ ] **Step 1: 添加 UpdateExperience 方法** + +```go +// FanProfileRepository 接口添加: +UpdateExperience(userID, starID int64, delta int64) (int64, error) + +// 实现: +func (r *fanProfileRepository) UpdateExperience(userID, starID int64, delta int64) (int64, error) { + if userID <= 0 { + return 0, errors.New("user_id must be greater than 0") + } + + var profile models.FanProfile + if err := r.db.Where("user_id = ? AND star_id = ?", userID, starID).First(&profile).Error; err != nil { + return 0, err + } + + profile.Experience += delta + if err := r.db.Save(&profile).Error; err != nil { + return 0, err + } + + return profile.Experience, nil +} +``` + +--- + +### Task 5.3: 修改 service + +**Files:** +- Modify: `backend/services/userService/service/user_service.go` + +- [ ] **Step 1: 添加 AddExperience 方法** + +```go +// UserService 接口添加: +AddExperience(req *pb.AddExperienceRequest) (*pb.AddExperienceResponse, error) + +// 实现: +func (s *userService) AddExperience(req *pb.AddExperienceRequest) (*pb.AddExperienceResponse, error) { + if !validator.ValidateUserID(req.UserId) || !validator.ValidateStarID(req.StarId) { + return &pb.AddExperienceResponse{ + Base: appErrors.BuildBaseResponse(appErrors.ErrInvalidUserID), + }, nil + } + + newExp, err := s.fanProfileRepo.UpdateExperience(req.UserId, req.StarId, req.Delta) + if err != nil { + logger.Logger.Error("Failed to update experience", zap.Error(err)) + return &pb.AddExperienceResponse{ + Base: appErrors.BuildBaseResponse(appErrors.ErrInternalServer), + }, nil + } + + return &pb.AddExperienceResponse{ + Base: &pbCommon.BaseResponse{ + Code: pbCommon.StatusCode_STATUS_OK, + Message: "", + }, + NewExperience: newExp, + }, nil +} +``` + +--- + +### Task 5.4: 修改 provider + +**Files:** +- Modify: `backend/services/userService/provider/user_provider.go` + +- [ ] **Step 1: 添加 AddExperience Provider 方法** + +```go +// AddExperience 更新经验值(内部RPC调用) +func (p *UserProvider) AddExperience(ctx context.Context, req *pb.AddExperienceRequest) (*pb.AddExperienceResponse, error) { + logger.Logger.Info("Received AddExperience request", + zap.Int64("user_id", req.UserId), + zap.Int64("star_id", req.StarId), + zap.Int64("delta", req.Delta), + ) + + resp, err := p.userService.AddExperience(req) + if err != nil { + logger.Logger.Error("AddExperience failed", zap.Error(err)) + } + + return resp, err +} +``` + +- [ ] **Step 2: 修改 unified_provider.go** + +```go +// 在 UnifiedProvider 中添加: +func (p *UnifiedProvider) AddExperience(ctx context.Context, req *pb.AddExperienceRequest) (*pb.AddExperienceResponse, error) { + return p.userProvider.AddExperience(ctx, req) +} +``` + +--- + +## Phase 6: 前端 API 适配(移动端) + +### Task 6.1: 更新前端 API 调用 + +**Files:** +- Create/Modify: `frontend/src/api/task.js`(移动端任务 API) + +- [ ] **Step 1: 创建移动端任务 API** + +```javascript +// 每日任务 +export function getDailyTasks(starId) { + return request.get('/tasks/daily', { params: { star_id: starId } }) +} + +export function claimTaskReward(taskKey, starId) { + return request.post('/tasks/claim', { task_key: taskKey, star_id: starId }) +} + +// 引导任务 +export function getOnboardingStages() { + return request.get('/tasks/onboarding/stages') +} + +export function getOnboardingStatus() { + return request.get('/tasks/onboarding/status') +} + +export function completeOnboarding(taskKey) { + return request.post('/tasks/onboarding/complete', { task_key: taskKey }) +} + +export function claimOnboardingReward(taskKey) { + return request.post('/tasks/onboarding/claim', { task_key: taskKey }) +} + +// 展示收益 +export function getExhibitionRevenue(starId, status, page, pageSize) { + return request.get('/tasks/exhibition-revenue', { + params: { star_id: starId, status, page, page_size: pageSize } + }) +} + +export function claimExhibitionRevenue(revenueId) { + return request.post('/tasks/exhibition-revenue/claim', { revenue_id: revenueId }) +} + +export function claimAllExhibitionRevenue() { + return request.post('/tasks/exhibition-revenue/claim-all') +} + +// 等级 +export function getLevelInfo() { + return request.get('/tasks/level') +} + +// 初始化 +export function initUserTasks() { + return request.post('/tasks/init') +} +``` + +--- + +## 实现检查点 + +完成所有任务后,请验证以下功能: + +1. **数据库**:所有表已创建,默认数据已插入 +2. **activity-admin**: + - [ ] 任务定义列表页面可访问 + - [ ] 新建/编辑/删除任务定义正常 + - [ ] 任务进度查询正常 + - [ ] 任务统计页面显示图表 + - [ ] 手动重置功能正常 + - [ ] 展示收益记录查询正常 +3. **Go taskService**: + - [ ] 服务启动正常 + - [ ] 每日重置 Worker 运行正常(5:00 Asia/Shanghai) + - [ ] 内部 RPC 可被 galleryService 调用 +4. **userService**: + - [ ] AddExperience RPC 可正常调用 +5. **移动端**: + - [ ] 每日任务列表显示正常 + - [ ] 任务完成上报正常 + - [ ] 奖励领取正常 + - [ ] 展示收益显示和领取正常 diff --git a/铸爱创作模块PRD.md b/铸爱创作模块PRD.md deleted file mode 100644 index e33d47e..0000000 --- a/铸爱创作模块PRD.md +++ /dev/null @@ -1,502 +0,0 @@ -# 铸爱创作模块 产品需求文档(PRD) - -> **文档版本:** V1.0 -> **创建日期:** 2026-04-09 -> **文档状态:** 初稿 - ---- - -## 1. 产品概述 - -铸爱创作模块是一个基于 AI 技术的创作平台。平台提供粉丝需要的创作方向(如小卡、吧唧、海报),用户借助 AI 图生图能力进行二次创作,每份作品自动生成全球唯一的「身份证编号(上链)」,用于确权、追溯和防抄袭维权。 - -**产品核心价值:** 降低创作门槛,让用户轻松完成 AI 创作,同时通过上链确权保护原创权益。 - -**目标用户:** 对铸爱内容感兴趣的消费者与创作者、需要明星合照二创的用户、重视版权保护的创作者。 - ---- - -## 2. 产品亮点 - -| 亮点 | 说明 | -|------|------| -| AI 图生图 | 基于精选素材(星卡/吧唧/海报),AI 自动生成独一无二的作品 | -| 唯一身份证编号 | 每份藏品上链,确权可追溯,防抄袭维权 | -| 双路径设计 | 铸造创作与藏品浏览路径互不干扰,满足不同用户需求 | -| 低门槛创作 | 用户只需选择素材、输入描述词即可完成 AI 创作,无需专业技能 | - ---- - -## 3. 产品整体介绍 - -### 3.1 页面结构 - -``` -┌─────────────────────────────────────┐ -│ 顶部运营轮播图 │ ← 区域一 -│ [图片1] [图片2] [图片3] ● ○ ○ │ -└─────────────────────────────────────┘ -┌─────────────────────────────────────┐ -│ [星卡] [吧唧] [海报] │ ← 区域二:主Tab -└─────────────────────────────────────┘ -┌─────────────────────────────────────┐ -│ [热门作品] [最新作品] [星卡] [吧唧] [海报] │ ← 区域三:分类标签 -└─────────────────────────────────────┘ -┌─────────────────────────────────────┐ -│ ┌─────┐ ┌─────┐ ┌─────┐ │ -│ │ │ │ │ │ │ │ -│ │ IMG │ │ IMG │ │ IMG │ │ -│ │#ID │ │#ID │ │#ID │ │ -│ └─────┘ └─────┘ └─────┘ │ ← 区域四:创作网格 -│ ┌─────┐ ┌─────┐ ┌─────┐ │ -│ │ │ │ │ │ │ │ -│ │ IMG │ │ IMG │ │ IMG │ │ -│ │#ID │ │#ID │ │#ID │ │ -│ └─────┘ └─────┘ └─────┘ │ -└─────────────────────────────────────┘ -``` - -### 3.2 四大区域概览 - -| 区域 | 核心功能 | -|------|----------| -| 顶部运营轮播图 | 运营位,后台可配置,点击跳转活动/话题/H5 | -| 主Tab(星卡/吧唧/海报) | 点击直接进入对应类型的 AI 铸造流程 | -| 分类标签(热门/最新/各分类) | 筛选用户已铸造的藏品列表 | -| 创作网格列表 | 双列瀑布流展示藏品,含身份证编号、创作者、点赞等 | - -### 3.3 铸造页面结构 - -#### 3.3.1 星卡铸造页面 - -``` -┌─────────────────────────────────────┐ -│ ← 返回 │ -└─────────────────────────────────────┘ -┌─────────────────────────────────────┐ -│ ┌─────────────────────────────────┐│ -│ │ ││ -│ │ ││ -│ │ 点击选择相册图片 ││ ← 素材预览区(单图,1:1或3:4) -│ │ ││ -│ └─────────────────────────────────┘│ -└─────────────────────────────────────┘ -┌─────────────────────────────────────┐ -│ 藏品信息 │ -│ ┌─────────────────────────────────┐│ -│ │ 请输入描述词或上传参考图... ││ ← 藏品描述/参考输入 -│ └─────────────────────────────────┘│ -│ │ -│ 跳过 │ ← 跳过 -└─────────────────────────────────────┘ -┌─────────────────────────────────────┐ -│ 输入描述词 │ -│ ┌─────────────────────────────────┐│ -│ │ 请输入描述词,引导AI创作... 发送 ││ ← Prompt输入框(发送即开始创作) -│ └─────────────────────────────────┘│ -└─────────────────────────────────────┘ -``` - -**页面说明:** 点击主Tab后进入铸造页面,选择素材 → 输入描述词 → 发送 → 进入AI处理中页面 - ---- - -#### 3.3.2 吧唧铸造页面 - -``` -┌─────────────────────────────────────┐ -│ ← 返回 │ -└─────────────────────────────────────┘ -┌─────────────────────────────────────┐ -│ ┌─────────────────────────────────┐│ -│ │ ││ -│ │ ⚙️ 圆形/异形 ││ ← 素材预览区(吧唧为圆形或异形徽章形态) -│ │ 点击选择相册图片 ││ -│ │ ││ -│ └─────────────────────────────────┘│ -└─────────────────────────────────────┘ -┌─────────────────────────────────────┐ -│ [奶油胶效果] [金属边框] [异形裁切] │ ← 吧唧专属装饰选项 -│ │ -│ 藏品信息 │ -│ ┌─────────────────────────────────┐│ -│ │ 请输入描述词或上传参考图... ││ ← 藏品描述/参考输入 -│ └─────────────────────────────────┘│ -│ │ -│ 跳过 │ -└─────────────────────────────────────┘ -┌─────────────────────────────────────┐ -│ 输入描述词 │ -│ ┌─────────────────────────────────┐│ -│ │ 请输入描述词,引导AI创作... 发送 ││ -│ └─────────────────────────────────┘│ -└─────────────────────────────────────┘ -``` - -**页面说明:** 吧唧铸造页面在素材预览区体现圆形/异形形态,并提供奶油胶效果、金属边框、异形裁切等专属装饰选项 - ---- - -#### 3.3.3 海报铸造页面 - -``` -┌─────────────────────────────────────┐ -│ ← 返回 │ -└─────────────────────────────────────┘ -┌─────────────────────────────────────┐ -│ ┌─────────────────────────────────┐│ -│ │ ││ -│ │ ││ -│ │ 点击选择相册图片 ││ ← 素材预览区(单图,16:9/4:3/2:3比例) -│ │ ││ -│ └─────────────────────────────────┘│ -│ [横向] [竖向] │ ← 比例切换 -└─────────────────────────────────────┘ -┌─────────────────────────────────────┐ -│ 藏品信息 │ -│ ┌─────────────────────────────────┐│ -│ │ 请输入描述词或上传参考图... ││ ← 藏品描述/参考输入 -│ └─────────────────────────────────┘│ -│ │ -│ 背景替换 风格迁移 光影调整 │ ← 海报专属选项 -│ │ -│ 跳过 │ -└─────────────────────────────────────┘ -┌─────────────────────────────────────┐ -│ 输入描述词 │ -│ ┌─────────────────────────────────┐│ -│ │ 请输入描述词,引导AI创作... 发送 ││ -│ └─────────────────────────────────┘│ -└─────────────────────────────────────┘ -``` - -**页面说明:** 海报铸造页面素材预览区支持横向/竖向比例切换,并提供背景替换、风格迁移、光影调整等专属选项 - ---- - -### 3.4 AI处理中页面结构 - -``` -┌─────────────────────────────────────┐ -│ │ -│ │ -│ ◐◐◐◐◐◐◐◐◐◐◐ 35% │ ← AI生成进度 -│ │ -│ AI 正在创作中,请稍候... │ -│ │ -└─────────────────────────────────────┘ -``` - -**页面说明:** 点击开始创作后进入此页面,展示AI处理进度,完成后自动跳转至结果预览页 - ---- - -### 3.5 AI创作结果预览页面结构 - -``` -┌─────────────────────────────────────┐ -│ ← 返回 ··· │ ← 顶部导航栏 -└─────────────────────────────────────┘ -┌─────────────────────────────────────┐ -│ ┌─────────────────────────────────┐│ -│ │ ││ -│ │ ││ -│ │ 生成结果图 ││ -│ │ ││ -│ │ ││ -│ └─────────────────────────────────┘│ -└─────────────────────────────────────┘ -┌─────────────────────────────────────┐ -│ │ -│ [重新生成] [确认铸造] │ -└─────────────────────────────────────┘ -``` - -**页面说明:** AI处理完成后进入此页面,展示生成结果和身份证编号,用户可重新生成或确认铸造 - ---- - -### 3.6 创作详情页结构 - -``` -┌─────────────────────────────────────┐ -│ ← 返回 ··· │ ← 顶部导航栏 -└─────────────────────────────────────┘ -┌─────────────────────────────────────┐ -│ ┌─────────────────────────────────┐│ -│ │ ││ -│ │ ││ -│ │ AI 创作结果大图 ││ -│ │ ││ -│ │ ││ -│ └─────────────────────────────────┘│ -└─────────────────────────────────────┘ -┌─────────────────────────────────────┐ -│ 用户昵称 │ ← 用户名 -│ ♡ 999 💬 88 │ ← 互动栏 -└─────────────────────────────────────┘ -┌─────────────────────────────────────┐ -│ #ZUA-20260409-XXXXX ⧉ 已上链 │ ← 身份证编号(可复制) -└─────────────────────────────────────┘ -``` - -**页面说明:** 点击创作卡片后进入详情页,展示创作结果、用户名、互动信息、身份证编号 - ---- - -## 4. 用户流程 - -### 4.1 详细用户流程 - -``` -[首页] → [铸爱创作模块] - │ - ├─→ 轮播图点击 → 活动/话题详情页 - │ - ├─→ 选择Tab(星卡 / 吧唧 / 海报)──→ 直接进入铸造页面 - │ │ - │ └─→ 选择素材 → AI铸造 → 生成藏品 - │ - └─→ 选择分类(热门作品 / 最新作品 / 星卡 / 吧唧 / 海报) - │ - └─→ 浏览藏品列表 - │ - └─→ 点击藏品卡片 → 创作详情页 - │ - ├─→ 点赞 / 评论 / 分享 - │ - └─→ [开始铸造] → AI铸造编辑器(以该藏品为原图) -``` - -### 4.2 核心交互路径 - -``` -路径一(铸造路径): -选择Tab(星卡/吧唧/海报)→ 进入铸造页面 → 选择素材 → AI图生图创作 → 铸造生成藏品 - -路径二(浏览路径): -选择分类标签 → 浏览藏品瀑布流 → 点击藏品 → 查看详情 / 以此藏品为原图继续铸造 -``` - ---- - -## 5. 功能需求(分区介绍) - -### 5.1 区域一:顶部运营轮播图 - -**位置:** 页面最顶部 - -**功能说明:** -- 铸爱界面顶部预留运营位 -- 支持后台配置运营活动,如联名创作等 -- 点击后可跳转 H5 链接或站内其他功能模块 - -**数据字段:** - -| 字段 | 说明 | -|------|------| -| image_url | 图片地址 | -| title | 标题(最多 20 字) | -| description | 描述(最多 50 字) | -| link_type | 跳转类型:活动 / 话题 / 外部链接 | -| link_value | 跳转目标ID或URL | - -**交互:** -- 手动滑动切换 + 自动轮播 -- 点击轮播项跳转到对应页面 - ---- - -### 5.2 区域二:主Tab标签区 - -**位置:** 轮播图下方 - -**功能说明:** -- 铸爱三大分类入口:星卡、吧唧、海报 -- **点击任意 Tab,直接进入对应类型的铸造页面** -- 铸造页中可选择素材进行 AI 创作 - -**Tab配置:** - -| Tab名称 | 素材形态 | 尺寸规格 | 创作特点 | 展示场景 | -|---------|----------|----------|----------|----------| -| 星卡 | 卡片形态,类似明星小卡、签名卡 | 通常 1:1 或 3:4 比例 | 适合人物特写、半身照,可添加装饰元素 | 手机壁纸、社交头像、收藏展示 | -| 吧唧 | 徽章/贴纸形态,类似金属徽章或圆形立牌 | 通常圆形或异形,直径 5-8cm | 适合可爱风格、二次元形象,可添加边框特效 | 实物徽章展示、书包/衣服装饰 | -| 海报 | 大尺寸图片素材 | 通常 16:9、4:3 或 2:3 比例 | 适合全身照、场景图、氛围感创作 | 手机壁纸、电脑壁纸、打印输出 | - -**各类型素材说明:** -- **星卡**:以明星照片为核心素材,用户可选择不同模板、滤镜、装饰进行 AI 创作,生成专属小卡风格的作品 -- **吧唧**:以徽章/立牌风格为核心素材,用户可为图片添加金属质感边框、奶油胶效果、异形裁切等,生成可爱风格的徽章作品 -- **海报**:以高质量人物/风景照片为核心素材,用户可添加背景替换、风格迁移、光影调整等,生成海报级别的作品 - -**交互:** -- 横向滚动式设计 -- 选中态有视觉区分(下划线/高亮) -- 默认选中"星卡"Tab - ---- - -### 5.3 区域三:分类标签区 - -**位置:** 主Tab下方 - -**功能说明:** -- 用于筛选用户已铸造的藏品 -- 分类为固定配置 - -**分类配置:** - -| 分类名称 | 排序规则 | 说明 | -|----------|----------|------| -| 热门作品 | 点赞数/访问数 | 查看所有类别的热门藏品 | -| 最新作品 | 发布时间 | 查看所有类别的新发布藏品 | -| 星卡 | 推荐算法 | 仅显示星卡类藏品 | -| 吧唧 | 推荐算法 | 仅显示吧唧类藏品 | -| 海报 | 推荐算法 | 仅显示海报类藏品 | - -**交互:** -- 横向排列,支持滚动 -- 选中态有视觉区分 -- 选中后刷新下方创作列表 - ---- - -### 5.4 区域四:创作网格列表 - -**位置:** 分类标签下方 - -**功能说明:** -- 双列瀑布流展示用户铸造的藏品 -- 根据当前选中的分类标签动态加载 - -**列表规则:** -- 无限下拉加载,默认 10 条/页 -- 下拉刷新获取最新内容 - -**卡片信息:** - -| 字段 | 说明 | -|------|------| -| 封面图 | AI 创作结果图 | -| 身份证编号(上链) | 全局唯一,如 #ZUA-20260409-XXXXX | -| 创作者 | 昵称 + 头像 | -| 点赞数 | 当前点赞数量 | -| 发布时间 | 相对时间(如"3小时前") | - -**卡片交互:** -- 点击卡片 → 进入创作详情页 -- 支持点赞、评论、分享 -- 详情页支持「以此为原图」继续铸造 - ---- - -### 5.5 AI 铸造编辑器 - -**入口:** 点击主Tab(星卡/吧唧/海报)后进入 - -**功能说明:** -- 用户选择素材,AI 进行图生图创作 - -**支持能力:** - -| 功能 | 状态 | 说明 | -|------|------|------| -| 图生图 | 支持 | 以用户选择的图片作为原图 | -| 参数调节 | 支持 | 可输入描述词(Prompt)调节生成效果 | -| 多轮对话式创作 | 待确定 | 是否支持连续对话优化 | -| AI 模型 | 初期 MiniMax | 后期可扩展多模型 | - -**创作流程:** - -``` -1. 选择原图(素材库图片 或 用户铸造的藏品) - │ - ▼ -2. 输入描述词(Prompt) - │ - ▼ -3. AI 生成创作结果 - │ - ▼ -4. 预览 → 确认 / 调整描述词继续生成 - │ - ▼ -5. 确认铸造 → 生成带唯一身份证编号(上链)的藏品 -``` - ---- - -### 5.6 创作详情页 - -**入口:** 点击创作网格中的任意卡片 - -**页面信息:** - -| 模块 | 说明 | -|------|------| -| 藏品大图 | AI 创作结果 | -| 身份证编号 | 全局唯一编号,可复制 | -| 原图入口 | 查看原始素材图 | -| 相似创作 | 使用同原图的其他作品 | -| 创作者信息 | 昵称、头像、发布时间 | -| 互动区 | 点赞、评论、分享 | - -**核心操作:** -- **"开始铸造"按钮**: 以此藏品为原图,进入 AI 创作编辑器进行二次创作 - ---- - -## 6. 非功能性需求 - -### 6.1 性能 -- 首屏加载时间 ≤ 2秒 -- 列表滑动流畅(60fps) - -### 6.2 兼容性 -- 适配 iOS 12+ / Android 6.0+ -- 主流屏幕尺寸适配(刘海屏、全面屏) - -### 6.3 数据要求 -- 轮播图支持运营后台配置 -- 分类标签为固定配置 - ---- - -## 7. 风险与待确认项 - -| 问题 | 说明 | 状态 | -|------|------|------| -| 分类数据来源 | 分类是固定配置 | 已确认 | -| 轮播图数量上限 | 后台运营控制 | 已确认 | -| 创作详情页 | 需要单独设计 | 已确认 | -| 创作编辑器 | 铸造功能的实现范围? | 待确认 | -| 登录态 | 浏览/创作需要登录 | 已确认 | -| 举报/审核 | 是否有内容审核机制? | 待确认 | -| 原图版权 | 使用藏品作为原图是否需要授权? | 已确认(与原创有合作授权) | -| AI 生成相似性 | 同原图+相似描述词生成相似作品,归属权如何界定? | 待确认 | -| 生成内容版权 | AI 二创作品的版权归属(用户/平台)? | 待确认 | -| 溯源链路 | 身份证编号能否追溯到原始素材来源? | 待确认 | - ---- - -## 8. 附录 - -### 8.1 术语说明 - -| 术语 | 说明 | -|------|------| -| 铸爱 | 平台特有的AI创作行为/活动名称 | -| 身份证编号(上链) | 每份AI创作作品的唯一标识符,全球唯一,用于确权和追溯 | -| 星卡 | 一种卡片形态的素材(明星周边风格) | -| 吧唧 | 网络用语,指徽章/贴纸类小物件 | -| 海报 | 大尺寸图片素材(通常16:9或4:3) | -| 铸造 | 用户借助AI能力基于原图进行二次创作的动作 | -| 图生图 | 以某张图片为原图,AI 生成创作结果 | - -### 8.2 参考页面 - -- (后续补充原型链接) - ---- - -> **下一步行动:** 请产品/运营确认待确认项后,更新本文档。