feat(task): GORM model + repository + ProcessTaskEvent engine

Phase B (model + repository):
- TaskDefinition: +TriggerEvent string, +TargetCount int
- UserDailyTaskProgress: +Progress int
- DailyTaskRepository: ListActiveDailyTaskDefinitions signature -> (starID, eventType string)
- IncrementProgress: atomic UPDATE progress=progress+1 WHERE status='pending'
  (returns rows-affected 0 + re-read on race; caller decides completion transition)
- ResetAllDailyTasks: +progress: 0 in Updates map
- InitDailyTasksForUser: pass eventType='' (backward-compat)

Phase D (engine + ReportEvent delegation, spec §4.3):
- TaskEventResult struct (CompletedTaskKeys []string)
- DailyTaskService.ProcessTaskEvent: 5-step engine
  1. ListActiveDailyTaskDefinitions(starID, eventType) - filter by trigger_event
  2. GetOrCreateDailyProgress; skip if completed/claimed (day idempotency)
  3. IncrementProgress (atomic +1)
  4. if progress >= target_count -> status='completed'
  5. UpdateDailyProgress + accumulate CompletedTaskKeys
- ReportEvent rewired: delegates to ProcessTaskEvent, maps TaskEventResult
  to ReportEventResponse (F1: single isolation unit; MQ consumer + RPC both use engine)
- 3 callers (GetDailyTasks / ClaimDailyTask / ClaimAllDailyTasks) pass eventType=''
  for backward-compat (preserves daily_login / daily_browse_asset before Phase F)

Note: Phase B and D are bundled because the signature change in B is what enables
D's engine to filter by trigger_event. Splitting would leave the codebase
uncompilable in the interim.

spec: docs/superpowers/specs/2026-07-21-daily-task-config-driven-design.md §4
plan: docs/superpowers/plans/2026-07-21-daily-task-config-driven-impl.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zerosaturation 2026-07-27 15:40:58 +08:00
parent bdf3abc2bc
commit e2f1122cc8
3 changed files with 153 additions and 51 deletions

View File

@ -13,6 +13,9 @@ type TaskDefinition struct {
IsActive bool `gorm:"column:is_active;default:true"`
CreatedAt int64 `gorm:"column:created_at"`
UpdatedAt int64 `gorm:"column:updated_at"`
// 方案 Aspec §2.1 / §4trigger_event + target_count 取代内联 def.TaskKey 匹配
TriggerEvent string `gorm:"column:trigger_event;size:64"` // NULL=仍走 def.TaskKey==eventType 兜底(向后兼容)
TargetCount int `gorm:"column:target_count;default:1"` // =1 等价"首次">1 为计数型
}
func (TaskDefinition) TableName() string { return "task_definitions" }
@ -28,6 +31,8 @@ type UserDailyTaskProgress struct {
ClaimedAt *int64 `gorm:"column:claimed_at"`
CreatedAt int64 `gorm:"column:created_at"`
UpdatedAt int64 `gorm:"column:updated_at"`
// 方案 Aspec §2.2 / §4累计次数progress >= target_count → status=completed
Progress int `gorm:"column:progress;default:0"`
}
func (UserDailyTaskProgress) TableName() string { return "user_daily_task_progress" }

View File

@ -18,11 +18,15 @@ type DailyTaskRepository interface {
ListDailyTasksByUser(userID, starID int64) ([]*model.UserDailyTaskProgress, error)
// ListCompletedDailyTasks 获取用户已完成但未领取的每日任务进度
ListCompletedDailyTasks(userID, starID int64) ([]*model.UserDailyTaskProgress, error)
// ListActiveDailyTaskDefinitions 获取所有活跃的每日任务定义包括star特定和全局默认
ListActiveDailyTaskDefinitions(starID int64) ([]*model.TaskDefinition, error)
// ListActiveDailyTaskDefinitions 获取所有活跃的每日任务定义(按 trigger_event 过滤,含 star 特定 + 全局默认)
// eventType 传空字符串则不过滤保留向后兼容GetDailyTasks 等老路径仍可用)
ListActiveDailyTaskDefinitions(starID int64, eventType string) ([]*model.TaskDefinition, error)
// UpdateDailyProgress 更新每日任务进度
UpdateDailyProgress(progress *model.UserDailyTaskProgress) error
// ResetAllDailyTasks 重置所有非pending状态的每日任务为pending
// IncrementProgress 原子累加 progressstatus='pending' 时 +1到达 target_count 时 status='completed'
// 失败返回 error调用方需在事务外层依据返回的 progress.Progress 决定后续动作
IncrementProgress(progress *model.UserDailyTaskProgress, def *model.TaskDefinition) error
// ResetAllDailyTasks 重置所有非pending状态的每日任务为pending包含 progress=0
ResetAllDailyTasks() (int64, error)
// InitDailyTasksForUser 为用户初始化该star的所有每日任务进度
InitDailyTasksForUser(userID, starID int64) error
@ -107,12 +111,17 @@ func (r *dailyTaskRepository) ListCompletedDailyTasks(userID, starID int64) ([]*
return progressList, err
}
// ListActiveDailyTaskDefinitions 获取所有活跃的每日任务定义包括star特定和全局默认
func (r *dailyTaskRepository) ListActiveDailyTaskDefinitions(starID int64) ([]*model.TaskDefinition, error) {
// ListActiveDailyTaskDefinitions 获取所有活跃的每日任务定义(按 trigger_event 过滤,含 star 特定 + 全局默认)
// eventType 为空字符串时不过滤 trigger_event向后兼容GetDailyTasks 路径调用)
func (r *dailyTaskRepository) ListActiveDailyTaskDefinitions(starID int64, eventType string) ([]*model.TaskDefinition, error) {
q := r.db.Where("is_active = true AND (star_id = ? OR star_id IS NULL)", starID).
Where("task_type = ?", "daily")
if eventType != "" {
// 只查 trigger_event 匹配的行F5star_id=NULL 全局任务 + star 专属任务叠加OR 已包含)
q = q.Where("trigger_event = ?", eventType)
}
var definitions []*model.TaskDefinition
err := r.db.Where("is_active = true AND (star_id = ? OR star_id IS NULL)", starID).
Where("task_type = ?", "daily").
Order("sort_order ASC, id ASC").
err := q.Order("sort_order ASC, id ASC").
Find(&definitions).Error
return definitions, err
}
@ -123,24 +132,57 @@ func (r *dailyTaskRepository) UpdateDailyProgress(progress *model.UserDailyTaskP
return r.db.Save(progress).Error
}
// ResetAllDailyTasks 重置所有非pending状态的每日任务为pending
// IncrementProgress 原子累加 progress事务内 UPDATE避免 Save 全量覆写)
// 行为:
// - WHERE status='pending' 才 +1已完成/已领取跳过,幂等)
// - UPDATE 同时设置 progress = progress + 1, updated_at = now
// - 调用方负责"progress 到达 target_count 后"的状态机转移Status/CompletedAt 字段)——
// 这里刻意不耦合,避免 UPDATE 双重语义
//
// 设计依据spec §5 计数型 + spec §4 F5 多命中语义service 层在拿到返回 progress 后
// 决定是否再调 UpdateDailyProgress 标记 completed。
func (r *dailyTaskRepository) IncrementProgress(progress *model.UserDailyTaskProgress, def *model.TaskDefinition) error {
now := time.Now().UnixMilli()
result := r.db.Model(&model.UserDailyTaskProgress{}).
Where("id = ? AND status = ?", progress.ID, "pending").
Updates(map[string]interface{}{
"progress": gorm.Expr("progress + ?", 1),
"updated_at": now,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
// 已被并发标记为 completed/claimed或 progress 不存在
// 重新读一次以便上层拿到最新值
return r.db.Where("id = ?", progress.ID).First(progress).Error
}
// UPDATE 成功后回填 progress 到入参对象service 层用)
progress.Progress++
progress.UpdatedAt = now
_ = def // def 当前未使用,保留以备未来扩展(如去重、增量)
return nil
}
// ResetAllDailyTasks 重置所有非pending状态的每日任务为pending含 progress=0
func (r *dailyTaskRepository) ResetAllDailyTasks() (int64, error) {
now := time.Now().UnixMilli()
result := r.db.Model(&model.UserDailyTaskProgress{}).
Where("status != ?", "pending").
Updates(map[string]interface{}{
"status": "pending",
"progress": 0, // 新增清零计数spec §6
"completed_at": nil,
"claimed_at": nil,
"updated_at": now,
"updated_at": now,
})
return result.RowsAffected, result.Error
}
// InitDailyTasksForUser 为用户初始化该star的所有每日任务进度
func (r *dailyTaskRepository) InitDailyTasksForUser(userID, starID int64) error {
// 获取所有活跃的每日任务定义
definitions, err := r.ListActiveDailyTaskDefinitions(starID)
// 获取所有活跃的每日任务定义(不过滤 trigger_event——初始化需包含全部 daily
definitions, err := r.ListActiveDailyTaskDefinitions(starID, "")
if err != nil {
return err
}

View File

@ -20,10 +20,21 @@ import (
type DailyTaskService interface {
GetDailyTasks(ctx context.Context, userID, starID int64) (*pb.GetDailyTasksResponse, error)
ReportEvent(ctx context.Context, userID, starID int64, eventType string) (*pb.ReportEventResponse, error)
// ProcessTaskEvent 每日任务完成引擎spec §4 单一隔离单元)。
// 同时被 MQ consumer + ReportEvent handler 调用;返回结果供 ReportEvent 回填 RPC 响应。
ProcessTaskEvent(ctx context.Context, userID, starID int64, eventType string) (*TaskEventResult, error)
ClaimDailyTask(ctx context.Context, userID, starID int64, taskKey string) (*pb.ClaimDailyTaskResponse, error)
ClaimAllDailyTasks(ctx context.Context, userID, starID int64) (*pb.ClaimAllDailyTasksResponse, error)
}
// TaskEventResult ProcessTaskEvent 的返回结果。
//
// CompletedTaskKeys: 本次事件导致 status 变为 "completed" 的 task_key 列表
// (可能 0~N 个spec §4 F5 多命中:全局任务 + star 专属任务叠加)。
type TaskEventResult struct {
CompletedTaskKeys []string
}
// dailyTaskService 每日任务Service实现
type dailyTaskService struct {
dailyRepo repository.DailyTaskRepository
@ -51,7 +62,7 @@ func (s *dailyTaskService) GetDailyTasks(ctx context.Context, userID, starID int
}
// 获取所有活跃的每日任务定义
definitions, err := s.dailyRepo.ListActiveDailyTaskDefinitions(starID)
definitions, err := s.dailyRepo.ListActiveDailyTaskDefinitions(starID, "")
if err != nil {
logger.Logger.Error("GetDailyTasks: failed to list active definitions",
zap.Int64("star_id", starID),
@ -96,72 +107,116 @@ func (s *dailyTaskService) GetDailyTasks(ctx context.Context, userID, starID int
}, nil
}
// ReportEvent 处理用户事件上报
// ReportEvent 处理用户事件上报(方案 A委托 ProcessTaskEvent 引擎)
//
// 本方法不再自己做 def.TaskKey == eventType 内联匹配spec §4 单一隔离单元);
// 改为调 ProcessTaskEvent 引擎完成匹配+累加+状态机转移,自身仅负责把
// TaskEventResult 回填到 ReportEventResponse 协议字段spec §4.1 F1
func (s *dailyTaskService) ReportEvent(ctx context.Context, userID, starID int64, eventType string) (*pb.ReportEventResponse, error) {
logger.Logger.Info("ReportEvent",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.String("event_type", eventType))
// 获取所有活跃的每日任务定义,查找匹配的任务
definitions, err := s.dailyRepo.ListActiveDailyTaskDefinitions(starID)
result, err := s.ProcessTaskEvent(ctx, userID, starID, eventType)
if err != nil {
return &pb.ReportEventResponse{Base: &pbCommon.BaseResponse{Code: uint32(codes.Internal)}, Success: false}, err
return &pb.ReportEventResponse{
Base: &pbCommon.BaseResponse{Code: uint32(codes.Internal)},
Success: false,
}, err
}
success := true
for _, def := range definitions {
// 检查事件类型是否匹配任务要求
// TODO: 根据实际业务逻辑判断事件与任务的匹配关系
// 这里简化处理:假设 taskKey 就是事件类型
if def.TaskKey != eventType {
continue
}
taskKey := ""
if len(result.CompletedTaskKeys) > 0 {
taskKey = result.CompletedTaskKeys[0]
}
return &pb.ReportEventResponse{
Base: &pbCommon.BaseResponse{Code: uint32(codes.OK)},
Success: true,
TaskKey: taskKey,
TaskCompleted: len(result.CompletedTaskKeys) > 0,
Message: "任务完成",
}, nil
}
// 获取或创建进度
// ProcessTaskEvent 每日任务完成引擎spec §4 单一隔离单元)
//
// 输入 {userID, starID, eventType},返回本次事件导致 completed 的 task_key 列表。
// 步骤spec §4.3
// 1. 查定义trigger_event=eventType AND is_active=true AND (star_id=? OR star_id IS NULL)
// 2. 逐个 GetOrCreateDailyProgressstatus=completed/claimed → 跳过(当天幂等)
// 3. IncrementProgress 事务内 +1仅 status='pending' 才生效)
// 4. progress.Progress >= def.TargetCount → status="completed" + completed_at=now
// 5. UpdateDailyProgress 持久化;累积完成 task_key 到 result
//
// 调用链:
// - MQ consumer异步主路径忽略返回值只关心 error
// - ReportEvent handler同步兜底从 result 回填 ReportEventResponse
func (s *dailyTaskService) ProcessTaskEvent(ctx context.Context, userID, starID int64, eventType string) (*TaskEventResult, error) {
logger.Logger.Info("ProcessTaskEvent",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.String("event_type", eventType))
definitions, err := s.dailyRepo.ListActiveDailyTaskDefinitions(starID, eventType)
if err != nil {
logger.Logger.Error("ProcessTaskEvent: failed to list active definitions",
zap.String("event_type", eventType), zap.Error(err))
return nil, err
}
result := &TaskEventResult{}
for _, def := range definitions {
// 步骤 2GetOrCreate 进度
progress, err := s.dailyRepo.GetOrCreateDailyProgress(userID, starID, def.TaskKey, def)
if err != nil {
logger.Logger.Error("ReportEvent: failed to get or create progress",
logger.Logger.Error("ProcessTaskEvent: get/create progress failed",
zap.Int64("user_id", userID),
zap.String("task_key", def.TaskKey),
zap.Error(err))
success = false
continue
continue // 单条失败不影响其他任务
}
// 如果已完成或已领取,跳过
// 步骤 2 续:已完成/已领取跳过(当天幂等)
if progress.Status == "completed" || progress.Status == "claimed" {
continue
}
// 更新进度为完成
now := time.Now().UnixMilli()
progress.Status = "completed"
progress.CompletedAt = &now
if err := s.dailyRepo.UpdateDailyProgress(progress); err != nil {
logger.Logger.Error("ReportEvent: failed to update progress",
// 步骤 3事务内 +1
if err := s.dailyRepo.IncrementProgress(progress, def); err != nil {
logger.Logger.Error("ProcessTaskEvent: increment progress failed",
zap.Int64("user_id", userID),
zap.String("task_key", def.TaskKey),
zap.Error(err))
success = false
continue
}
logger.Logger.Info("ReportEvent: task completed",
zap.Int64("user_id", userID),
zap.String("task_key", def.TaskKey))
// 步骤 4判断是否到达 target_countspec §4.3 第 3-4 步)
if progress.Progress >= def.TargetCount {
now := time.Now().UnixMilli()
progress.Status = "completed"
progress.CompletedAt = &now
return &pb.ReportEventResponse{
Base: &pbCommon.BaseResponse{Code: uint32(codes.OK)},
Success: true,
TaskKey: def.TaskKey,
TaskCompleted: true,
Message: "任务完成",
}, nil
// 步骤 5保存最终状态
if err := s.dailyRepo.UpdateDailyProgress(progress); err != nil {
logger.Logger.Error("ProcessTaskEvent: update progress to completed failed",
zap.Int64("user_id", userID),
zap.String("task_key", def.TaskKey),
zap.Error(err))
continue
}
result.CompletedTaskKeys = append(result.CompletedTaskKeys, def.TaskKey)
logger.Logger.Info("ProcessTaskEvent: task completed",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.String("task_key", def.TaskKey),
zap.Int("progress", progress.Progress),
zap.Int("target_count", def.TargetCount))
}
}
return &pb.ReportEventResponse{Base: &pbCommon.BaseResponse{Code: uint32(codes.OK)}, Success: success}, nil
return result, nil
}
// ClaimDailyTask 领取单个每日任务奖励
@ -197,7 +252,7 @@ func (s *dailyTaskService) ClaimDailyTask(ctx context.Context, userID, starID in
}
// 获取任务定义以获取奖励信息
definitions, err := s.dailyRepo.ListActiveDailyTaskDefinitions(starID)
definitions, err := s.dailyRepo.ListActiveDailyTaskDefinitions(starID, "")
if err != nil {
return &pb.ClaimDailyTaskResponse{Base: &pbCommon.BaseResponse{Code: uint32(codes.Internal)}, Success: false}, err
}
@ -285,7 +340,7 @@ func (s *dailyTaskService) ClaimAllDailyTasks(ctx context.Context, userID, starI
}
// 获取所有活跃的每日任务定义
definitions, err := s.dailyRepo.ListActiveDailyTaskDefinitions(starID)
definitions, err := s.dailyRepo.ListActiveDailyTaskDefinitions(starID, "")
if err != nil {
logger.Logger.Error("ClaimAllDailyTasks: failed to list definitions",
zap.Int64("star_id", starID),