package task import ( "context" "sort" "strings" "time" "google.golang.org/grpc" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/pkg/xerr" taskdomain "hyapp/services/activity-service/internal/domain/task" ) const ( defaultTaskTimezone = "Asia/Shanghai" defaultTaskOffset = 8 * 60 * 60 taskRewardAsset = "COIN" ) // Repository 是任务系统的唯一持久化边界;进度、领奖和事件幂等都必须落 activity MySQL。 type Repository interface { ListVisibleDefinitions(ctx context.Context, nowMS int64) ([]taskdomain.Definition, error) ListUserProgress(ctx context.Context, userID int64, cycleKeys []string) ([]taskdomain.Progress, error) ConsumeTaskEvent(ctx context.Context, event taskdomain.Event, cycleKey string, nowMS int64) (taskdomain.EventResult, error) ListTaskDefinitions(ctx context.Context, query taskdomain.DefinitionQuery) ([]taskdomain.Definition, int64, error) UpsertTaskDefinition(ctx context.Context, command taskdomain.DefinitionCommand, nowMS int64) (taskdomain.Definition, bool, error) SetTaskDefinitionStatus(ctx context.Context, taskID string, status string, operatorAdminID int64, nowMS int64) (taskdomain.Definition, error) PrepareTaskClaim(ctx context.Context, command taskdomain.ClaimCommand, nowMS int64) (taskdomain.Claim, error) MarkTaskClaimGranted(ctx context.Context, claimID string, walletTransactionID string, grantedAtMS int64) (taskdomain.Claim, error) MarkTaskClaimFailed(ctx context.Context, claimID string, failureReason string, nowMS int64) error } // WalletClient 是 activity-service 发放金币奖励时依赖的钱包入账接口。 type WalletClient interface { CreditTaskReward(ctx context.Context, req *walletv1.CreditTaskRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditTaskRewardResponse, error) } // Config 保存任务系统的全局口径;daily 周期只能使用服务端时区。 type Config struct { TaskTimezone string } // Service 承载任务查询、事件消费、领奖和后台配置用例。 type Service struct { cfg Config repository Repository wallet WalletClient now func() time.Time location *time.Location } // New 创建任务服务;时区加载失败会退回 Asia/Shanghai,避免启动后使用用户或机器本地时区。 func New(cfg Config, repository Repository, wallet WalletClient) *Service { tz := strings.TrimSpace(cfg.TaskTimezone) if tz == "" { tz = defaultTaskTimezone } location, err := time.LoadLocation(tz) if err != nil { location, _ = time.LoadLocation(defaultTaskTimezone) } if location == nil { // Alpine 等精简镜像可能没有 IANA tzdata;任务日边界必须继续按产品口径 UTC+8 计算。 location = time.FixedZone(defaultTaskTimezone, defaultTaskOffset) } return &Service{cfg: Config{TaskTimezone: tz}, repository: repository, wallet: wallet, now: time.Now, location: location} } // SetClock 给测试注入稳定时间;生产路径始终使用 time.Now。 func (s *Service) SetClock(now func() time.Time) { if now != nil { s.now = now } } // ListUserTasks 只读 task_definitions + user_task_progress,不实时扫描钱包或房间事件。 func (s *Service) ListUserTasks(ctx context.Context, userID int64) (taskdomain.ListResult, error) { if err := s.requireRepository(); err != nil { return taskdomain.ListResult{}, err } if userID <= 0 { return taskdomain.ListResult{}, xerr.New(xerr.InvalidArgument, "user_id is required") } now := s.now() nowMS := now.UnixMilli() cycle := s.dailyCycle(now) definitions, err := s.repository.ListVisibleDefinitions(ctx, nowMS) if err != nil { return taskdomain.ListResult{}, err } progresses, err := s.repository.ListUserProgress(ctx, userID, []string{cycle.Key, taskdomain.CycleLifetime}) if err != nil { return taskdomain.ListResult{}, err } progressByKey := make(map[string]taskdomain.Progress, len(progresses)) for _, progress := range progresses { progressByKey[progressKey(progress.TaskID, progress.CycleKey)] = progress } daily := taskdomain.Section{Section: taskdomain.SectionDaily, ServerTimeMS: nowMS, NextRefreshAtMS: cycle.NextRefreshMS} exclusive := taskdomain.Section{Section: taskdomain.SectionExclusive, ServerTimeMS: nowMS, NextRefreshAtMS: cycle.NextRefreshMS} for _, definition := range definitions { switch definition.TaskType { case taskdomain.TypeDaily: daily.Items = append(daily.Items, s.itemFromDefinition(definition, progressByKey[progressKey(definition.TaskID, cycle.Key)], cycle.Key, nowMS, cycle.NextRefreshMS)) case taskdomain.TypeExclusive: exclusive.Items = append(exclusive.Items, s.itemFromDefinition(definition, progressByKey[progressKey(definition.TaskID, taskdomain.CycleLifetime)], taskdomain.CycleLifetime, nowMS, cycle.NextRefreshMS)) } } sortTaskItems(daily.Items) sortTaskItems(exclusive.Items) return taskdomain.ListResult{ Sections: []taskdomain.Section{ daily, exclusive, }, ServerTimeMS: nowMS, NextRefreshAtMS: cycle.NextRefreshMS, }, nil } // ClaimTaskReward 校验 activity 侧完成状态后,使用 wallet-service 幂等命令发放 COIN。 func (s *Service) ClaimTaskReward(ctx context.Context, command taskdomain.ClaimCommand) (taskdomain.Claim, error) { if err := s.requireRepository(); err != nil { return taskdomain.Claim{}, err } command.TaskID = strings.TrimSpace(command.TaskID) command.TaskType = normalizeTaskType(command.TaskType) command.CommandID = strings.TrimSpace(command.CommandID) if command.UserID <= 0 || command.TaskID == "" || command.CommandID == "" { return taskdomain.Claim{}, xerr.New(xerr.InvalidArgument, "claim command is incomplete") } cycle := s.dailyCycle(s.now()) switch command.TaskType { case taskdomain.TypeDaily: if command.CycleKey != cycle.Key { return taskdomain.Claim{}, xerr.New(xerr.InvalidArgument, "daily task can only claim current task_day") } case taskdomain.TypeExclusive: if command.CycleKey == "" { command.CycleKey = taskdomain.CycleLifetime } if command.CycleKey != taskdomain.CycleLifetime { return taskdomain.Claim{}, xerr.New(xerr.InvalidArgument, "exclusive task cycle_key must be lifetime") } default: return taskdomain.Claim{}, xerr.New(xerr.InvalidArgument, "task_type is invalid") } nowMS := s.now().UnixMilli() claim, err := s.repository.PrepareTaskClaim(ctx, command, nowMS) if err != nil { return taskdomain.Claim{}, err } if claim.Status == taskdomain.ClaimStatusGranted { return claim, nil } if s.wallet == nil { return taskdomain.Claim{}, xerr.New(xerr.Unavailable, "wallet client is not configured") } reason := "daily_task_reward" if command.TaskType == taskdomain.TypeExclusive { reason = "exclusive_task_reward" } resp, err := s.wallet.CreditTaskReward(ctx, &walletv1.CreditTaskRewardRequest{ CommandId: claim.WalletCommandID, AppCode: appcode.FromContext(ctx), TargetUserId: claim.UserID, Amount: claim.RewardCoinAmount, TaskType: claim.TaskType, TaskId: claim.TaskID, CycleKey: claim.CycleKey, Reason: reason, }) if err != nil { // 钱包失败时不能把任务进度改成 claimed;同一 command_id 重试会复用 claim 和 wallet_command_id。 _ = s.repository.MarkTaskClaimFailed(ctx, claim.ClaimID, xerr.MessageOf(err), s.now().UnixMilli()) return taskdomain.Claim{}, err } grantedAtMS := resp.GetGrantedAtMs() if grantedAtMS <= 0 { grantedAtMS = s.now().UnixMilli() } return s.repository.MarkTaskClaimGranted(ctx, claim.ClaimID, resp.GetTransactionId(), grantedAtMS) } // ConsumeTaskEvent 把服务端事实事件归属到 occurred_at_ms 所在的任务周期。 func (s *Service) ConsumeTaskEvent(ctx context.Context, event taskdomain.Event) (taskdomain.EventResult, error) { if err := s.requireRepository(); err != nil { return taskdomain.EventResult{}, err } event.EventID = strings.TrimSpace(event.EventID) event.EventType = strings.TrimSpace(event.EventType) event.SourceService = strings.TrimSpace(event.SourceService) event.MetricType = strings.TrimSpace(event.MetricType) if event.EventID == "" || event.EventType == "" || event.SourceService == "" || event.UserID <= 0 || event.MetricType == "" || event.Value <= 0 { return taskdomain.EventResult{}, xerr.New(xerr.InvalidArgument, "task event is incomplete") } if event.OccurredAtMS <= 0 { event.OccurredAtMS = s.now().UnixMilli() } cycleKey := s.dailyCycle(time.UnixMilli(event.OccurredAtMS)).Key return s.repository.ConsumeTaskEvent(ctx, event, cycleKey, s.now().UnixMilli()) } // ListTaskDefinitions 返回后台任务配置列表;状态筛选不影响 App 查询口径。 func (s *Service) ListTaskDefinitions(ctx context.Context, query taskdomain.DefinitionQuery) ([]taskdomain.Definition, int64, error) { if err := s.requireRepository(); err != nil { return nil, 0, err } query.TaskType = normalizeTaskType(query.TaskType) query.Category = strings.TrimSpace(query.Category) query.Status = strings.TrimSpace(query.Status) query.Keyword = strings.TrimSpace(query.Keyword) if query.Page <= 0 { query.Page = 1 } if query.PageSize <= 0 { query.PageSize = 20 } if query.PageSize > 100 { query.PageSize = 100 } return s.repository.ListTaskDefinitions(ctx, query) } // UpsertTaskDefinition 创建或编辑后台任务配置;关键领奖字段变更由 repository 生成版本快照。 func (s *Service) UpsertTaskDefinition(ctx context.Context, command taskdomain.DefinitionCommand) (taskdomain.Definition, bool, error) { if err := s.requireRepository(); err != nil { return taskdomain.Definition{}, false, err } command = normalizeDefinitionCommand(command) if err := validateDefinitionCommand(command); err != nil { return taskdomain.Definition{}, false, err } return s.repository.UpsertTaskDefinition(ctx, command, s.now().UnixMilli()) } // SetTaskDefinitionStatus 只做状态流转,不删除历史进度和领奖事实。 func (s *Service) SetTaskDefinitionStatus(ctx context.Context, taskID string, status string, operatorAdminID int64) (taskdomain.Definition, error) { if err := s.requireRepository(); err != nil { return taskdomain.Definition{}, err } taskID = strings.TrimSpace(taskID) status = strings.TrimSpace(status) if taskID == "" || !validDefinitionStatus(status) || operatorAdminID <= 0 { return taskdomain.Definition{}, xerr.New(xerr.InvalidArgument, "task status command is invalid") } return s.repository.SetTaskDefinitionStatus(ctx, taskID, status, operatorAdminID, s.now().UnixMilli()) } func (s *Service) requireRepository() error { if s == nil || s.repository == nil { return xerr.New(xerr.Unavailable, "task repository is not configured") } return nil } func (s *Service) itemFromDefinition(def taskdomain.Definition, progress taskdomain.Progress, cycleKey string, nowMS int64, nextRefreshMS int64) taskdomain.Item { status := taskdomain.StatusInProgress progressValue := int64(0) if progress.TaskID != "" { status = progress.Status progressValue = progress.ProgressValue } if status == "" { status = taskdomain.StatusInProgress } return taskdomain.Item{ Definition: def, ProgressValue: progressValue, UserStatus: status, Claimable: status == taskdomain.StatusCompleted, TaskDay: cycleKey, ServerTimeMS: nowMS, NextRefreshAtMS: nextRefreshMS, } } type dailyCycle struct { Key string NextRefreshMS int64 } func (s *Service) dailyCycle(t time.Time) dailyCycle { location := s.location if location == nil { location, _ = time.LoadLocation(defaultTaskTimezone) } if location == nil { location = time.FixedZone(defaultTaskTimezone, defaultTaskOffset) } local := t.In(location) start := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location) next := start.AddDate(0, 0, 1) return dailyCycle{Key: start.Format("2006-01-02"), NextRefreshMS: next.UnixMilli()} } func progressKey(taskID string, cycleKey string) string { return taskID + "\x00" + cycleKey } func sortTaskItems(items []taskdomain.Item) { sort.SliceStable(items, func(i, j int) bool { leftRank := taskStatusRank(items[i]) rightRank := taskStatusRank(items[j]) if leftRank != rightRank { return leftRank < rightRank } if items[i].SortOrder != items[j].SortOrder { return items[i].SortOrder < items[j].SortOrder } return items[i].TaskID < items[j].TaskID }) } func taskStatusRank(item taskdomain.Item) int { if item.Claimable { return 0 } switch item.UserStatus { case taskdomain.StatusCompleted: return 1 case taskdomain.StatusInProgress: return 2 case taskdomain.StatusClaimed: return 3 default: return 4 } } func normalizeDefinitionCommand(command taskdomain.DefinitionCommand) taskdomain.DefinitionCommand { command.TaskID = strings.TrimSpace(command.TaskID) command.TaskType = normalizeTaskType(command.TaskType) command.Category = strings.ToLower(strings.TrimSpace(command.Category)) command.MetricType = strings.ToLower(strings.TrimSpace(command.MetricType)) command.Title = strings.TrimSpace(command.Title) command.Description = strings.TrimSpace(command.Description) command.TargetUnit = strings.ToLower(strings.TrimSpace(command.TargetUnit)) command.Status = strings.TrimSpace(command.Status) if command.Status == "" { command.Status = taskdomain.StatusDraft } return command } func normalizeTaskType(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case taskdomain.TypeDaily: return taskdomain.TypeDaily case taskdomain.TypeExclusive: return taskdomain.TypeExclusive default: return strings.ToLower(strings.TrimSpace(value)) } } func validateDefinitionCommand(command taskdomain.DefinitionCommand) error { if command.TaskType != taskdomain.TypeDaily && command.TaskType != taskdomain.TypeExclusive { return xerr.New(xerr.InvalidArgument, "task_type is invalid") } if command.Category == "" || command.MetricType == "" || command.Title == "" { return xerr.New(xerr.InvalidArgument, "task category, metric_type and title are required") } if command.TargetValue <= 0 || command.RewardCoinAmount <= 0 { return xerr.New(xerr.InvalidArgument, "target_value and reward_coin_amount must be positive") } if !validTargetUnit(command.TargetUnit) { return xerr.New(xerr.InvalidArgument, "target_unit is invalid") } if !validDefinitionStatus(command.Status) { return xerr.New(xerr.InvalidArgument, "task status is invalid") } if command.EffectiveToMS > 0 && command.EffectiveFromMS > 0 && command.EffectiveToMS <= command.EffectiveFromMS { return xerr.New(xerr.InvalidArgument, "effective_to_ms must be after effective_from_ms") } if command.OperatorAdminID <= 0 { return xerr.New(xerr.InvalidArgument, "operator_admin_id is required") } return nil } func validTargetUnit(value string) bool { switch value { case "count", "minute", "coin": return true default: return false } } func validDefinitionStatus(value string) bool { switch value { case taskdomain.StatusDraft, taskdomain.StatusActive, taskdomain.StatusPaused, taskdomain.StatusArchived: return true default: return false } }