diff --git a/docs/fix/2026-05-16_OnExhibitionCompleted修复文档.md b/docs/fix/2026-05-16_OnExhibitionCompleted修复文档.md new file mode 100644 index 0000000..0928ae2 --- /dev/null +++ b/docs/fix/2026-05-16_OnExhibitionCompleted修复文档.md @@ -0,0 +1,471 @@ +# 修复文档:OnExhibitionCompleted 调用链路重建 + +**日期**: 2026-05-16 +**问题**: OnExhibitionCompleted 未被调用,用户累计上架时长 (user_exhibition_hours) 数据异常 +**影响**: 61 次重复调用记录,8 条有效记录 vs 53 小时重复累计 + +--- + +## 一、问题根因 + +### 1.1 当前代码状态(经验证) + +| 组件 | 状态 | 位置 | 备注 | +|------|------|------|------| +| OnExhibitionCompleted RPC 定义 | ✅ 存在 | `galleryService/client/task_rpc_client.go:51` | 完整实现,但从未被调用 | +| OnExhibitionCompleted 调用处 | ❌ 不存在 | **无任何代码调用** | 已搜索全项目无调用 | +| CleanupWorker NewCleanupWorker 调用 | ⚠️ 存在但实现缺失 | `main.go:183` | 调用了但 `service/cleanup_worker.go` 不存在 | +| AddExhibitionHours 调用 | ❌ 缺失 | `revenue_service.go:132-172` | OnExhibitionCompleted 未调用 | +| revenueService.userClient | ✅ 已注入 | `service/revenue_service.go:23` | | +| main.go 调用签名不匹配 | ⚠️ 存在 | `main.go:110` | 传3个参数,函数只接收2个 | + +### 1.2 数据流断点 + +``` +展位过期 → main.go 调用 NewCleanupWorker + ↓ + cleanup_worker.go 不存在 + ↓ + 无人调用 taskClient.OnExhibitionCompleted() + ↓ + OnExhibitionCompleted RPC 从未被触发 + ↓ + revenueService.OnExhibitionCompleted 只创建 revenue record + ↓ + 缺少: s.userClient.AddExhibitionHours() + ↓ + user_exhibition_hours 表无数据来源 +``` + +### 1.3 验证过程 + +```bash +# 1. 搜索 OnExhibitionCompleted 调用 - 找到定义但无调用 +grep -rn "\.OnExhibitionCompleted\(" backend/services/galleryService/ +# 结果: task_rpc_client.go:77 是定义,其他都是 RPC handler + +# 2. 确认 taskClient.OnExhibitionCompleted 无人调用 +grep -rn "taskClient\.OnExhibitionCompleted\|taskRPCClient\.OnExhibitionCompleted" backend/ +# 结果: 无匹配 + +# 3. 检查 service/ 目录文件 +ls backend/services/galleryService/service/ +# 结果: 只有 gallery_service.go,没有 cleanup_worker.go + +# 4. 检查 revenueService 结构 +grep -A 5 "type revenueService struct" backend/services/taskService/service/revenue_service.go +# 结果: +# type revenueService struct { +# revenueRepo repository.RevenueRepository +# userClient client.UserServiceClient ✅ 已注入 +# } + +# 5. 检查 AddExhibitionHours 调用位置 +grep -n "AddExhibitionHours" backend/services/taskService/service/revenue_service.go +# 结果: 无匹配 - 确认未调用 + +# 6. 检查 main.go 签名不匹配 +grep "NewRevenueService" backend/services/taskService/main.go +# 结果: main.go:110 传3个参数 (revenueRepo, userRPCClient, galleryRPCClient) + +grep "func NewRevenueService" backend/services/taskService/service/revenue_service.go +# 结果: revenue_service.go:26 只接收2个参数 (revenueRepo, userClient) +``` + +### 1.4 风险分析 + +如果直接修复代码让 RemoveExhibitionByAsset 或 cleanup worker 调用 AddExhibitionHours: +- 基于当前 61 条记录,会重复累计约 53 小时(61 - 8 = 53 小时的重复) +- 需要先清理重复数据或添加去重机制 + +--- + +## 二、修复方案 + +### 2.1 修复步骤 + +#### Step 1: 创建 `backend/services/galleryService/service/cleanup_worker.go` + +```go +package service + +import ( + "context" + "time" + + "github.com/topfans/backend/pkg/logger" + "github.com/topfans/backend/pkg/models" + "github.com/topfans/backend/services/galleryService/client" + "github.com/topfans/backend/services/galleryService/repository" + "go.uber.org/zap" +) + +type CleanupWorker struct { + repo *repository.GalleryRepository + assetClient client.AssetRPCClient + userClient client.UserRPCClient + taskClient client.TaskRPCClient + stopCh chan struct{} + ticker *time.Ticker +} + +func NewCleanupWorker( + repo *repository.GalleryRepository, + assetClient client.AssetRPCClient, + userClient client.UserRPCClient, + taskClient client.TaskRPCClient, +) *CleanupWorker { + return &CleanupWorker{ + repo: repo, + assetClient: assetClient, + userClient: userClient, + taskClient: taskClient, + stopCh: make(chan struct{}), + ticker: time.NewTicker(1 * time.Minute), + } +} + +func (w *CleanupWorker) Start() { + logger.Logger.Info("CleanupWorker started") + w.cleanup() // 立即执行一次 + + for { + select { + case <-w.ticker.C: + w.cleanup() + case <-w.stopCh: + logger.Logger.Info("CleanupWorker stopped") + return + } + } +} + +func (w *CleanupWorker) Stop() { + w.ticker.Stop() + close(w.stopCh) +} + +func (w *CleanupWorker) cleanup() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // 获取所有过期展品 (使用毫秒时间戳) + nowMs := time.Now().UnixMilli() + expiredExhibitions, err := w.repo.GetExpiredExhibitions(nowMs) + if err != nil { + logger.Logger.Error("CleanupWorker: failed to get expired exhibitions", zap.Error(err)) + return + } + + if len(expiredExhibitions) == 0 { + return + } + + logger.Logger.Info("CleanupWorker: processing expired exhibitions", zap.Int("count", len(expiredExhibitions))) + + for _, exhibition := range expiredExhibitions { + w.processExpiredExhibition(ctx, exhibition) + } +} + +func (w *CleanupWorker) processExpiredExhibition(ctx context.Context, exhibition *models.Exhibition) { + // 获取展位信息 + slot, err := w.repo.GetSlotByID(exhibition.SlotID) + if err != nil { + logger.Logger.Error("CleanupWorker: failed to get slot", + zap.Int64("slot_id", exhibition.SlotID), zap.Error(err)) + return + } + + // 计算实际上架时长(毫秒转小时) + startTime := exhibition.PlacedAt.UnixMilli() + expireAt := exhibition.ExpiresAt.UnixMilli() + actualHours := (expireAt - startTime) / 3600000 // 毫秒转小时 + if actualHours < 1 { + actualHours = 1 + } + + // 构建请求 + req := &client.OnExhibitionCompletedRequest{ + ExhibitionId: exhibition.ID, + AssetId: exhibition.AssetID, + SlotId: exhibition.SlotID, + OccupierUid: exhibition.OccupierUID, + OccupierStarId: exhibition.OccupierStarID, + SlotOwnerUid: slot.UserID, + CrystalAmount: exhibition.CrystalAmount, + StartTime: startTime, + ExpireAt: expireAt, + } + + // 调用 OnExhibitionCompleted - 这是缺失的关键调用 + resp, err := w.taskClient.OnExhibitionCompleted(ctx, req) + if err != nil { + logger.Logger.Error("CleanupWorker: OnExhibitionCompleted failed", + zap.Int64("exhibition_id", exhibition.ID), zap.Error(err)) + return + } + + logger.Logger.Info("CleanupWorker: OnExhibitionCompleted succeeded", + zap.Int64("exhibition_id", exhibition.ID), + zap.Int64("revenue_record_id", resp.RevenueRecordId)) + + // 删除展品记录 + if err := w.repo.DeleteExhibition(exhibition.ID); err != nil { + logger.Logger.Error("CleanupWorker: failed to delete exhibition", + zap.Int64("exhibition_id", exhibition.ID), zap.Error(err)) + } +} +``` + +#### Step 2: 修改 `revenue_service.go` 添加 AddExhibitionHours 调用 + +**文件**: `backend/services/taskService/service/revenue_service.go` + +在 `OnExhibitionCompleted` 成功创建 revenue record 后,添加 `AddExhibitionHours` 调用: + +```go +func (s *revenueService) OnExhibitionCompleted(ctx context.Context, req *pb.OnExhibitionCompletedRequest) (*pb.OnExhibitionCompletedResponse, error) { + // ... 现有代码 (检查 self-slot,创建 revenue record) ... + + result, err := s.revenueRepo.CreateRevenueRecord(record) + if err != nil { + logger.Logger.Error("OnExhibitionCompleted: failed to create revenue record", + zap.Int64("slot_owner_uid", req.SlotOwnerUid), + zap.Int64("amount", req.CrystalAmount), + zap.Error(err)) + return nil, err + } + + // ===== 新增:调用 AddExhibitionHours ===== + // 计算实际上架时长(毫秒转小时) + startTime := req.StartTime + expireAt := req.ExpireAt + actualHours := (expireAt - startTime) / 3600000 + if actualHours < 1 { + actualHours = 1 + } + + // sourceID 用于去重,避免重复累计 + sourceID := fmt.Sprintf("exhibition_%d", req.ExhibitionId) + + // 增加用户累计上架时长(收益属于展位主人) + newLevel, levelDelta, crystalReward, err := s.userClient.AddExhibitionHours( + ctx, + req.SlotOwnerUid, + req.OccupierStarId, + actualHours, + sourceID, + ) + if err != nil { + logger.Logger.Error("OnExhibitionCompleted: AddExhibitionHours failed", + zap.Int64("slot_owner_uid", req.SlotOwnerUid), + zap.Int64("hours", actualHours), + zap.Error(err)) + // 不返回错误,因为收益记录已创建 + } else { + logger.Logger.Info("OnExhibitionCompleted: AddExhibitionHours succeeded", + zap.Int64("slot_owner_uid", req.SlotOwnerUid), + zap.Int64("hours", actualHours), + zap.Int32("new_level", newLevel), + zap.Int32("level_delta", levelDelta), + zap.Int64("crystal_reward", crystalReward)) + } + // ====================================== + + logger.Logger.Info("OnExhibitionCompleted: revenue record created", + zap.Int64("record_id", result.ID), + zap.Int64("slot_owner_uid", req.SlotOwnerUid), + zap.Int64("amount", req.CrystalAmount)) + + return &pb.OnExhibitionCompletedResponse{RevenueRecordId: result.ID}, nil +} +``` + +**注意**: 需要在文件头部添加 `fmt` import: +```go +import ( + "context" + "fmt" // 新增 + // ... +) +``` + +#### Step 3: 修复 main.go 签名不匹配 + +**文件**: `backend/services/taskService/main.go:110` + +当前代码: +```go +revenueSvc := service.NewRevenueService(revenueRepo, userRPCClient, galleryRPCClient) +``` + +有两种修复方式: + +**方式 A**: 如果 `galleryRPCClient` 不需要,从调用中移除: +```go +revenueSvc := service.NewRevenueService(revenueRepo, userRPCClient) +``` + +**方式 B**: 如果需要 `galleryRPCClient`,修改 `NewRevenueService` 签名: +```go +func NewRevenueService(revenueRepo repository.RevenueRepository, userClient client.UserServiceClient, galleryClient client.GalleryServiceClient) RevenueService { + return &revenueService{ + revenueRepo: revenueRepo, + userClient: userClient, + galleryClient: galleryClient, // 新增字段 + } +} +``` + +--- + +## 三、数据修复 + +### 3.1 重复数据清理 + +在修复代码之前,需要先处理现有的重复数据: + +```sql +-- 查看重复记录 +SELECT + user_id, + star_id, + source_id, + COUNT(*) as cnt, + SUM(exhibition_hours) as total_hours +FROM user_exhibition_hours +WHERE source_id LIKE 'exhibition_%' +GROUP BY user_id, star_id, source_id +HAVING COUNT(*) > 1; + +-- 保留最新记录,删除重复 +DELETE FROM user_exhibition_hours +WHERE id NOT IN ( + SELECT MAX(id) + FROM user_exhibition_hours + WHERE source_id LIKE 'exhibition_%' + GROUP BY user_id, star_id, source_id +); +``` + +### 3.2 添加去重逻辑(可选增强) + +在 `AddExhibitionHours` 中使用 `source_id` 进行去重: + +```go +func (r *fanProfileRepository) AddExhibitionHours(userID, starID int64, hours int64, sourceID string) (int32, int32, int64, error) { + // 检查是否已处理过此 source + var existing fanProfile + err := r.db.Where("user_id = ? AND star_id = ? AND exhibition_source_id = ?", userID, starID, sourceID).First(&existing).Error + if err == nil { + // 已存在,跳过(幂等性保证) + return existing.Level, 0, 0, nil + } + + // ... 正常逻辑 ... +} +``` + +--- + +## 四、验证步骤 + +### 4.1 代码验证 + +1. ✅ 确认 `cleanup_worker.go` 文件存在于 `service/` 目录 +2. ✅ 确认 `GetExpiredExhibitions` 方法签名正确(接收 `int64` 时间戳) +3. ✅ 确认 CleanupWorker 调用 `taskClient.OnExhibitionCompleted()` +4. ✅ 确认 `revenue_service.go` 中 `OnExhibitionCompleted` 调用 `AddExhibitionHours` +5. ✅ 确认 `main.go` 和 `NewRevenueService` 签名匹配 + +### 4.2 功能验证 + +1. 启动 galleryService +2. 创建一个过期的展品(expire_at < NOW()) +3. 等待 CleanupWorker 执行(1分钟内) +4. 检查 logs 中是否有 "CleanupWorker: processing expired exhibitions" +5. 检查 `exhibition_revenue_records` 表是否有新记录 +6. 检查 `user_exhibition_hours` 表是否有对应的时长记录 + +### 4.3 去重验证 + +1. 多次触发同一展品的 OnExhibitionCompleted +2. 确认 `user_exhibition_hours` 中只有一条记录 +3. 确认累计时长正确(不应累加多次) + +--- + +## 五、风险与注意事项 + +1. **幂等性**: OnExhibitionCompleted 必须是幂等的,重复调用不应产生重复数据 +2. **事务性**: AddExhibitionHours 和 revenue record 创建应该在同一事务中(当前未实现,需要评估) +3. **时区问题**: 使用 Asia/Shanghai 时区计算时长 +4. **历史数据**: 修复前的历史重复数据需要手动清理 +5. **编译验证**: 修改后需重新编译 `go build ./services/galleryService/...` 和 `go build ./services/taskService/...` + +--- + +## 六、文件清单 + +| 文件路径 | 操作 | 关键修改 | +|----------|------|----------| +| `backend/services/galleryService/service/cleanup_worker.go` | **新增** | 完整实现 CleanupWorker,调用 taskClient.OnExhibitionCompleted | +| `backend/services/taskService/service/revenue_service.go` | 修改 | OnExhibitionCompleted 中添加 AddExhibitionHours 调用 | +| `backend/services/taskService/main.go` | 修改 | 修复 NewRevenueService 调用签名 | + +--- + +## 七、修复后数据流 + +``` +正确的数据流: + +展位过期 (expire_at < NOW()) + ↓ +CleanupWorker.Start() 每分钟扫描 + ↓ +GetExpiredExhibitions() 获取过期展品 + ↓ +对每个展品调用 processExpiredExhibition() + ↓ +taskClient.OnExhibitionCompleted() RPC 调用 ← 缺失的调用 + ↓ +TaskInternalProvider.OnExhibitionCompleted() + ↓ +revenueService.OnExhibitionCompleted() + ├── 创建 revenue record (slot_owner 获取收益) + └── userClient.AddExhibitionHours() (slot_owner 累加上架时长) ← 缺失的调用 + ↓ +repo.DeleteExhibition() 软删除展品 +``` + +--- + +## 附:待确认 - main.go 调用签名不匹配 + +### 现象 +```bash +$ grep "NewRevenueService" backend/services/taskService/main.go +→ revenueSvc := service.NewRevenueService(revenueRepo, userRPCClient, galleryRPCClient) + # 传3个参数 + +$ grep "func NewRevenueService" backend/services/taskService/service/revenue_service.go +→ func NewRevenueService(revenueRepo repository.RevenueRepository, userClient client.UserServiceClient) RevenueService { + # 只接收2个参数 +``` + +### 验证方法 +```bash +cd backend && go build ./services/taskService/... +``` +如果编译报错,说明确实是签名不匹配问题。 + +### 可能原因 +1. **代码未同步** - main.go 或 revenue_service.go 有过修改但另一处未同步 +2. **存在另一个定义** - 如果编译通过,可能有其他地方定义了 NewRevenueService + +### 搜索结果 +全项目搜索只找到一个定义:`backend/services/taskService/service/revenue_service.go:26` + +这表明 **main.go 存在编译错误**,需要将 `galleryRPCClient` 移除或修改函数签名。 \ No newline at end of file