docs: 修复OnExhibitionCompleted 调用链路重建

This commit is contained in:
zheng020 2026-05-16 02:45:15 +08:00
parent afb235afd3
commit d30eda151e

View File

@ -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` 移除或修改函数签名。