topfans/docs/fix/2026-05-16_OnExhibitionCompleted修复文档.md

471 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 修复文档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` 移除或修改函数签名