From c4549b16afdceaff8f823ff33f2cc97ce55bb2b2 Mon Sep 17 00:00:00 2001 From: hy001 Date: Sat, 9 May 2026 00:16:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=BB=98=E8=AE=A4=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/integration/java.go | 12 +- internal/model/taskcenter_models.go | 18 ++ internal/service/taskcenter/admin.go | 245 +++++++++++++++++- internal/service/taskcenter/app.go | 28 +- .../service/taskcenter/taskcenter_test.go | 134 +++++++++- internal/service/taskcenter/types.go | 53 +++- migrations/027_task_center_recharge_jump.sql | 13 + migrations/028_task_center_condition_jump.sql | 66 +++++ .../029_task_center_chinese_comments.sql | 109 ++++++++ 9 files changed, 637 insertions(+), 41 deletions(-) create mode 100644 migrations/027_task_center_recharge_jump.sql create mode 100644 migrations/028_task_center_condition_jump.sql create mode 100644 migrations/029_task_center_chinese_comments.sql diff --git a/internal/integration/java.go b/internal/integration/java.go index 1bae1b9..bf5cab3 100644 --- a/internal/integration/java.go +++ b/internal/integration/java.go @@ -84,6 +84,7 @@ type GoldReceiptCommand struct { Amount PennyAmountPayload `json:"amount"` CloseDelayAsset bool `json:"closeDelayAsset"` OpUserType string `json:"opUserType"` + Origin string `json:"origin,omitempty"` CustomizeOrigin string `json:"customizeOrigin,omitempty"` CustomizeOriginDesc string `json:"customizeOriginDesc,omitempty"` } @@ -875,7 +876,7 @@ func (c *Client) ChangeGoldBalance(ctx context.Context, cmd GoldReceiptCommand) testCmd := TestGoldReceiptCommand{ ReceiptType: cmd.ReceiptType, UserID: cmd.UserID, - EventType: cmd.CustomizeOrigin, + EventType: firstNonEmpty(cmd.CustomizeOrigin, cmd.Origin), EventDesc: cmd.CustomizeOriginDesc, EventID: cmd.EventID, SysOrigin: cmd.SysOrigin, @@ -886,6 +887,15 @@ func (c *Client) ChangeGoldBalance(ctx context.Context, cmd GoldReceiptCommand) return c.postJSON(ctx, c.cfg.Java.WalletBaseURL+"/wallet/gold/client/balance/change/test", testCmd, nil, nil) } +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + func NewPennyAmountPayloadFromDollar(amount int64) PennyAmountPayload { return PennyAmountPayload{ PennyAmount: amount * 100, diff --git a/internal/model/taskcenter_models.go b/internal/model/taskcenter_models.go index d7519fb..f009364 100644 --- a/internal/model/taskcenter_models.go +++ b/internal/model/taskcenter_models.go @@ -39,6 +39,24 @@ type TaskCenterTaskConfig struct { func (TaskCenterTaskConfig) TableName() string { return "task_center_task_config" } +// TaskCenterConditionJumpConfig 保存任务条件对应的跳转配置。 +type TaskCenterConditionJumpConfig struct { + ID int64 `gorm:"column:id;primaryKey"` + ConfigID int64 `gorm:"column:config_id;index:idx_task_center_condition_jump_config_id"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_task_center_condition_jump,priority:1;index:idx_task_center_condition_jump_sys_category"` + TaskCategory string `gorm:"column:task_category;size:32;uniqueIndex:uk_task_center_condition_jump,priority:2;index:idx_task_center_condition_jump_sys_category"` + ConditionType string `gorm:"column:condition_type;size:64;uniqueIndex:uk_task_center_condition_jump,priority:3;index:idx_task_center_condition_jump_condition"` + JumpType string `gorm:"column:jump_type;size:64"` + JumpPage string `gorm:"column:jump_page;size:512"` + SortOrder int `gorm:"column:sort_order;index:idx_task_center_condition_jump_sort"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (TaskCenterConditionJumpConfig) TableName() string { + return "task_center_condition_jump_config" +} + // TaskCenterUserProgress 保存用户在某个任务周期内的进度。 type TaskCenterUserProgress struct { ID int64 `gorm:"column:id;primaryKey"` diff --git a/internal/service/taskcenter/admin.go b/internal/service/taskcenter/admin.go index c69b679..5d1c836 100644 --- a/internal/service/taskcenter/admin.go +++ b/internal/service/taskcenter/admin.go @@ -28,12 +28,13 @@ func (s *Service) GetConfig(ctx context.Context, sysOrigin string, taskCategory First(&configRow).Error if errors.Is(err, gorm.ErrRecordNotFound) { return &ConfigResponse{ - Configured: false, - SysOrigin: sysOrigin, - Enabled: false, - Timezone: s.defaultTimezone(), - TaskCategory: category, - Tasks: []TaskConfigPayload{}, + Configured: false, + SysOrigin: sysOrigin, + Enabled: false, + Timezone: s.defaultTimezone(), + TaskCategory: category, + Tasks: []TaskConfigPayload{}, + ConditionJumps: []ConditionJumpConfigPayload{}, }, nil } if err != nil { @@ -44,15 +45,20 @@ func (s *Service) GetConfig(ctx context.Context, sysOrigin string, taskCategory if err != nil { return nil, err } + conditionJumps, err := s.loadConditionJumpConfigs(ctx, configRow.ID, category) + if err != nil { + return nil, err + } return &ConfigResponse{ - Configured: true, - ID: configRow.ID, - SysOrigin: configRow.SysOrigin, - Enabled: configRow.Enabled, - Timezone: normalizeTimezone(configRow.Timezone, s.defaultTimezone()), - TaskCategory: category, - UpdateTime: formatDateTime(configRow.UpdateTime), - Tasks: buildTaskConfigPayloads(tasks), + Configured: true, + ID: configRow.ID, + SysOrigin: configRow.SysOrigin, + Enabled: configRow.Enabled, + Timezone: normalizeTimezone(configRow.Timezone, s.defaultTimezone()), + TaskCategory: category, + UpdateTime: formatDateTime(configRow.UpdateTime), + Tasks: buildTaskConfigPayloads(tasks), + ConditionJumps: buildConditionJumpConfigPayloads(conditionJumps, tasks), }, nil } @@ -73,6 +79,13 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*Confi if err != nil { return nil, err } + conditionJumps, saveConditionJumps, err := s.normalizeConditionJumpInputs(req.ConditionJumps, category) + if err != nil { + return nil, err + } + if saveConditionJumps { + applyConditionJumpsToTasks(tasks, conditionJumps) + } if req.Enabled && !hasEnabledTask(tasks) { return nil, NewAppError(http.StatusBadRequest, "invalid_tasks", "enabled config must contain at least one enabled task") } @@ -120,6 +133,12 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*Confi return err } + if saveConditionJumps { + if err := s.saveConditionJumpConfigsTx(tx, configRow, category, conditionJumps, now); err != nil { + return err + } + } + keepIDs := make([]int64, 0, len(tasks)) for index := range tasks { if tasks[index].ID > 0 { @@ -319,6 +338,134 @@ func (s *Service) normalizeTaskInputs(inputs []SaveTaskConfigInput, category str return rows, nil } +func (s *Service) normalizeConditionJumpInputs(inputs []SaveConditionJumpConfigInput, category string) ([]model.TaskCenterConditionJumpConfig, bool, error) { + if inputs == nil { + return nil, false, nil + } + + seenConditions := make(map[string]struct{}, len(inputs)) + rows := make([]model.TaskCenterConditionJumpConfig, 0, len(inputs)) + for _, item := range inputs { + taskCategory := strings.ToUpper(strings.TrimSpace(item.TaskCategory)) + if taskCategory == "" { + taskCategory = category + } else { + taskCategory = normalizeCategory(taskCategory) + } + if taskCategory != category { + return nil, true, NewAppError(http.StatusBadRequest, "invalid_task_category", "condition jump taskCategory does not match request category") + } + conditionType := strings.ToUpper(strings.TrimSpace(item.ConditionType)) + if conditionType == "" { + return nil, true, NewAppError(http.StatusBadRequest, "invalid_condition_type", "conditionType is required") + } + if !isAllowedConditionType(conditionType) { + return nil, true, NewAppError(http.StatusBadRequest, "invalid_condition_type", "conditionType is not supported") + } + if _, exists := seenConditions[conditionType]; exists { + return nil, true, NewAppError(http.StatusBadRequest, "duplicate_condition_type", "conditionType must not duplicate") + } + seenConditions[conditionType] = struct{}{} + + rows = append(rows, model.TaskCenterConditionJumpConfig{ + ID: item.ID.Int64(), + TaskCategory: category, + ConditionType: conditionType, + JumpType: normalizeJumpType(item.JumpType), + JumpPage: normalizeConditionJumpPage(item.JumpType, item.JumpPage), + SortOrder: item.SortOrder, + }) + } + + sort.SliceStable(rows, func(i, j int) bool { + if rows[i].SortOrder == rows[j].SortOrder { + return rows[i].ConditionType < rows[j].ConditionType + } + return rows[i].SortOrder < rows[j].SortOrder + }) + return rows, true, nil +} + +func applyConditionJumpsToTasks(tasks []model.TaskCenterTaskConfig, jumps []model.TaskCenterConditionJumpConfig) { + jumpByCondition := make(map[string]model.TaskCenterConditionJumpConfig, len(jumps)) + for _, jump := range jumps { + jumpByCondition[strings.ToUpper(strings.TrimSpace(jump.ConditionType))] = jump + } + for index := range tasks { + conditionType := strings.ToUpper(strings.TrimSpace(tasks[index].ConditionType)) + jump, exists := jumpByCondition[conditionType] + if !exists { + tasks[index].JumpType, tasks[index].JumpPage = defaultConditionJump(conditionType) + continue + } + tasks[index].JumpType = normalizeJumpType(jump.JumpType) + tasks[index].JumpPage = normalizeConditionJumpPage(jump.JumpType, jump.JumpPage) + } +} + +func defaultConditionJump(conditionType string) (string, string) { + switch strings.ToUpper(strings.TrimSpace(conditionType)) { + case EventTypeRechargeGold: + return JumpTypeRechargeInApp, "" + case EventTypeMicDurationSeconds, EventTypeGameConsumeGold, EventTypeGiftConsumeGold: + return "ROOM_RANDOM_WITH_MIC", "" + default: + return "NONE", "" + } +} + +func (s *Service) saveConditionJumpConfigsTx(tx *gorm.DB, configRow model.TaskCenterConfig, category string, rows []model.TaskCenterConditionJumpConfig, now time.Time) error { + var existingRows []model.TaskCenterConditionJumpConfig + if err := tx. + Where("config_id = ? AND task_category = ?", configRow.ID, category). + Find(&existingRows).Error; err != nil { + return err + } + existingByCondition := make(map[string]model.TaskCenterConditionJumpConfig, len(existingRows)) + for _, row := range existingRows { + existingByCondition[strings.ToUpper(strings.TrimSpace(row.ConditionType))] = row + } + + keepIDs := make([]int64, 0, len(rows)) + for index := range rows { + conditionType := strings.ToUpper(strings.TrimSpace(rows[index].ConditionType)) + if rows[index].ID <= 0 { + if existing, exists := existingByCondition[conditionType]; exists { + rows[index].ID = existing.ID + rows[index].CreateTime = existing.CreateTime + } + } + if rows[index].ID <= 0 { + id, idErr := utils.NextID() + if idErr != nil { + return idErr + } + rows[index].ID = id + rows[index].CreateTime = now + } + if rows[index].CreateTime.IsZero() { + rows[index].CreateTime = now + } + rows[index].ConfigID = configRow.ID + rows[index].SysOrigin = configRow.SysOrigin + rows[index].TaskCategory = category + rows[index].ConditionType = conditionType + rows[index].JumpType = normalizeJumpType(rows[index].JumpType) + rows[index].JumpPage = normalizeConditionJumpPage(rows[index].JumpType, rows[index].JumpPage) + rows[index].UpdateTime = now + if err := tx.Save(&rows[index]).Error; err != nil { + return err + } + keepIDs = append(keepIDs, rows[index].ID) + } + + deleteQuery := tx.Where("config_id = ? AND task_category = ?", configRow.ID, category) + if len(keepIDs) > 0 { + deleteQuery = deleteQuery.Where("id NOT IN ?", keepIDs) + } + return deleteQuery.Delete(&model.TaskCenterConditionJumpConfig{}).Error +} + func (s *Service) loadTaskConfigs(ctx context.Context, configID int64, category string, enabledOnly bool) ([]model.TaskCenterTaskConfig, error) { if configID <= 0 { return []model.TaskCenterTaskConfig{}, nil @@ -335,6 +482,20 @@ func (s *Service) loadTaskConfigs(ctx context.Context, configID int64, category return rows, nil } +func (s *Service) loadConditionJumpConfigs(ctx context.Context, configID int64, category string) ([]model.TaskCenterConditionJumpConfig, error) { + if configID <= 0 { + return []model.TaskCenterConditionJumpConfig{}, nil + } + var rows []model.TaskCenterConditionJumpConfig + if err := s.db.WithContext(ctx). + Where("config_id = ? AND task_category = ?", configID, normalizeCategory(category)). + Order("sort_order asc, id asc"). + Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + func buildTaskConfigPayloads(rows []model.TaskCenterTaskConfig) []TaskConfigPayload { result := make([]TaskConfigPayload, 0, len(rows)) for _, row := range rows { @@ -359,6 +520,62 @@ func buildTaskConfigPayloads(rows []model.TaskCenterTaskConfig) []TaskConfigPayl return result } +func buildConditionJumpConfigPayloads(rows []model.TaskCenterConditionJumpConfig, tasks []model.TaskCenterTaskConfig) []ConditionJumpConfigPayload { + result := make([]ConditionJumpConfigPayload, 0, len(rows)+len(tasks)) + seenConditions := make(map[string]struct{}, len(rows)+len(tasks)) + for _, row := range rows { + conditionType := strings.ToUpper(strings.TrimSpace(row.ConditionType)) + if conditionType == "" { + continue + } + seenConditions[conditionType] = struct{}{} + jumpType := normalizeJumpType(row.JumpType) + result = append(result, ConditionJumpConfigPayload{ + ID: row.ID, + TaskCategory: row.TaskCategory, + ConditionType: conditionType, + JumpType: jumpType, + JumpPage: normalizeConditionJumpPage(jumpType, row.JumpPage), + SortOrder: row.SortOrder, + }) + } + for _, task := range tasks { + conditionType := strings.ToUpper(strings.TrimSpace(task.ConditionType)) + if conditionType == "" { + continue + } + if _, exists := seenConditions[conditionType]; exists { + continue + } + jumpType := normalizeJumpType(task.JumpType) + jumpPage := strings.TrimSpace(task.JumpPage) + if jumpType == "NONE" && jumpPage == "" { + jumpType, jumpPage = defaultConditionJump(conditionType) + } + jumpPage = normalizeConditionJumpPage(jumpType, jumpPage) + result = append(result, ConditionJumpConfigPayload{ + ID: 0, + TaskCategory: task.TaskCategory, + ConditionType: conditionType, + JumpType: jumpType, + JumpPage: jumpPage, + SortOrder: task.SortOrder, + }) + seenConditions[conditionType] = struct{}{} + } + sort.SliceStable(result, func(i, j int) bool { + if result[i].SortOrder == result[j].SortOrder { + return result[i].ConditionType < result[j].ConditionType + } + return result[i].SortOrder < result[j].SortOrder + }) + return result +} + +func normalizeConditionJumpPage(jumpType string, jumpPage string) string { + return strings.TrimSpace(jumpPage) +} + func claimRecordView(row model.TaskCenterClaimRecord) ClaimRecordView { return ClaimRecordView{ ID: row.ID, diff --git a/internal/service/taskcenter/app.go b/internal/service/taskcenter/app.go index 7cf12e3..1c3766e 100644 --- a/internal/service/taskcenter/app.go +++ b/internal/service/taskcenter/app.go @@ -166,16 +166,15 @@ func (s *Service) dispatchClaimGold(ctx context.Context, sysOrigin string, userI return nil } if err := s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ - ReceiptType: "INCOME", - UserID: userID, - SysOrigin: sysOrigin, - EventID: walletEventID, - Remark: fmt.Sprintf("taskCode=%s cycleKey=%s", task.TaskCode, currentCycleKey(task.TaskCategory, s.location, time.Now())), - Amount: integration.NewPennyAmountPayloadFromDollar(task.RewardGold), - CloseDelayAsset: false, - OpUserType: "APP", - CustomizeOrigin: "TASK_CENTER", - CustomizeOriginDesc: "TASK CENTER", + ReceiptType: "INCOME", + UserID: userID, + SysOrigin: sysOrigin, + EventID: walletEventID, + Remark: fmt.Sprintf("taskCode=%s cycleKey=%s", task.TaskCode, currentCycleKey(task.TaskCategory, s.location, time.Now())), + Amount: integration.NewPennyAmountPayloadFromDollar(task.RewardGold), + CloseDelayAsset: false, + OpUserType: "APP", + Origin: "DAILY_TASK", }); err != nil { return NewAppError(http.StatusBadGateway, "wallet_change_failed", err.Error()) } @@ -279,7 +278,12 @@ func resolveJumpPage(task model.TaskCenterTaskConfig) string { if jumpPage != "" { return jumpPage } + if isRechargeTask(task) { + return defaultRechargeJumpPage + } switch normalizeJumpType(task.JumpType) { + case JumpTypeRechargeInApp: + return defaultRechargeJumpPage case "ROOM_RANDOM", "ROOM_RANDOM_WITH_MIC": return "/room" case "NONE": @@ -289,6 +293,10 @@ func resolveJumpPage(task model.TaskCenterTaskConfig) string { } } +func isRechargeTask(task model.TaskCenterTaskConfig) bool { + return strings.EqualFold(strings.TrimSpace(task.ConditionType), EventTypeRechargeGold) +} + func buildWalletEventID(sysOrigin string, userID int64, taskCode string, cycleKey string) string { normalizedSysOrigin := compactWalletEventPart(sysOrigin, 8) if normalizedSysOrigin == "" { diff --git a/internal/service/taskcenter/taskcenter_test.go b/internal/service/taskcenter/taskcenter_test.go index b3302b4..5fcc6ea 100644 --- a/internal/service/taskcenter/taskcenter_test.go +++ b/internal/service/taskcenter/taskcenter_test.go @@ -22,6 +22,7 @@ func newTestService(t *testing.T, java taskCenterJavaGateway) (*Service, *gorm.D if err := db.AutoMigrate( &model.TaskCenterConfig{}, &model.TaskCenterTaskConfig{}, + &model.TaskCenterConditionJumpConfig{}, &model.TaskCenterUserProgress{}, &model.TaskCenterEvent{}, &model.TaskCenterClaimRecord{}, @@ -110,6 +111,9 @@ func TestDailyTaskConfigEventListAndClaim(t *testing.T) { if len(java.changedEvents) != 1 { t.Fatalf("wallet changes = %d, want 1", len(java.changedEvents)) } + if java.changedCommands[0].Origin != "DAILY_TASK" { + t.Fatalf("wallet origin = %q, want DAILY_TASK", java.changedCommands[0].Origin) + } if len(java.changedEvents[0]) > 50 { t.Fatalf("wallet event id length = %d, want <= 50: %s", len(java.changedEvents[0]), java.changedEvents[0]) } @@ -157,6 +161,9 @@ func TestExclusiveTaskConfigUsesLifetimeCycleAndHidesAfterClaim(t *testing.T) { if len(tasks) != 1 || tasks[0].TaskType != 1 { t.Fatalf("exclusive tasks = %+v, want one taskType=1", tasks) } + if tasks[0].JumpPage != defaultRechargeJumpPage { + t.Fatalf("exclusive recharge jumpPage = %q, want %q", tasks[0].JumpPage, defaultRechargeJumpPage) + } eventResp, err := service.ReceiveEvent(ctx, EventRequest{ SysOrigin: "LIKEI", @@ -189,6 +196,127 @@ func TestExclusiveTaskConfigUsesLifetimeCycleAndHidesAfterClaim(t *testing.T) { } } +func TestConditionJumpConfigAppliesToTasks(t *testing.T) { + service, _ := newTestService(t, nil) + ctx := context.Background() + + resp, err := service.SaveConfig(ctx, SaveConfigRequest{ + SysOrigin: "LIKEI", + Enabled: true, + Timezone: "Asia/Riyadh", + TaskCategory: TaskCategoryDaily, + ConditionJumps: []SaveConditionJumpConfigInput{ + { + ConditionType: "MIC_DURATION_SECONDS", + JumpType: "APP_ROUTE", + JumpPage: "/task/mic-guide", + SortOrder: 10, + }, + { + ConditionType: "GIFT_CONSUME_GOLD", + JumpType: "ROOM_RANDOM", + JumpPage: "/room?source=custom", + SortOrder: 20, + }, + }, + Tasks: []SaveTaskConfigInput{ + { + TaskCode: "DAILY_MIC", + Enabled: true, + TaskName: "Mic task", + ConditionType: "MIC_DURATION_SECONDS", + TargetValue: 60, + TargetUnit: "second", + RewardGold: 100, + JumpType: "ROOM_RANDOM_WITH_MIC", + SortOrder: 10, + }, + { + TaskCode: "DAILY_GIFT", + Enabled: true, + TaskName: "Gift task", + ConditionType: "GIFT_CONSUME_GOLD", + TargetValue: 1, + TargetUnit: "gold", + RewardGold: 100, + SortOrder: 20, + }, + }, + }) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + if len(resp.ConditionJumps) != 2 { + t.Fatalf("condition jumps = %+v, want 2 rows", resp.ConditionJumps) + } + if resp.ConditionJumps[1].JumpPage != "/room?source=custom" { + t.Fatalf("room condition jump page = %q, want preserved", resp.ConditionJumps[1].JumpPage) + } + if resp.Tasks[0].JumpType != "APP_ROUTE" || resp.Tasks[0].JumpPage != "/task/mic-guide" { + t.Fatalf("mic task jump = %s %s, want APP_ROUTE /task/mic-guide", resp.Tasks[0].JumpType, resp.Tasks[0].JumpPage) + } + if resp.Tasks[1].JumpType != "ROOM_RANDOM" || resp.Tasks[1].JumpPage != "/room?source=custom" { + t.Fatalf("gift task jump = %s %s, want ROOM_RANDOM /room?source=custom", resp.Tasks[1].JumpType, resp.Tasks[1].JumpPage) + } + + tasks, err := service.ListTasks(ctx, common.AuthUser{UserID: 3001, SysOrigin: "LIKEI"}) + if err != nil { + t.Fatalf("ListTasks() error = %v", err) + } + if len(tasks) != 2 || tasks[0].JumpPage != "/task/mic-guide" || tasks[1].JumpPage != "/room?source=custom" { + t.Fatalf("app task jumps = %+v, want custom route and custom room param", tasks) + } +} + +func TestResolveJumpPageUsesRechargeRouteForRechargeTask(t *testing.T) { + tests := []struct { + name string + task model.TaskCenterTaskConfig + want string + }{ + { + name: "recharge task with old room jump type", + task: model.TaskCenterTaskConfig{ + ConditionType: EventTypeRechargeGold, + JumpType: "ROOM_RANDOM_WITH_MIC", + }, + want: defaultRechargeJumpPage, + }, + { + name: "recharge jump type", + task: model.TaskCenterTaskConfig{ + JumpType: JumpTypeRechargeInApp, + }, + want: defaultRechargeJumpPage, + }, + { + name: "explicit jump page wins", + task: model.TaskCenterTaskConfig{ + ConditionType: EventTypeRechargeGold, + JumpPage: "/custom/recharge", + JumpType: "ROOM_RANDOM_WITH_MIC", + }, + want: "/custom/recharge", + }, + { + name: "room task keeps room jump", + task: model.TaskCenterTaskConfig{ + ConditionType: EventTypeMicDurationSeconds, + JumpType: "ROOM_RANDOM_WITH_MIC", + }, + want: "/room", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := resolveJumpPage(tt.task); got != tt.want { + t.Fatalf("resolveJumpPage() = %q, want %q", got, tt.want) + } + }) + } +} + func TestTaskConfigRejectsRemovedConditionTypes(t *testing.T) { service, _ := newTestService(t, nil) @@ -219,8 +347,9 @@ func TestTaskConfigRejectsRemovedConditionTypes(t *testing.T) { } type taskCenterTestGateway struct { - changedEvents []string - events map[string]bool + changedEvents []string + changedCommands []integration.GoldReceiptCommand + events map[string]bool } func (g *taskCenterTestGateway) ExistsGoldEvent(_ context.Context, eventID string) (bool, error) { @@ -229,6 +358,7 @@ func (g *taskCenterTestGateway) ExistsGoldEvent(_ context.Context, eventID strin func (g *taskCenterTestGateway) ChangeGoldBalance(_ context.Context, cmd integration.GoldReceiptCommand) error { g.changedEvents = append(g.changedEvents, cmd.EventID) + g.changedCommands = append(g.changedCommands, cmd) g.events[cmd.EventID] = true return nil } diff --git a/internal/service/taskcenter/types.go b/internal/service/taskcenter/types.go index 66c499d..32ebd9d 100644 --- a/internal/service/taskcenter/types.go +++ b/internal/service/taskcenter/types.go @@ -32,7 +32,10 @@ const ( ClaimStatusSuccess = "SUCCESS" ClaimStatusFailed = "FAILED" + JumpTypeRechargeInApp = "RECHARGE_IN_APP" + defaultTaskCenterTimezone = "Asia/Riyadh" + defaultRechargeJumpPage = "/recharge" ) type AppError = common.AppError @@ -88,6 +91,16 @@ type TaskConfigPayload struct { SortOrder int `json:"sortOrder"` } +// ConditionJumpConfigPayload 是后台条件跳转配置读模型。 +type ConditionJumpConfigPayload struct { + ID int64 `json:"id,string"` + TaskCategory string `json:"taskCategory"` + ConditionType string `json:"conditionType"` + JumpType string `json:"jumpType"` + JumpPage string `json:"jumpPage"` + SortOrder int `json:"sortOrder"` +} + // SaveTaskConfigInput 是后台保存单个任务的入参。 type SaveTaskConfigInput struct { ID flexibleInt64 `json:"id"` @@ -107,26 +120,38 @@ type SaveTaskConfigInput struct { SortOrder int `json:"sortOrder"` } +// SaveConditionJumpConfigInput 是后台保存单个条件跳转配置的入参。 +type SaveConditionJumpConfigInput struct { + ID flexibleInt64 `json:"id"` + TaskCategory string `json:"taskCategory"` + ConditionType string `json:"conditionType"` + JumpType string `json:"jumpType"` + JumpPage string `json:"jumpPage"` + SortOrder int `json:"sortOrder"` +} + // ConfigResponse 是后台配置页响应。 type ConfigResponse struct { - Configured bool `json:"configured"` - ID int64 `json:"id,string"` - SysOrigin string `json:"sysOrigin"` - Enabled bool `json:"enabled"` - Timezone string `json:"timezone"` - TaskCategory string `json:"taskCategory"` - UpdateTime string `json:"updateTime,omitempty"` - Tasks []TaskConfigPayload `json:"tasks"` + Configured bool `json:"configured"` + ID int64 `json:"id,string"` + SysOrigin string `json:"sysOrigin"` + Enabled bool `json:"enabled"` + Timezone string `json:"timezone"` + TaskCategory string `json:"taskCategory"` + UpdateTime string `json:"updateTime,omitempty"` + Tasks []TaskConfigPayload `json:"tasks"` + ConditionJumps []ConditionJumpConfigPayload `json:"conditionJumps"` } // SaveConfigRequest 是后台保存任务配置的入参。 type SaveConfigRequest struct { - ID flexibleInt64 `json:"id"` - SysOrigin string `json:"sysOrigin"` - Enabled bool `json:"enabled"` - Timezone string `json:"timezone"` - TaskCategory string `json:"taskCategory"` - Tasks []SaveTaskConfigInput `json:"tasks"` + ID flexibleInt64 `json:"id"` + SysOrigin string `json:"sysOrigin"` + Enabled bool `json:"enabled"` + Timezone string `json:"timezone"` + TaskCategory string `json:"taskCategory"` + Tasks []SaveTaskConfigInput `json:"tasks"` + ConditionJumps []SaveConditionJumpConfigInput `json:"conditionJumps"` } // AppTaskItem 兼容 Flutter 现有 SCTaskListRes 字段。 diff --git a/migrations/027_task_center_recharge_jump.sql b/migrations/027_task_center_recharge_jump.sql new file mode 100644 index 0000000..68029e0 --- /dev/null +++ b/migrations/027_task_center_recharge_jump.sql @@ -0,0 +1,13 @@ +-- Recharge tasks should open the Flutter in-app recharge route, not a room. +-- Existing configs created before RECHARGE_IN_APP existed may still carry +-- ROOM_RANDOM_WITH_MIC or an empty jump page. +UPDATE task_center_task_config +SET + jump_type = 'RECHARGE_IN_APP', + jump_page = '', + update_time = NOW(3) +WHERE condition_type = 'RECHARGE_GOLD' + AND ( + jump_type IN ('', 'NONE', 'ROOM_RANDOM', 'ROOM_RANDOM_WITH_MIC', 'RECHARGE_IN_APP') + OR jump_page IN ('', '/room', '/main/me/wallet/recharge') + ); diff --git a/migrations/028_task_center_condition_jump.sql b/migrations/028_task_center_condition_jump.sql new file mode 100644 index 0000000..4a08718 --- /dev/null +++ b/migrations/028_task_center_condition_jump.sql @@ -0,0 +1,66 @@ +CREATE TABLE IF NOT EXISTS `task_center_condition_jump_config` ( + `id` bigint NOT NULL COMMENT '主键ID', + `config_id` bigint NOT NULL COMMENT '任务中心配置ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `task_category` varchar(32) NOT NULL COMMENT '任务分类:DAILY/NEWBIE', + `condition_type` varchar(64) NOT NULL COMMENT '完成条件类型', + `jump_type` varchar(64) NOT NULL DEFAULT 'NONE' COMMENT '跳转类型', + `jump_page` varchar(512) NOT NULL DEFAULT '' COMMENT '跳转参数', + `sort_order` int NOT NULL DEFAULT '0' COMMENT '排序', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_task_center_condition_jump` (`sys_origin`, `task_category`, `condition_type`), + KEY `idx_task_center_condition_jump_config_id` (`config_id`), + KEY `idx_task_center_condition_jump_sys_category` (`sys_origin`, `task_category`), + KEY `idx_task_center_condition_jump_condition` (`condition_type`), + KEY `idx_task_center_condition_jump_sort` (`sort_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务中心条件跳转配置'; + +INSERT INTO task_center_condition_jump_config ( + id, + config_id, + sys_origin, + task_category, + condition_type, + jump_type, + jump_page, + sort_order, + create_time, + update_time +) +SELECT + id, + config_id, + sys_origin, + task_category, + condition_type, + CASE + WHEN condition_type = 'RECHARGE_GOLD' THEN 'RECHARGE_IN_APP' + ELSE first_jump_type + END AS jump_type, + first_jump_page AS jump_page, + sort_order, + create_time, + update_time +FROM ( + SELECT + MIN(id) AS id, + config_id, + sys_origin, + task_category, + condition_type, + COALESCE(NULLIF(SUBSTRING_INDEX(GROUP_CONCAT(jump_type ORDER BY sort_order ASC, id ASC SEPARATOR '\n'), '\n', 1), ''), 'ROOM_RANDOM_WITH_MIC') AS first_jump_type, + COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(jump_page ORDER BY sort_order ASC, id ASC SEPARATOR '\n'), '\n', 1), '') AS first_jump_page, + MIN(sort_order) AS sort_order, + COALESCE(MIN(create_time), NOW(3)) AS create_time, + NOW(3) AS update_time + FROM task_center_task_config + WHERE condition_type <> '' + GROUP BY config_id, sys_origin, task_category, condition_type +) source +ON DUPLICATE KEY UPDATE + jump_type = VALUES(jump_type), + jump_page = VALUES(jump_page), + sort_order = VALUES(sort_order), + update_time = VALUES(update_time); diff --git a/migrations/029_task_center_chinese_comments.sql b/migrations/029_task_center_chinese_comments.sql new file mode 100644 index 0000000..874a584 --- /dev/null +++ b/migrations/029_task_center_chinese_comments.sql @@ -0,0 +1,109 @@ +ALTER TABLE `task_center_config` + CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +ALTER TABLE `task_center_config` + MODIFY COLUMN `id` bigint NOT NULL COMMENT '主键ID', + MODIFY COLUMN `sys_origin` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源系统', + MODIFY COLUMN `enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用任务中心', + MODIFY COLUMN `timezone` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'Asia/Riyadh' COMMENT '任务周期时区', + MODIFY COLUMN `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + MODIFY COLUMN `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + COMMENT = '任务中心系统配置'; + +ALTER TABLE `task_center_task_config` + CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +ALTER TABLE `task_center_task_config` + MODIFY COLUMN `id` bigint NOT NULL COMMENT '主键ID', + MODIFY COLUMN `config_id` bigint NOT NULL COMMENT '任务中心配置ID', + MODIFY COLUMN `sys_origin` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源系统', + MODIFY COLUMN `task_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '任务编码', + MODIFY COLUMN `task_category` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '任务分类:DAILY/NEWBIE', + MODIFY COLUMN `enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用', + MODIFY COLUMN `task_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '任务名称', + MODIFY COLUMN `task_desc` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '任务说明', + MODIFY COLUMN `condition_type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '完成条件类型', + MODIFY COLUMN `target_value` bigint NOT NULL DEFAULT '0' COMMENT '目标值', + MODIFY COLUMN `target_unit` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '目标单位', + MODIFY COLUMN `reward_gold` bigint NOT NULL DEFAULT '0' COMMENT '奖励金币', + MODIFY COLUMN `jump_type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'NONE' COMMENT '跳转类型', + MODIFY COLUMN `jump_page` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '跳转参数', + MODIFY COLUMN `task_icon` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '任务图标', + MODIFY COLUMN `cover` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '任务封面', + MODIFY COLUMN `sort_order` int NOT NULL DEFAULT '0' COMMENT '排序', + MODIFY COLUMN `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + MODIFY COLUMN `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + COMMENT = '任务中心任务配置'; + +ALTER TABLE `task_center_condition_jump_config` + CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +ALTER TABLE `task_center_condition_jump_config` + MODIFY COLUMN `id` bigint NOT NULL COMMENT '主键ID', + MODIFY COLUMN `config_id` bigint NOT NULL COMMENT '任务中心配置ID', + MODIFY COLUMN `sys_origin` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源系统', + MODIFY COLUMN `task_category` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '任务分类:DAILY/NEWBIE', + MODIFY COLUMN `condition_type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '完成条件类型', + MODIFY COLUMN `jump_type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'NONE' COMMENT '跳转类型', + MODIFY COLUMN `jump_page` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '跳转参数', + MODIFY COLUMN `sort_order` int NOT NULL DEFAULT '0' COMMENT '排序', + MODIFY COLUMN `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + MODIFY COLUMN `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + COMMENT = '任务中心条件跳转配置'; + +ALTER TABLE `task_center_user_progress` + CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +ALTER TABLE `task_center_user_progress` + MODIFY COLUMN `id` bigint NOT NULL COMMENT '主键ID', + MODIFY COLUMN `sys_origin` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源系统', + MODIFY COLUMN `user_id` bigint NOT NULL COMMENT '用户ID', + MODIFY COLUMN `task_id` bigint NOT NULL COMMENT '任务ID', + MODIFY COLUMN `task_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '任务编码', + MODIFY COLUMN `task_category` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '任务分类', + MODIFY COLUMN `cycle_key` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '任务周期', + MODIFY COLUMN `condition_type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '完成条件类型', + MODIFY COLUMN `completed_value` bigint NOT NULL DEFAULT '0' COMMENT '已完成值', + MODIFY COLUMN `target_value` bigint NOT NULL DEFAULT '0' COMMENT '目标值', + MODIFY COLUMN `status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'IN_PROGRESS' COMMENT '任务状态', + MODIFY COLUMN `reward_collected` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已领取', + MODIFY COLUMN `claimed_at` datetime(3) DEFAULT NULL COMMENT '领取时间', + MODIFY COLUMN `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + MODIFY COLUMN `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + COMMENT = '任务中心用户进度'; + +ALTER TABLE `task_center_event` + CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +ALTER TABLE `task_center_event` + MODIFY COLUMN `id` bigint NOT NULL COMMENT '主键ID', + MODIFY COLUMN `sys_origin` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源系统', + MODIFY COLUMN `event_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务事件ID', + MODIFY COLUMN `event_type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', + MODIFY COLUMN `user_id` bigint NOT NULL COMMENT '用户ID', + MODIFY COLUMN `task_id` bigint NOT NULL COMMENT '命中任务ID', + MODIFY COLUMN `task_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '命中任务编码', + MODIFY COLUMN `task_category` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '任务分类', + MODIFY COLUMN `cycle_key` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '任务周期', + MODIFY COLUMN `delta_value` bigint NOT NULL DEFAULT '0' COMMENT '本次增量', + MODIFY COLUMN `completed_value` bigint NOT NULL DEFAULT '0' COMMENT '事件后完成值', + MODIFY COLUMN `raw_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT '原始事件内容', + MODIFY COLUMN `occurred_at` datetime(3) DEFAULT NULL COMMENT '业务发生时间', + MODIFY COLUMN `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + MODIFY COLUMN `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + COMMENT = '任务中心事件流水'; + +ALTER TABLE `task_center_claim_record` + CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +ALTER TABLE `task_center_claim_record` + MODIFY COLUMN `id` bigint NOT NULL COMMENT '主键ID', + MODIFY COLUMN `sys_origin` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源系统', + MODIFY COLUMN `user_id` bigint NOT NULL COMMENT '用户ID', + MODIFY COLUMN `task_id` bigint NOT NULL COMMENT '任务ID', + MODIFY COLUMN `task_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '任务编码', + MODIFY COLUMN `task_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '任务名称快照', + MODIFY COLUMN `task_category` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '任务分类', + MODIFY COLUMN `cycle_key` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '任务周期', + MODIFY COLUMN `reward_gold` bigint NOT NULL DEFAULT '0' COMMENT '奖励金币', + MODIFY COLUMN `wallet_event_id` varchar(160) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '钱包事件ID', + MODIFY COLUMN `status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '领取状态', + MODIFY COLUMN `failure_reason` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '失败原因', + MODIFY COLUMN `claimed_at` datetime(3) DEFAULT NULL COMMENT '领取成功时间', + MODIFY COLUMN `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + MODIFY COLUMN `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + COMMENT = '任务中心领取记录';