697 lines
27 KiB
Go
697 lines
27 KiB
Go
package task
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"google.golang.org/grpc"
|
||
"google.golang.org/protobuf/proto"
|
||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/xerr"
|
||
taskdomain "hyapp/services/activity-service/internal/domain/task"
|
||
)
|
||
|
||
// 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
|
||
}
|
||
|
||
type taskRewardPolicyStore interface {
|
||
GetActiveTaskRewardAssetType(ctx context.Context, nowMS int64) (string, bool, error)
|
||
PublishTaskRewardPolicy(ctx context.Context, command taskdomain.RewardPolicyCommand, 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 幂等命令发放任务奖励资产。
|
||
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,
|
||
RewardAssetType: claim.RewardAssetType,
|
||
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
|
||
}
|
||
// 入口层只接受服务端事实事件,先统一裁剪字符串,避免上游空格导致同一个指标或事件 ID 被拆成两类。
|
||
event.EventID = strings.TrimSpace(event.EventID)
|
||
event.EventType = strings.TrimSpace(event.EventType)
|
||
event.SourceService = strings.TrimSpace(event.SourceService)
|
||
event.MetricType = strings.TrimSpace(event.MetricType)
|
||
// dimensions_json 是后台维度过滤的唯一匹配依据;这里强制为对象 JSON,避免数组/字符串透传后让仓储层匹配语义变得不确定。
|
||
dimensionsJSON, err := normalizeObjectJSON(event.DimensionsJSON)
|
||
if err != nil {
|
||
return taskdomain.EventResult{}, xerr.New(xerr.InvalidArgument, "dimensions_json is invalid")
|
||
}
|
||
event.DimensionsJSON = dimensionsJSON
|
||
// 任务进度不接受客户端自报,也不接受 0 增量;没有可追溯的 event_id/source/metric 时不能写入幂等消费表。
|
||
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")
|
||
}
|
||
// 上游没有精确事件时间时使用 activity-service 当前时间;每日任务周期由 occurred_at_ms 决定,不由 MQ 消费时间决定。
|
||
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())
|
||
}
|
||
|
||
// HandleRoomEvent 把 room-service 已提交送礼事实投影为任务进度事件。
|
||
// 这里不读取房间或礼物当前状态,所有判断都基于 outbox 快照,保证重放时进度和第一次消费完全一致。
|
||
func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (int32, error) {
|
||
if s == nil || envelope == nil || envelope.GetEventType() != "RoomGiftSent" {
|
||
return 0, nil
|
||
}
|
||
var gift roomeventsv1.RoomGiftSent
|
||
if err := proto.Unmarshal(envelope.GetBody(), &gift); err != nil {
|
||
return 0, err
|
||
}
|
||
if gift.GetIsRobotGift() {
|
||
return 0, nil
|
||
}
|
||
if gift.GetSenderUserId() <= 0 || gift.GetGiftId() == "" || gift.GetGiftCount() <= 0 {
|
||
return 0, nil
|
||
}
|
||
// coin_spent 是钱包真实扣费金额;旧 outbox 可能只有礼物面值,兜底用 gift_value 保持历史事件可重放。
|
||
coinSpent := gift.GetCoinSpent()
|
||
if coinSpent <= 0 {
|
||
coinSpent = gift.GetGiftValue()
|
||
}
|
||
dimensions, err := roomGiftTaskDimensions(envelope, &gift)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
eventCtx := appcode.WithContext(ctx, envelope.GetAppCode())
|
||
// 一次送礼同时满足“消耗金币”和“赠送次数”两个指标;用不同 event_id 后缀隔离幂等键,避免两个指标互相覆盖。
|
||
events := []taskdomain.Event{
|
||
{
|
||
EventID: envelope.GetEventId() + ":task:gift_spend",
|
||
EventType: envelope.GetEventType(),
|
||
SourceService: "room-service",
|
||
UserID: gift.GetSenderUserId(),
|
||
MetricType: taskdomain.MetricGiftSpendCoin,
|
||
Value: coinSpent,
|
||
OccurredAtMS: envelope.GetOccurredAtMs(),
|
||
DimensionsJSON: dimensions,
|
||
},
|
||
{
|
||
EventID: envelope.GetEventId() + ":task:gift_count",
|
||
EventType: envelope.GetEventType(),
|
||
SourceService: "room-service",
|
||
UserID: gift.GetSenderUserId(),
|
||
MetricType: taskdomain.MetricGiftSendCount,
|
||
Value: int64(gift.GetGiftCount()),
|
||
OccurredAtMS: envelope.GetOccurredAtMs(),
|
||
DimensionsJSON: dimensions,
|
||
},
|
||
}
|
||
// 幸运礼物是普通送礼的子集;普通礼物指标照常累计,额外再写幸运礼物专属指标,方便后台用不同任务口径配置。
|
||
if isLuckyGiftTaskEvent(&gift) {
|
||
events = append(events,
|
||
taskdomain.Event{
|
||
EventID: envelope.GetEventId() + ":task:lucky_gift_spend",
|
||
EventType: envelope.GetEventType(),
|
||
SourceService: "room-service",
|
||
UserID: gift.GetSenderUserId(),
|
||
MetricType: taskdomain.MetricLuckyGiftSpendCoin,
|
||
Value: coinSpent,
|
||
OccurredAtMS: envelope.GetOccurredAtMs(),
|
||
DimensionsJSON: dimensions,
|
||
},
|
||
taskdomain.Event{
|
||
EventID: envelope.GetEventId() + ":task:lucky_gift_count",
|
||
EventType: envelope.GetEventType(),
|
||
SourceService: "room-service",
|
||
UserID: gift.GetSenderUserId(),
|
||
MetricType: taskdomain.MetricLuckyGiftSendCount,
|
||
Value: int64(gift.GetGiftCount()),
|
||
OccurredAtMS: envelope.GetOccurredAtMs(),
|
||
DimensionsJSON: dimensions,
|
||
},
|
||
)
|
||
}
|
||
consumed := int32(0)
|
||
for _, event := range events {
|
||
// 任意一个指标写入失败都返回错误给 MQ/gRPC 调用方重试;事件幂等表保证已成功的指标不会重复累加。
|
||
result, err := s.ConsumeTaskEvent(eventCtx, event)
|
||
if err != nil {
|
||
return consumed, err
|
||
}
|
||
if result.Status == taskdomain.EventStatusConsumed {
|
||
consumed += result.MatchedTaskCount
|
||
}
|
||
}
|
||
return consumed, nil
|
||
}
|
||
|
||
// 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
|
||
}
|
||
if strings.TrimSpace(command.RewardAssetType) == "" {
|
||
if policyStore, ok := s.repository.(taskRewardPolicyStore); ok {
|
||
// policy instance 只提供“未显式配置时”的默认奖励资产;任务定义一旦保存仍会按定义和进度快照发放。
|
||
if assetType, found, err := policyStore.GetActiveTaskRewardAssetType(ctx, s.now().UnixMilli()); err != nil {
|
||
return taskdomain.Definition{}, false, err
|
||
} else if found {
|
||
command.RewardAssetType = assetType
|
||
}
|
||
}
|
||
}
|
||
command = normalizeDefinitionCommand(command)
|
||
if err := validateDefinitionCommand(ctx, command); err != nil {
|
||
return taskdomain.Definition{}, false, err
|
||
}
|
||
return s.repository.UpsertTaskDefinition(ctx, command, s.now().UnixMilli())
|
||
}
|
||
|
||
// PublishTaskRewardPolicy 写入 activity 运行侧默认任务奖励资产;它不改历史任务,只影响后续未显式传 reward_asset_type 的配置。
|
||
func (s *Service) PublishTaskRewardPolicy(ctx context.Context, command taskdomain.RewardPolicyCommand) error {
|
||
if err := s.requireRepository(); err != nil {
|
||
return err
|
||
}
|
||
policyStore, ok := s.repository.(taskRewardPolicyStore)
|
||
if !ok {
|
||
return xerr.New(xerr.Unavailable, "task reward policy store is not configured")
|
||
}
|
||
command.InstanceCode = strings.TrimSpace(command.InstanceCode)
|
||
command.TemplateCode = strings.TrimSpace(command.TemplateCode)
|
||
command.TemplateVersion = strings.TrimSpace(command.TemplateVersion)
|
||
command.Status = strings.TrimSpace(command.Status)
|
||
command.RewardAssetType = normalizeRewardAssetType(command.RewardAssetType)
|
||
command.RuleJSON = normalizeJSONForCommand(command.RuleJSON)
|
||
if command.InstanceCode == "" || command.TemplateCode == "" || command.TemplateVersion == "" || command.OperatorAdminID <= 0 {
|
||
return xerr.New(xerr.InvalidArgument, "task reward policy command is incomplete")
|
||
}
|
||
if command.Status != "active" && command.Status != "disabled" {
|
||
return xerr.New(xerr.InvalidArgument, "task reward policy status is invalid")
|
||
}
|
||
if !validRewardAssetType(appcode.FromContext(ctx), command.RewardAssetType) {
|
||
return xerr.New(xerr.InvalidArgument, "reward_asset_type is invalid")
|
||
}
|
||
nowMS := s.now().UnixMilli()
|
||
if command.PublishedAtMS <= 0 {
|
||
command.PublishedAtMS = nowMS
|
||
}
|
||
return policyStore.PublishTaskRewardPolicy(ctx, command, nowMS)
|
||
}
|
||
|
||
// 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
|
||
def.TargetValue = progress.TargetValue
|
||
def.RewardCoinAmount = progress.RewardCoinAmount
|
||
def.RewardAssetType = normalizeRewardAssetType(progress.RewardAssetType)
|
||
}
|
||
if status == "" {
|
||
status = taskdomain.StatusInProgress
|
||
}
|
||
def.RewardAssetType = normalizeRewardAssetType(def.RewardAssetType)
|
||
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.AudienceType = normalizeAudienceType(command.AudienceType)
|
||
command.IconKey = strings.TrimSpace(command.IconKey)
|
||
command.IconURL = strings.TrimSpace(command.IconURL)
|
||
command.ActionType = normalizeActionType(command.ActionType)
|
||
command.ActionParam = strings.TrimSpace(command.ActionParam)
|
||
command.ActionPayloadJSON = normalizeJSONForCommand(command.ActionPayloadJSON)
|
||
command.DimensionFilterJSON = normalizeJSONForCommand(command.DimensionFilterJSON)
|
||
command.TargetUnit = strings.ToLower(strings.TrimSpace(command.TargetUnit))
|
||
command.RewardAssetType = normalizeRewardAssetType(command.RewardAssetType)
|
||
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(ctx context.Context, command taskdomain.DefinitionCommand) error {
|
||
// daily 按 UTC 日周期生成进度,exclusive 使用 lifetime 周期;新增“新人专属+每日”用 audience_type 表达,不新增 task_type。
|
||
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")
|
||
}
|
||
// audience/action 只允许固定枚举,避免后台写入 App 无法识别的展示人群或跳转类型。
|
||
if !validAudienceType(command.AudienceType) {
|
||
return xerr.New(xerr.InvalidArgument, "audience_type is invalid")
|
||
}
|
||
if !validActionType(command.ActionType) {
|
||
return xerr.New(xerr.InvalidArgument, "action_type is invalid")
|
||
}
|
||
// 扩展跳转和维度过滤必须是对象 JSON;空值会在 normalizeDefinitionCommand 中落成 {},App 和事件匹配都按对象处理。
|
||
if _, err := normalizeObjectJSON(command.ActionPayloadJSON); err != nil {
|
||
return xerr.New(xerr.InvalidArgument, "action_payload_json is invalid")
|
||
}
|
||
if _, err := normalizeObjectJSON(command.DimensionFilterJSON); err != nil {
|
||
return xerr.New(xerr.InvalidArgument, "dimension_filter_json is invalid")
|
||
}
|
||
if command.TargetValue <= 0 || command.RewardCoinAmount <= 0 {
|
||
return xerr.New(xerr.InvalidArgument, "target_value and reward_coin_amount must be positive")
|
||
}
|
||
if !validRewardAssetType(appcode.FromContext(ctx), command.RewardAssetType) {
|
||
return xerr.New(xerr.InvalidArgument, "reward_asset_type is invalid")
|
||
}
|
||
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 normalizeRewardAssetType(value string) string {
|
||
value = strings.ToUpper(strings.TrimSpace(value))
|
||
if value == "" {
|
||
return taskdomain.RewardAssetCoin
|
||
}
|
||
return value
|
||
}
|
||
|
||
func validRewardAssetType(appCode string, value string) bool {
|
||
switch normalizeRewardAssetType(value) {
|
||
case taskdomain.RewardAssetCoin:
|
||
return true
|
||
case taskdomain.RewardAssetPoint:
|
||
// POINT 是 Huwaa 第一套政策的真实收益资产;其它 App 继续只能发 COIN,避免配置误伤旧任务。
|
||
return appcode.Normalize(appCode) == "huwaa"
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func roomGiftTaskDimensions(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.RoomGiftSent) (string, error) {
|
||
// 维度只记录任务匹配和排障需要的事实字段;后台 dimension_filter_json 可以按 gift_id、pool_id、room_id 等字段精确限定任务。
|
||
payload := map[string]any{
|
||
"room_id": envelope.GetRoomId(),
|
||
"room_version": envelope.GetRoomVersion(),
|
||
"gift_id": gift.GetGiftId(),
|
||
"gift_count": gift.GetGiftCount(),
|
||
"gift_value": gift.GetGiftValue(),
|
||
"billing_receipt_id": gift.GetBillingReceiptId(),
|
||
"command_id": gift.GetCommandId(),
|
||
"pool_id": gift.GetPoolId(),
|
||
"coin_spent": gift.GetCoinSpent(),
|
||
"visible_region_id": gift.GetVisibleRegionId(),
|
||
"country_id": gift.GetCountryId(),
|
||
"region_id": gift.GetRegionId(),
|
||
"gift_type_code": strings.ToLower(strings.TrimSpace(gift.GetGiftTypeCode())),
|
||
"cp_relation_type": gift.GetCpRelationType(),
|
||
"target_user_id": gift.GetTargetUserId(),
|
||
}
|
||
data, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return string(data), nil
|
||
}
|
||
|
||
func isLuckyGiftTaskEvent(gift *roomeventsv1.RoomGiftSent) bool {
|
||
if gift == nil {
|
||
return false
|
||
}
|
||
// 兼容两种上游表达:礼物类型直接标 lucky,或通过幸运池 pool_id 表明这次送礼进入幸运玩法。
|
||
giftType := strings.ToLower(strings.TrimSpace(gift.GetGiftTypeCode()))
|
||
poolID := strings.ToLower(strings.TrimSpace(gift.GetPoolId()))
|
||
return strings.Contains(giftType, "lucky") || strings.Contains(poolID, "lucky")
|
||
}
|
||
|
||
func normalizeAudienceType(value string) string {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case "", taskdomain.AudienceAll:
|
||
return taskdomain.AudienceAll
|
||
case taskdomain.AudienceNewbie:
|
||
return taskdomain.AudienceNewbie
|
||
default:
|
||
return strings.ToLower(strings.TrimSpace(value))
|
||
}
|
||
}
|
||
|
||
func validAudienceType(value string) bool {
|
||
switch value {
|
||
case taskdomain.AudienceAll, taskdomain.AudienceNewbie:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func normalizeActionType(value string) string {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case "", taskdomain.ActionNone:
|
||
return taskdomain.ActionNone
|
||
case taskdomain.ActionRoom:
|
||
return taskdomain.ActionRoom
|
||
case taskdomain.ActionRoomRandom:
|
||
return taskdomain.ActionRoomRandom
|
||
case taskdomain.ActionRoomWindow:
|
||
return taskdomain.ActionRoomWindow
|
||
case taskdomain.ActionRoomRandomWindow:
|
||
return taskdomain.ActionRoomRandomWindow
|
||
case taskdomain.ActionRoomGame:
|
||
return taskdomain.ActionRoomGame
|
||
case taskdomain.ActionRoomRandomGame:
|
||
return taskdomain.ActionRoomRandomGame
|
||
case taskdomain.ActionExploreGame:
|
||
return taskdomain.ActionExploreGame
|
||
case taskdomain.ActionWallet:
|
||
return taskdomain.ActionWallet
|
||
case taskdomain.ActionGiftPanel:
|
||
return taskdomain.ActionGiftPanel
|
||
case taskdomain.ActionGiftPanelSpecific:
|
||
return taskdomain.ActionGiftPanelSpecific
|
||
default:
|
||
return strings.ToLower(strings.TrimSpace(value))
|
||
}
|
||
}
|
||
|
||
func validActionType(value string) bool {
|
||
switch value {
|
||
case taskdomain.ActionNone,
|
||
taskdomain.ActionRoom,
|
||
taskdomain.ActionRoomRandom,
|
||
taskdomain.ActionRoomWindow,
|
||
taskdomain.ActionRoomRandomWindow,
|
||
taskdomain.ActionRoomGame,
|
||
taskdomain.ActionRoomRandomGame,
|
||
taskdomain.ActionExploreGame,
|
||
taskdomain.ActionWallet,
|
||
taskdomain.ActionGiftPanel,
|
||
taskdomain.ActionGiftPanelSpecific:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func normalizeObjectJSON(value string) (string, error) {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return "{}", nil
|
||
}
|
||
var decoded map[string]any
|
||
if err := json.Unmarshal([]byte(value), &decoded); err != nil {
|
||
return "", err
|
||
}
|
||
data, err := json.Marshal(decoded)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return string(data), nil
|
||
}
|
||
|
||
func normalizeJSONForCommand(value string) string {
|
||
normalized, err := normalizeObjectJSON(value)
|
||
if err != nil {
|
||
return strings.TrimSpace(value)
|
||
}
|
||
return normalized
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|