376 lines
14 KiB
Go
376 lines
14 KiB
Go
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 (
|
||
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)
|
||
}
|
||
|
||
// Service 承载任务查询、事件消费、领奖和后台配置用例。
|
||
type Service struct {
|
||
repository Repository
|
||
wallet WalletClient
|
||
now func() time.Time
|
||
}
|
||
|
||
// New 创建任务服务;daily 周期固定按 UTC 自然日切分,不读取机器或客户端时区。
|
||
func New(repository Repository, wallet WalletClient) *Service {
|
||
return &Service{repository: repository, wallet: wallet, now: time.Now}
|
||
}
|
||
|
||
// 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 {
|
||
utc := t.UTC()
|
||
start := time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC)
|
||
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
|
||
}
|
||
}
|