2026-06-15 23:53:30 +08:00

626 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"
)
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
}
// 入口层只接受服务端事实事件,先统一裁剪字符串,避免上游空格导致同一个指标或事件 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
}
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.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.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 {
// 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 !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 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
}
}