diff --git a/cmd/api/main.go b/cmd/api/main.go index 22e52ad..3969a43 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -12,6 +12,7 @@ import ( "chatapp3-golang/internal/bootstrap" "chatapp3-golang/internal/router" + "chatapp3-golang/internal/service/apppopup" "chatapp3-golang/internal/service/baishun" "chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameprovider" @@ -21,10 +22,13 @@ import ( "chatapp3-golang/internal/service/luckygift" "chatapp3-golang/internal/service/rechargeagency" "chatapp3-golang/internal/service/rechargereward" + "chatapp3-golang/internal/service/regionimgroup" "chatapp3-golang/internal/service/registerreward" "chatapp3-golang/internal/service/roomturnoverreward" "chatapp3-golang/internal/service/signinreward" + "chatapp3-golang/internal/service/taskcenter" "chatapp3-golang/internal/service/vip" + "chatapp3-golang/internal/service/voiceroomredpacket" "chatapp3-golang/internal/service/weekstar" ) @@ -37,6 +41,7 @@ func main() { log.Fatalf("bootstrap api failed: %v", err) } + appPopupService := apppopup.NewService(app.Config, app.Repository.DB, app.Repository.Redis) inviteService := invite.NewInviteService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) baishunService := baishun.NewBaishunService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) lingxianService := lingxian.NewService(app.Config, app.Repository.DB, app.Repository.Redis) @@ -46,11 +51,18 @@ func main() { registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) rechargeRewardService := rechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways) signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) + taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways) vipService := vip.NewService(app.Config, app.Repository.DB, &app.Gateways) + regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB) + voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room) weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways) hostCenterService := hostcenter.NewService(app.Config, &app.Gateways) + baishunService.SetTaskEventReporter(taskCenterService) + gameOpenService.SetTaskEventReporter(taskCenterService) + weekStarService.SetTaskEventReporter(taskCenterService) + workerCtx, workerCancel := context.WithCancel(context.Background()) defer workerCancel() if err := registerRewardService.Start(workerCtx); err != nil { @@ -59,12 +71,14 @@ func main() { if err := weekStarService.StartMessageConsumer(workerCtx); err != nil { log.Fatalf("start week star message consumer failed: %v", err) } + voiceRoomRedPacketService.StartRefundWorker(workerCtx) gameProviders := gameprovider.NewRegistry( baishun.NewAppProvider(baishunService), lingxian.NewAppProvider(lingxianService), ) engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{ + AppPopup: appPopupService, Invite: inviteService, Baishun: baishunService, Lingxian: lingxianService, @@ -74,10 +88,13 @@ func main() { HostCenter: hostCenterService, RechargeAgency: rechargeAgencyService, RechargeReward: rechargeRewardService, + RegionIMGroup: regionIMGroupService, RegisterReward: registerRewardService, RoomTurnoverReward: roomTurnoverRewardService, SignInReward: signInRewardService, + TaskCenter: taskCenterService, VIP: vipService, + VoiceRoomRedPacket: voiceRoomRedPacketService, WeekStar: weekStarService, }) server := &http.Server{ diff --git a/cmd/consumer/main.go b/cmd/consumer/main.go index 31ec32b..ed05abf 100644 --- a/cmd/consumer/main.go +++ b/cmd/consumer/main.go @@ -2,7 +2,9 @@ package main import ( "chatapp3-golang/internal/bootstrap" + "chatapp3-golang/internal/service/regionimgroup" "chatapp3-golang/internal/service/registerreward" + "chatapp3-golang/internal/service/voiceroomredpacket" "chatapp3-golang/internal/service/weekstar" "context" "log" @@ -22,6 +24,8 @@ func main() { registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) + regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB) + voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room) workerCtx, workerCancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer workerCancel() @@ -32,6 +36,7 @@ func main() { if err := weekStarService.StartMessageConsumer(workerCtx); err != nil { log.Fatalf("start week star message consumer failed: %v", err) } + voiceRoomRedPacketService.StartRefundWorker(workerCtx) log.Printf("chatapp3-golang consumer started") <-workerCtx.Done() diff --git a/internal/config/config.go b/internal/config/config.go index be51079..95fc1ff 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,8 +16,11 @@ type Config struct { Worker WorkerConfig Invite InviteConfig RechargeReward RechargeRewardConfig + TaskCenter TaskCenterConfig WeekStar WeekStarConfig RoomTurnoverReward RoomTurnoverRewardConfig + VoiceRoomRedPacket VoiceRoomRedPacketConfig + TencentIM TencentIMConfig Baishun BaishunConfig Lingxian LingxianConfig GameOpen GameOpenConfig @@ -80,6 +83,13 @@ type RechargeRewardConfig struct { Timezone string } +// TaskCenterConfig 保存任务中心模块配置。 +type TaskCenterConfig struct { + DefaultSysOrigin string + Timezone string + InternalEventTTL time.Duration +} + // WeekStarConfig 保存周榜模块配置。 type WeekStarConfig struct { DefaultSysOrigin string @@ -108,6 +118,27 @@ type RoomTurnoverRewardConfig struct { CycleDuration time.Duration } +// VoiceRoomRedPacketConfig 保存语音房红包 Go 侧运行配置。 +type VoiceRoomRedPacketConfig struct { + DefaultSysOrigin string + Timezone string + PresenceTTLSeconds int + RefundScanIntervalSeconds int + RefundBatchSize int + NotifyStreamKey string + ExpireZSetKey string +} + +// TencentIMConfig 保存腾讯 IM REST API 配置。 +type TencentIMConfig struct { + AppID int64 + Key string + Identifier string + BaseEndpoint string + UserSigExpireSeconds int64 + RequestTimeoutSeconds int +} + // BaishunConfig 保存百顺模块配置。 type BaishunConfig struct { PlatformBaseURL string @@ -156,6 +187,12 @@ type LuckyGiftProviderConfig struct { func Load() Config { javaAppBaseURL := getEnvAny([]string{"CHATAPP_JAVA_APP_BASE_URL", "INVITE_JAVA_APP_BASE_URL"}, "http://127.0.0.1:2400") consoleBaseURL := getEnvAny([]string{"CHATAPP_JAVA_CONSOLE_BASE_URL", "JAVA_CONSOLE_BASE_URL"}, "http://127.0.0.1:2700/console") + weekStarRocketMQEndpoint := getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_ENDPOINT", "WEEK_STAR_ROCKETMQ_ENDPOINT"}, "") + weekStarRocketMQAccessKey := getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_KEY", "WEEK_STAR_ROCKETMQ_ACCESS_KEY"}, "") + weekStarRocketMQAccessSecret := getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_SECRET", "WEEK_STAR_ROCKETMQ_ACCESS_SECRET"}, "") + weekStarRocketMQEnabledDefault := strings.TrimSpace(weekStarRocketMQEndpoint) != "" && + strings.TrimSpace(weekStarRocketMQAccessKey) != "" && + strings.TrimSpace(weekStarRocketMQAccessSecret) != "" return Config{ HTTP: HTTPConfig{ @@ -205,16 +242,30 @@ func Load() Config { "Asia/Riyadh", ), }, + TaskCenter: TaskCenterConfig{ + DefaultSysOrigin: strings.ToUpper(getEnvAny( + []string{"CHATAPP_TASK_CENTER_DEFAULT_SYS_ORIGIN", "TASK_CENTER_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"}, + "LIKEI", + )), + Timezone: getEnvAny( + []string{"CHATAPP_TASK_CENTER_TIMEZONE", "TASK_CENTER_TIMEZONE", "CHATAPP_WEEK_STAR_TIMEZONE", "WEEK_STAR_TIMEZONE"}, + "Asia/Riyadh", + ), + InternalEventTTL: time.Duration(getEnvIntAny( + []string{"CHATAPP_TASK_CENTER_EVENT_TTL_HOURS", "TASK_CENTER_EVENT_TTL_HOURS"}, + 72, + )) * time.Hour, + }, WeekStar: WeekStarConfig{ DefaultSysOrigin: strings.ToUpper(getEnvAny([]string{"CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"}, "LIKEI")), Timezone: getEnvAny([]string{"CHATAPP_WEEK_STAR_TIMEZONE", "WEEK_STAR_TIMEZONE"}, "Asia/Riyadh"), RetryStreamKey: getEnvAny([]string{"CHATAPP_WEEK_STAR_RETRY_STREAM_KEY", "WEEK_STAR_RETRY_STREAM_KEY"}, "week-star:reward-retry"), RocketMQ: WeekStarRocketMQConfig{ - Enabled: getEnvBoolAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_ENABLED", "WEEK_STAR_ROCKETMQ_ENABLED"}, false), - Endpoint: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_ENDPOINT", "WEEK_STAR_ROCKETMQ_ENDPOINT"}, ""), + Enabled: getEnvBoolAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_ENABLED", "WEEK_STAR_ROCKETMQ_ENABLED"}, weekStarRocketMQEnabledDefault), + Endpoint: weekStarRocketMQEndpoint, Namespace: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_NAMESPACE", "WEEK_STAR_ROCKETMQ_NAMESPACE"}, ""), - AccessKey: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_KEY", "WEEK_STAR_ROCKETMQ_ACCESS_KEY"}, ""), - AccessSecret: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_SECRET", "WEEK_STAR_ROCKETMQ_ACCESS_SECRET"}, ""), + AccessKey: weekStarRocketMQAccessKey, + AccessSecret: weekStarRocketMQAccessSecret, SecurityToken: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_SECURITY_TOKEN", "WEEK_STAR_ROCKETMQ_SECURITY_TOKEN"}, ""), ConsumerGroup: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_CONSUMER_GROUP", "WEEK_STAR_ROCKETMQ_CONSUMER_GROUP"}, "week-star-activity"), Topic: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_TOPIC", "WEEK_STAR_ROCKETMQ_TOPIC"}, "RC_DEFAULT_APP_ORDINARY"), @@ -235,6 +286,62 @@ func Load() Config { 10, )) * time.Second, }, + VoiceRoomRedPacket: VoiceRoomRedPacketConfig{ + DefaultSysOrigin: strings.ToUpper(getEnvAny( + []string{"CHATAPP_VOICE_ROOM_RED_PACKET_DEFAULT_SYS_ORIGIN", "VOICE_ROOM_RED_PACKET_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"}, + "LIKEI", + )), + Timezone: getEnvAny( + []string{"CHATAPP_VOICE_ROOM_RED_PACKET_TIMEZONE", "VOICE_ROOM_RED_PACKET_TIMEZONE", "CHATAPP_WEEK_STAR_TIMEZONE", "WEEK_STAR_TIMEZONE"}, + "Asia/Riyadh", + ), + PresenceTTLSeconds: getEnvIntAny( + []string{"CHATAPP_VOICE_ROOM_RED_PACKET_PRESENCE_TTL_SECONDS", "VOICE_ROOM_RED_PACKET_PRESENCE_TTL_SECONDS"}, + 60, + ), + RefundScanIntervalSeconds: getEnvIntAny( + []string{"CHATAPP_VOICE_ROOM_RED_PACKET_REFUND_SCAN_INTERVAL_SECONDS", "VOICE_ROOM_RED_PACKET_REFUND_SCAN_INTERVAL_SECONDS"}, + 10, + ), + RefundBatchSize: getEnvIntAny( + []string{"CHATAPP_VOICE_ROOM_RED_PACKET_REFUND_BATCH_SIZE", "VOICE_ROOM_RED_PACKET_REFUND_BATCH_SIZE"}, + 100, + ), + NotifyStreamKey: getEnvAny( + []string{"CHATAPP_VOICE_ROOM_RED_PACKET_NOTIFY_STREAM_KEY", "VOICE_ROOM_RED_PACKET_NOTIFY_STREAM_KEY"}, + "voice_room:red_packet:notify", + ), + ExpireZSetKey: getEnvAny( + []string{"CHATAPP_VOICE_ROOM_RED_PACKET_EXPIRE_ZSET_KEY", "VOICE_ROOM_RED_PACKET_EXPIRE_ZSET_KEY"}, + "voice_room:red_packet:expire_zset", + ), + }, + TencentIM: TencentIMConfig{ + AppID: getEnvInt64Any( + []string{"CHATAPP_TENCENT_IM_APP_ID", "TENCENT_IM_APP_ID", "LIKEI_IM_APP_ID"}, + 0, + ), + Key: getEnvAny( + []string{"CHATAPP_TENCENT_IM_KEY", "TENCENT_IM_KEY", "LIKEI_IM_KEY"}, + "", + ), + Identifier: getEnvAny( + []string{"CHATAPP_TENCENT_IM_IDENTIFIER", "TENCENT_IM_IDENTIFIER", "LIKEI_IM_IDENTIFIER"}, + "administrator", + ), + BaseEndpoint: getEnvAny( + []string{"CHATAPP_TENCENT_IM_BASE_ENDPOINT", "TENCENT_IM_BASE_ENDPOINT", "LIKEI_IM_BASE_ENDPOINT"}, + "https://console.tim.qq.com", + ), + UserSigExpireSeconds: getEnvInt64Any( + []string{"CHATAPP_TENCENT_IM_USERSIG_EXPIRE_SECONDS", "TENCENT_IM_USERSIG_EXPIRE_SECONDS"}, + 7200000, + ), + RequestTimeoutSeconds: getEnvIntAny( + []string{"CHATAPP_TENCENT_IM_REQUEST_TIMEOUT_SECONDS", "TENCENT_IM_REQUEST_TIMEOUT_SECONDS"}, + 8, + ), + }, Baishun: BaishunConfig{ PlatformBaseURL: getEnvAny([]string{"CHATAPP_BAISHUN_PLATFORM_BASE_URL", "BAISHUN_PLATFORM_BASE_URL"}, ""), GameListURL: getEnvAny([]string{"CHATAPP_BAISHUN_GAME_LIST_URL", "BAISHUN_GAME_LIST_URL"}, ""), diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c9d5de9..68f7871 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -57,6 +57,30 @@ func TestGetEnvBoolAliasSupportsLegacyLuckyGiftKey(t *testing.T) { } } +func TestLoadWeekStarRocketMQAutoEnablesWhenCredentialsPresent(t *testing.T) { + unsetEnvForTest(t, "CHATAPP_WEEK_STAR_ROCKETMQ_ENABLED", "WEEK_STAR_ROCKETMQ_ENABLED") + t.Setenv("CHATAPP_WEEK_STAR_ROCKETMQ_ENDPOINT", "rmq.example.com:8081") + t.Setenv("CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_KEY", "access-key") + t.Setenv("CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_SECRET", "access-secret") + + cfg := Load() + if !cfg.WeekStar.RocketMQ.Enabled { + t.Fatalf("expected week star rocketmq to auto-enable when endpoint and credentials are present") + } +} + +func TestLoadWeekStarRocketMQExplicitDisableOverridesCredentials(t *testing.T) { + t.Setenv("CHATAPP_WEEK_STAR_ROCKETMQ_ENABLED", "false") + t.Setenv("CHATAPP_WEEK_STAR_ROCKETMQ_ENDPOINT", "rmq.example.com:8081") + t.Setenv("CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_KEY", "access-key") + t.Setenv("CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_SECRET", "access-secret") + + cfg := Load() + if cfg.WeekStar.RocketMQ.Enabled { + t.Fatalf("expected explicit week star rocketmq disable to override credentials") + } +} + func TestLoadLingxianConfigSupportsLikeiHkysSignKey(t *testing.T) { t.Setenv("LIKEI_GAME_HKYS_SIGN_KEY", "likei-hkys-key") @@ -66,6 +90,29 @@ func TestLoadLingxianConfigSupportsLikeiHkysSignKey(t *testing.T) { } } +func unsetEnvForTest(t *testing.T, keys ...string) { + t.Helper() + values := make(map[string]string, len(keys)) + present := make(map[string]bool, len(keys)) + for _, key := range keys { + value, ok := os.LookupEnv(key) + values[key] = value + present[key] = ok + if err := os.Unsetenv(key); err != nil { + t.Fatalf("unset %s: %v", key, err) + } + } + t.Cleanup(func() { + for _, key := range keys { + if present[key] { + _ = os.Setenv(key, values[key]) + } else { + _ = os.Unsetenv(key) + } + } + }) +} + func TestLoadLuckyGiftConfigsSupportsLegacyDefaultEnv(t *testing.T) { oldJSON, hadJSON := os.LookupEnv("LUCKY_GIFT_CONFIGS_JSON") oldList, hadList := os.LookupEnv("LUCKY_GIFT_CONFIGS") diff --git a/internal/model/app_popup_models.go b/internal/model/app_popup_models.go new file mode 100644 index 0000000..6e6aff6 --- /dev/null +++ b/internal/model/app_popup_models.go @@ -0,0 +1,32 @@ +package model + +import "time" + +// AppPopupConfig 保存 APP 进场弹窗配置。 +type AppPopupConfig struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_app_popup_config_key,priority:1;index:idx_app_popup_config_scene,priority:1"` + PopupKey string `gorm:"column:popup_key;size:64;uniqueIndex:uk_app_popup_config_key,priority:3"` + Name string `gorm:"column:name;size:128"` + Scene string `gorm:"column:scene;size:32;uniqueIndex:uk_app_popup_config_key,priority:2;index:idx_app_popup_config_scene,priority:2"` + Enabled bool `gorm:"column:enabled;index:idx_app_popup_config_scene,priority:3"` + LimitDays int `gorm:"column:limit_days"` + Priority int `gorm:"column:priority;index:idx_app_popup_config_scene,priority:4"` + Version int `gorm:"column:version"` + StartTime *time.Time `gorm:"column:start_time"` + EndTime *time.Time `gorm:"column:end_time"` + PlatformsJSON string `gorm:"column:platforms_json;type:json"` + MinAppVersion string `gorm:"column:min_app_version;size:32"` + MaxAppVersion string `gorm:"column:max_app_version;size:32"` + UserScopeType string `gorm:"column:user_scope_type;size:32"` + UserScopeJSON string `gorm:"column:user_scope_json;type:json"` + ContentJSON string `gorm:"column:content_json;type:json"` + JumpType string `gorm:"column:jump_type;size:32"` + JumpURL string `gorm:"column:jump_url;size:512"` + Remark string `gorm:"column:remark;size:255"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +// TableName 返回 APP 进场弹窗配置表名。 +func (AppPopupConfig) TableName() string { return "app_popup_config" } diff --git a/internal/model/region_im_group_models.go b/internal/model/region_im_group_models.go new file mode 100644 index 0000000..2fc085d --- /dev/null +++ b/internal/model/region_im_group_models.go @@ -0,0 +1,19 @@ +package model + +import "time" + +// VoiceRoomRegionIMGroup 保存区域通用腾讯 IM 群配置。 +type VoiceRoomRegionIMGroup struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_voice_room_region_im_group,priority:1"` + RegionCode string `gorm:"column:region_code;size:32;uniqueIndex:uk_voice_room_region_im_group,priority:2;index:idx_voice_room_region_im_group_group,priority:2"` + RegionName string `gorm:"column:region_name;size:128"` + GroupID string `gorm:"column:group_id;size:128;index:idx_voice_room_region_im_group_group,priority:1"` + GroupName string `gorm:"column:group_name;size:128"` + Status string `gorm:"column:status;size:32"` + LastError string `gorm:"column:last_error;size:1024"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (VoiceRoomRegionIMGroup) TableName() string { return "voice_room_region_im_group" } diff --git a/internal/model/taskcenter_models.go b/internal/model/taskcenter_models.go new file mode 100644 index 0000000..d7519fb --- /dev/null +++ b/internal/model/taskcenter_models.go @@ -0,0 +1,103 @@ +package model + +import "time" + +// TaskCenterConfig 保存任务中心的系统级开关和时区配置。 +type TaskCenterConfig struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_task_center_config_sys_origin"` + Enabled bool `gorm:"column:enabled;index:idx_task_center_config_enabled"` + Timezone string `gorm:"column:timezone;size:64"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (TaskCenterConfig) TableName() string { return "task_center_config" } + +// TaskCenterTaskConfig 保存单个任务定义。 +type TaskCenterTaskConfig struct { + ID int64 `gorm:"column:id;primaryKey"` + ConfigID int64 `gorm:"column:config_id;index:idx_task_center_task_config_id"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_task_center_task_code,priority:1;index:idx_task_center_task_sys_category"` + TaskCode string `gorm:"column:task_code;size:64;uniqueIndex:uk_task_center_task_code,priority:2"` + TaskCategory string `gorm:"column:task_category;size:32;index:idx_task_center_task_sys_category"` + Enabled bool `gorm:"column:enabled;index:idx_task_center_task_enabled"` + TaskName string `gorm:"column:task_name;size:255"` + TaskDesc string `gorm:"column:task_desc;size:255"` + ConditionType string `gorm:"column:condition_type;size:64;index:idx_task_center_task_condition"` + TargetValue int64 `gorm:"column:target_value"` + TargetUnit string `gorm:"column:target_unit;size:32"` + RewardGold int64 `gorm:"column:reward_gold"` + JumpType string `gorm:"column:jump_type;size:64"` + JumpPage string `gorm:"column:jump_page;size:512"` + TaskIcon string `gorm:"column:task_icon;size:512"` + Cover string `gorm:"column:cover;size:512"` + SortOrder int `gorm:"column:sort_order;index:idx_task_center_task_sort"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (TaskCenterTaskConfig) TableName() string { return "task_center_task_config" } + +// TaskCenterUserProgress 保存用户在某个任务周期内的进度。 +type TaskCenterUserProgress struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_task_center_user_progress,priority:1;index:idx_task_center_progress_user"` + UserID int64 `gorm:"column:user_id;uniqueIndex:uk_task_center_user_progress,priority:2;index:idx_task_center_progress_user"` + TaskID int64 `gorm:"column:task_id;uniqueIndex:uk_task_center_user_progress,priority:3;index:idx_task_center_progress_task"` + TaskCode string `gorm:"column:task_code;size:64;index:idx_task_center_progress_task_code"` + TaskCategory string `gorm:"column:task_category;size:32"` + CycleKey string `gorm:"column:cycle_key;size:32;uniqueIndex:uk_task_center_user_progress,priority:4;index:idx_task_center_progress_cycle"` + ConditionType string `gorm:"column:condition_type;size:64"` + CompletedValue int64 `gorm:"column:completed_value"` + TargetValue int64 `gorm:"column:target_value"` + Status string `gorm:"column:status;size:32;index:idx_task_center_progress_status"` + RewardCollected bool `gorm:"column:reward_collected"` + ClaimedAt *time.Time `gorm:"column:claimed_at"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (TaskCenterUserProgress) TableName() string { return "task_center_user_progress" } + +// TaskCenterEvent 保存内部业务事件和命中的任务流水。 +type TaskCenterEvent struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_task_center_event_sys"` + EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_task_center_event_task,priority:1;index:idx_task_center_event_id"` + EventType string `gorm:"column:event_type;size:64;index:idx_task_center_event_type"` + UserID int64 `gorm:"column:user_id;index:idx_task_center_event_user"` + TaskID int64 `gorm:"column:task_id;uniqueIndex:uk_task_center_event_task,priority:2"` + TaskCode string `gorm:"column:task_code;size:64;index:idx_task_center_event_task_code"` + TaskCategory string `gorm:"column:task_category;size:32"` + CycleKey string `gorm:"column:cycle_key;size:32"` + DeltaValue int64 `gorm:"column:delta_value"` + CompletedValue int64 `gorm:"column:completed_value"` + RawJSON string `gorm:"column:raw_json;type:text"` + OccurredAt time.Time `gorm:"column:occurred_at;index:idx_task_center_event_occurred"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (TaskCenterEvent) TableName() string { return "task_center_event" } + +// TaskCenterClaimRecord 保存任务奖励领取记录。 +type TaskCenterClaimRecord struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_task_center_claim_sys"` + UserID int64 `gorm:"column:user_id;index:idx_task_center_claim_user"` + TaskID int64 `gorm:"column:task_id;index:idx_task_center_claim_task"` + TaskCode string `gorm:"column:task_code;size:64;index:idx_task_center_claim_task_code"` + TaskName string `gorm:"column:task_name;size:255"` + TaskCategory string `gorm:"column:task_category;size:32"` + CycleKey string `gorm:"column:cycle_key;size:32;index:idx_task_center_claim_cycle"` + RewardGold int64 `gorm:"column:reward_gold"` + WalletEventID string `gorm:"column:wallet_event_id;size:160;uniqueIndex:uk_task_center_claim_wallet_event"` + Status string `gorm:"column:status;size:32;index:idx_task_center_claim_status"` + FailureReason string `gorm:"column:failure_reason;size:512"` + ClaimedAt *time.Time `gorm:"column:claimed_at"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (TaskCenterClaimRecord) TableName() string { return "task_center_claim_record" } diff --git a/internal/model/voice_room_red_packet_models.go b/internal/model/voice_room_red_packet_models.go new file mode 100644 index 0000000..66dcaf1 --- /dev/null +++ b/internal/model/voice_room_red_packet_models.go @@ -0,0 +1,127 @@ +package model + +import "time" + +// VoiceRoomRedPacketConfig 保存语音房红包系统级配置。 +type VoiceRoomRedPacketConfig struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_voice_room_red_packet_config"` + Enabled bool `gorm:"column:enabled"` + DailySendLimit int `gorm:"column:daily_send_limit"` + DelaySeconds int `gorm:"column:delay_seconds"` + RefundAfterSeconds int `gorm:"column:refund_after_seconds"` + Timezone string `gorm:"column:timezone;size:64"` + AmountOptions string `gorm:"column:amount_options;size:255"` + CountOptions string `gorm:"column:count_options;size:255"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (VoiceRoomRedPacketConfig) TableName() string { return "voice_room_red_packet_config" } + +// VoiceRoomRegionCountry 保存国家到区域的映射。 +type VoiceRoomRegionCountry struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_voice_room_region_country,priority:1;index:idx_voice_room_region_country_region,priority:1"` + RegionCode string `gorm:"column:region_code;size:32;index:idx_voice_room_region_country_region,priority:2"` + CountryCode string `gorm:"column:country_code;size:32;uniqueIndex:uk_voice_room_region_country,priority:2"` + Enabled bool `gorm:"column:enabled"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (VoiceRoomRegionCountry) TableName() string { return "voice_room_region_country" } + +// RoomLiveBroadcastVIP 是 Java 房间资料表,Go 侧只读取 room_id 到 room_account 的映射。 +type RoomLiveBroadcastVIP struct { + ID int64 `gorm:"column:id;primaryKey"` + UserID int64 `gorm:"column:user_id"` + RoomID int64 `gorm:"column:room_id;index"` + RoomAccount int64 `gorm:"column:room_account"` + ExpireDate time.Time `gorm:"column:expire_date"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (RoomLiveBroadcastVIP) TableName() string { return "room_live_broadcasts_vip" } + +// VoiceRoomRedPacket 保存语音房红包主记录。 +type VoiceRoomRedPacket struct { + ID int64 `gorm:"column:id;primaryKey"` + PacketNo string `gorm:"column:packet_no;size:64;uniqueIndex:uk_voice_room_red_packet_no"` + RequestID string `gorm:"column:request_id;size:128;uniqueIndex:uk_voice_room_red_packet_request"` + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_voice_room_red_packet_room,priority:1;index:idx_voice_room_red_packet_sender_day,priority:1"` + RoomID int64 `gorm:"column:room_id;index:idx_voice_room_red_packet_room,priority:2"` + SenderUserID int64 `gorm:"column:sender_user_id;index:idx_voice_room_red_packet_sender_day,priority:2"` + SenderRegionCode string `gorm:"column:sender_region_code;size:32"` + PacketMode string `gorm:"column:packet_mode;size:16"` + AmountMode string `gorm:"column:amount_mode;size:16"` + TotalAmount int64 `gorm:"column:total_amount"` + TotalCount int `gorm:"column:total_count"` + RemainAmount int64 `gorm:"column:remain_amount"` + RemainCount int `gorm:"column:remain_count"` + ClaimStartTime time.Time `gorm:"column:claim_start_time"` + ExpireTime time.Time `gorm:"column:expire_time;index:idx_voice_room_red_packet_expire,priority:2"` + Status string `gorm:"column:status;size:32;index:idx_voice_room_red_packet_expire,priority:1"` + SendWalletEventID string `gorm:"column:send_wallet_event_id;size:128;uniqueIndex:uk_voice_room_red_packet_send_event"` + RefundWalletEventID string `gorm:"column:refund_wallet_event_id;size:128"` + RefundAmount int64 `gorm:"column:refund_amount"` + RefundStatus string `gorm:"column:refund_status;size:32"` + ErrorMessage string `gorm:"column:error_message;size:1024"` + CreateTime time.Time `gorm:"column:create_time;index:idx_voice_room_red_packet_sender_day,priority:3"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (VoiceRoomRedPacket) TableName() string { return "voice_room_red_packet" } + +// VoiceRoomRedPacketSlice 保存红包预拆分份额。 +type VoiceRoomRedPacketSlice struct { + ID int64 `gorm:"column:id;primaryKey"` + PacketID int64 `gorm:"column:packet_id;uniqueIndex:uk_voice_room_red_packet_slice,priority:1;index:idx_voice_room_red_packet_slice_claim,priority:1"` + PacketNo string `gorm:"column:packet_no;size:64"` + SortNo int `gorm:"column:sort_no;uniqueIndex:uk_voice_room_red_packet_slice,priority:2"` + Amount int64 `gorm:"column:amount"` + Status string `gorm:"column:status;size:32;index:idx_voice_room_red_packet_slice_claim,priority:2"` + ClaimUserID *int64 `gorm:"column:claim_user_id"` + ClaimRecordID *int64 `gorm:"column:claim_record_id"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (VoiceRoomRedPacketSlice) TableName() string { return "voice_room_red_packet_slice" } + +// VoiceRoomRedPacketClaim 保存领取记录和入账状态。 +type VoiceRoomRedPacketClaim struct { + ID int64 `gorm:"column:id;primaryKey"` + PacketID int64 `gorm:"column:packet_id;uniqueIndex:uk_voice_room_red_packet_claim_user,priority:1;index:idx_voice_room_red_packet_claim_packet,priority:1"` + PacketNo string `gorm:"column:packet_no;size:64"` + SliceID int64 `gorm:"column:slice_id"` + SysOrigin string `gorm:"column:sys_origin;size:32"` + RoomID int64 `gorm:"column:room_id"` + UserID int64 `gorm:"column:user_id;uniqueIndex:uk_voice_room_red_packet_claim_user,priority:2"` + UserRegionCode string `gorm:"column:user_region_code;size:32"` + Amount int64 `gorm:"column:amount"` + WalletEventID string `gorm:"column:wallet_event_id;size:128;uniqueIndex:uk_voice_room_red_packet_claim_event"` + WalletStatus string `gorm:"column:wallet_status;size:32"` + ErrorMessage string `gorm:"column:error_message;size:1024"` + ClaimTime time.Time `gorm:"column:claim_time;index:idx_voice_room_red_packet_claim_packet,priority:2"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (VoiceRoomRedPacketClaim) TableName() string { return "voice_room_red_packet_claim" } + +// VoiceRoomRedPacketDailyCounter 保存用户单日发送计数。 +type VoiceRoomRedPacketDailyCounter struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_voice_room_red_packet_daily,priority:1"` + UserID int64 `gorm:"column:user_id;uniqueIndex:uk_voice_room_red_packet_daily,priority:2"` + DayKey string `gorm:"column:day_key;size:16;uniqueIndex:uk_voice_room_red_packet_daily,priority:3"` + SendCount int `gorm:"column:send_count"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (VoiceRoomRedPacketDailyCounter) TableName() string { + return "voice_room_red_packet_daily_counter" +} diff --git a/internal/router/app_popup_routes.go b/internal/router/app_popup_routes.go new file mode 100644 index 0000000..725dacd --- /dev/null +++ b/internal/router/app_popup_routes.go @@ -0,0 +1,77 @@ +package router + +import ( + "errors" + "io" + "net/http" + "strconv" + "strings" + + "chatapp3-golang/internal/service/apppopup" + + "github.com/gin-gonic/gin" +) + +// registerAppPopupRoutes 注册 APP 进场弹窗接口。 +func registerAppPopupRoutes(engine *gin.Engine, javaClient authGateway, service *apppopup.Service) { + if service == nil { + return + } + + appGroup := engine.Group("/app/popups") + appGroup.Use(authMiddleware(javaClient)) + appGroup.POST("/entry", func(c *gin.Context) { + var req apppopup.EntryQueryRequest + if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { + writeError(c, apppopup.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.QueryEntryPopups(c.Request.Context(), mustAuthUser(c), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + adminGroup := engine.Group("/app-system/home-popups") + adminGroup.Use(consoleAuthMiddleware(javaClient)) + adminGroup.GET("/configs", func(c *gin.Context) { + cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1"))) + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) + resp, err := service.PageAdminConfigs( + c.Request.Context(), + c.Query("sysOrigin"), + c.Query("scene"), + c.Query("popupKey"), + cursor, + limit, + ) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + adminGroup.POST("/configs/save", func(c *gin.Context) { + var req apppopup.SaveAdminConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, apppopup.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.SaveAdminConfig(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + adminGroup.DELETE("/configs/:id", func(c *gin.Context) { + id, _ := strconv.ParseInt(strings.TrimSpace(c.Param("id")), 10, 64) + if err := service.DeleteAdminConfig(c.Request.Context(), id); err != nil { + writeError(c, err) + return + } + writeOK(c, gin.H{"deleted": true}) + }) +} diff --git a/internal/router/app_popup_routes_test.go b/internal/router/app_popup_routes_test.go new file mode 100644 index 0000000..2a84dff --- /dev/null +++ b/internal/router/app_popup_routes_test.go @@ -0,0 +1,183 @@ +package router + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/repo" + "chatapp3-golang/internal/service/apppopup" + + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type appPopupAuthStub struct{} + +func (appPopupAuthStub) AuthenticateToken(context.Context, string) (integration.UserCredential, error) { + return integration.UserCredential{UserID: 123, SysOrigin: "LIKEI"}, nil +} + +func (appPopupAuthStub) AuthenticateConsoleToken(context.Context, string) (integration.ConsoleAccount, error) { + return integration.ConsoleAccount{}, nil +} + +func TestAppPopupEntryRoute(t *testing.T) { + gin.SetMode(gin.TestMode) + db := newRouterAppPopupDB(t) + if err := db.Create(&model.AppPopupConfig{ + ID: 1001, + SysOrigin: "LIKEI", + PopupKey: "invite", + Scene: "APP_ENTER", + Enabled: true, + LimitDays: 7, + Priority: 100, + Version: 1, + ContentJSON: `{"title":"Invite friends","image":"https://cdn.example.com/invite.png"}`, + JumpType: "H5", + JumpURL: "/h5/app-invite/landing.html", + }).Error; err != nil { + t.Fatalf("create config: %v", err) + } + service := apppopup.NewService(config.Config{}, db, nil) + engine := NewRouter(config.Config{}, &repo.Repository{}, appPopupAuthStub{}, Services{AppPopup: service}) + + body := bytes.NewBufferString(`{"scene":"APP_ENTER","platform":"ios","appVersion":"3.12.0"}`) + req := httptest.NewRequest(http.MethodPost, "/app/popups/entry", body) + req.Header.Set("Authorization", "Bearer token") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + data, ok := payload["data"].(map[string]any) + if !ok { + t.Fatalf("data missing: %#v", payload) + } + queue, ok := data["queue"].([]any) + if !ok || len(queue) != 1 || queue[0] != "invite" { + t.Fatalf("queue = %#v, want [invite]", data["queue"]) + } + invite, ok := data["invite"].(map[string]any) + if !ok { + t.Fatalf("invite missing: %#v", data) + } + if invite["value"] != true || invite["limit"] != float64(7) { + t.Fatalf("invite = %#v, want value true limit 7", invite) + } +} + +func TestAppPopupEntryRouteRequiresAuth(t *testing.T) { + gin.SetMode(gin.TestMode) + db := newRouterAppPopupDB(t) + service := apppopup.NewService(config.Config{}, db, nil) + engine := NewRouter(config.Config{}, &repo.Repository{}, appPopupAuthStub{}, Services{AppPopup: service}) + + req := httptest.NewRequest(http.MethodPost, "/app/popups/entry", bytes.NewBufferString(`{}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d body = %s, want 401", rec.Code, rec.Body.String()) + } +} + +func TestAppPopupEntryRouteAllowsEmptyBody(t *testing.T) { + gin.SetMode(gin.TestMode) + db := newRouterAppPopupDB(t) + if err := db.Create(&model.AppPopupConfig{ + ID: 1001, + SysOrigin: "LIKEI", + PopupKey: "invite", + Scene: "APP_ENTER", + Enabled: true, + LimitDays: 7, + Priority: 100, + Version: 1, + UserScopeType: "ALL", + }).Error; err != nil { + t.Fatalf("create config: %v", err) + } + service := apppopup.NewService(config.Config{}, db, nil) + engine := NewRouter(config.Config{}, &repo.Repository{}, appPopupAuthStub{}, Services{AppPopup: service}) + + req := httptest.NewRequest(http.MethodPost, "/app/popups/entry", nil) + req.Header.Set("Authorization", "Bearer token") + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s, want 200", rec.Code, rec.Body.String()) + } +} + +func TestAppPopupAdminRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + db := newRouterAppPopupDB(t) + service := apppopup.NewService(config.Config{}, db, nil) + engine := NewRouter(config.Config{}, &repo.Repository{}, appPopupAuthStub{}, Services{AppPopup: service}) + + saveBody := bytes.NewBufferString(`{"sysOrigin":"LIKEI","popupKey":"invite","enabled":true,"limitDays":7,"description":"邀请弹窗"}`) + saveReq := httptest.NewRequest(http.MethodPost, "/app-system/home-popups/configs/save", saveBody) + saveReq.Header.Set("Authorization", "Bearer console-token") + saveReq.Header.Set("Content-Type", "application/json") + saveRec := httptest.NewRecorder() + engine.ServeHTTP(saveRec, saveReq) + if saveRec.Code != http.StatusOK { + t.Fatalf("save status = %d body = %s", saveRec.Code, saveRec.Body.String()) + } + + listReq := httptest.NewRequest(http.MethodGet, "/app-system/home-popups/configs?sysOrigin=LIKEI", nil) + listReq.Header.Set("Authorization", "Bearer console-token") + listRec := httptest.NewRecorder() + engine.ServeHTTP(listRec, listReq) + if listRec.Code != http.StatusOK { + t.Fatalf("list status = %d body = %s", listRec.Code, listRec.Body.String()) + } + var listPayload map[string]any + if err := json.Unmarshal(listRec.Body.Bytes(), &listPayload); err != nil { + t.Fatalf("decode list response: %v", err) + } + data := listPayload["data"].(map[string]any) + records := data["records"].([]any) + if len(records) != 1 { + t.Fatalf("records = %#v, want one", records) + } + record := records[0].(map[string]any) + id := record["id"].(string) + + deleteReq := httptest.NewRequest(http.MethodDelete, "/app-system/home-popups/configs/"+id, nil) + deleteReq.Header.Set("Authorization", "Bearer console-token") + deleteRec := httptest.NewRecorder() + engine.ServeHTTP(deleteRec, deleteReq) + if deleteRec.Code != http.StatusOK { + t.Fatalf("delete status = %d body = %s", deleteRec.Code, deleteRec.Body.String()) + } +} + +func newRouterAppPopupDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.AppPopupConfig{}); err != nil { + t.Fatalf("migrate app popup config: %v", err) + } + return db +} diff --git a/internal/router/region_im_group_routes.go b/internal/router/region_im_group_routes.go new file mode 100644 index 0000000..873ab12 --- /dev/null +++ b/internal/router/region_im_group_routes.go @@ -0,0 +1,106 @@ +package router + +import ( + "net/http" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/service/regionimgroup" + + "github.com/gin-gonic/gin" +) + +// registerRegionIMGroupRoutes 注册区域通用 IM 群组和区域广播接口。 +func registerRegionIMGroupRoutes( + engine *gin.Engine, + cfg config.Config, + javaClient authGateway, + service *regionimgroup.Service, +) { + if service == nil { + return + } + + registerRegionIMGroupAppRoutes(engine, javaClient, service) + registerRegionIMGroupInternalRoutes(engine, cfg, service) + registerRegionIMGroupAdminRoutes(engine, javaClient, service) +} + +func registerRegionIMGroupAppRoutes(engine *gin.Engine, javaClient authGateway, service *regionimgroup.Service) { + handler := func(c *gin.Context) { + resp, err := service.GetCurrentRegionIMGroup(c.Request.Context(), mustAuthUser(c)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + } + + appGroup := engine.Group("/app/region-im-group") + appGroup.Use(authMiddleware(javaClient)) + appGroup.GET("/current", handler) + + legacyGroup := engine.Group("/app/voice-room/red-packet") + legacyGroup.Use(authMiddleware(javaClient)) + legacyGroup.GET("/im-group", handler) +} + +func registerRegionIMGroupInternalRoutes(engine *gin.Engine, cfg config.Config, service *regionimgroup.Service) { + internalGroup := engine.Group("/internal/voice-room") + internalGroup.Use(internalSecretMiddleware(cfg.HTTP.InternalCallbackSecret)) + internalGroup.POST("/region-broadcast", func(c *gin.Context) { + var req regionimgroup.RegionBroadcastRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, regionimgroup.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.SendRegionBroadcast(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) +} + +func registerRegionIMGroupAdminRoutes(engine *gin.Engine, javaClient authGateway, service *regionimgroup.Service) { + registerRegionIMGroupAdminGroup(engine.Group("/region/config"), javaClient, service) + registerRegionIMGroupAdminGroup(engine.Group("/resident-activity/voice-room-red-packet"), javaClient, service) +} + +func registerRegionIMGroupAdminGroup(group *gin.RouterGroup, javaClient authGateway, service *regionimgroup.Service) { + group.Use(consoleAuthMiddleware(javaClient)) + group.GET("/im-groups", func(c *gin.Context) { + resp, err := service.ListRegionIMGroups(c.Request.Context(), c.Query("sysOrigin")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + group.POST("/im-groups/save", func(c *gin.Context) { + var req regionimgroup.RegionIMGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, regionimgroup.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.SaveRegionIMGroup(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + group.POST("/im-groups/create", func(c *gin.Context) { + var req regionimgroup.RegionIMGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, regionimgroup.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.CreateRegionIMGroup(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) +} diff --git a/internal/router/router.go b/internal/router/router.go index 7ece879..168c09d 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -6,6 +6,7 @@ import ( "chatapp3-golang/internal/config" "chatapp3-golang/internal/repo" + "chatapp3-golang/internal/service/apppopup" "chatapp3-golang/internal/service/baishun" "chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameprovider" @@ -15,10 +16,13 @@ import ( "chatapp3-golang/internal/service/luckygift" "chatapp3-golang/internal/service/rechargeagency" "chatapp3-golang/internal/service/rechargereward" + "chatapp3-golang/internal/service/regionimgroup" "chatapp3-golang/internal/service/registerreward" "chatapp3-golang/internal/service/roomturnoverreward" "chatapp3-golang/internal/service/signinreward" + "chatapp3-golang/internal/service/taskcenter" "chatapp3-golang/internal/service/vip" + "chatapp3-golang/internal/service/voiceroomredpacket" "chatapp3-golang/internal/service/weekstar" "github.com/gin-gonic/gin" @@ -26,6 +30,7 @@ import ( // Services 聚合所有业务模块服务,供路由层统一装配。 type Services struct { + AppPopup *apppopup.Service Invite *invite.InviteService Baishun *baishun.BaishunService Lingxian *lingxian.Service @@ -35,10 +40,13 @@ type Services struct { HostCenter *hostcenter.Service RechargeAgency *rechargeagency.Service RechargeReward *rechargereward.Service + RegionIMGroup *regionimgroup.Service RegisterReward *registerreward.RegisterRewardService RoomTurnoverReward *roomturnoverreward.Service SignInReward *signinreward.SignInRewardService + TaskCenter *taskcenter.Service VIP *vip.Service + VoiceRoomRedPacket *voiceroomredpacket.Service WeekStar *weekstar.WeekStarService } @@ -53,12 +61,16 @@ func NewRouter( engine.Use(gin.Logger(), gin.Recovery(), corsMiddleware()) registerHealthRoutes(engine, cfg, repository) + registerAppPopupRoutes(engine, javaClient, services.AppPopup) registerInviteRoutes(engine, javaClient, services.Invite) registerRechargeAgencyRoutes(engine, javaClient, services.RechargeAgency) registerRechargeRewardRoutes(engine, javaClient, services.RechargeReward) registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward) registerSignInRewardRoutes(engine, javaClient, services.SignInReward) + registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter) registerVipRoutes(engine, javaClient, services.VIP) + registerRegionIMGroupRoutes(engine, cfg, javaClient, services.RegionIMGroup) + registerVoiceRoomRedPacketRoutes(engine, javaClient, services.VoiceRoomRedPacket) registerWeekStarRoutes(engine, javaClient, services.WeekStar) registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward) registerHostCenterRoutes(engine, services.HostCenter) diff --git a/internal/router/task_center_routes.go b/internal/router/task_center_routes.go new file mode 100644 index 0000000..af89d08 --- /dev/null +++ b/internal/router/task_center_routes.go @@ -0,0 +1,150 @@ +package router + +import ( + "net/http" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/service/taskcenter" + + "github.com/gin-gonic/gin" +) + +// registerTaskCenterRoutes 注册任务中心 App、后台和内部事件路由。 +func registerTaskCenterRoutes(engine *gin.Engine, cfg config.Config, javaClient authGateway, service *taskcenter.Service) { + if service == nil { + return + } + + appGroup := engine.Group("/app/task-center") + appGroup.Use(authMiddleware(javaClient)) + appGroup.GET("/list", func(c *gin.Context) { + resp, err := service.ListTasks(c.Request.Context(), mustAuthUser(c)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.GET("/claimable-count", func(c *gin.Context) { + resp, err := service.ClaimableCount(c.Request.Context(), mustAuthUser(c)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.POST("/claim", func(c *gin.Context) { + var req taskcenter.ClaimRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, taskcenter.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.Claim(c.Request.Context(), mustAuthUser(c), req.TaskID.Int64()) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + v2Group := engine.Group("/app/v2/task") + v2Group.Use(authMiddleware(javaClient)) + v2Group.GET("/list", func(c *gin.Context) { + resp, err := service.ListTasks(c.Request.Context(), mustAuthUser(c)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + v2Group.GET("/claimable-count", func(c *gin.Context) { + resp, err := service.ClaimableCount(c.Request.Context(), mustAuthUser(c)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + v2Group.GET("/reward", func(c *gin.Context) { + resp, err := service.Claim(c.Request.Context(), mustAuthUser(c), taskcenter.ParseInt64(c.Query("taskId"))) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + internalGroup := engine.Group("/internal/task-center") + internalGroup.Use(internalSecretMiddleware(cfg.HTTP.InternalCallbackSecret)) + internalGroup.POST("/event", func(c *gin.Context) { + var req taskcenter.EventRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, taskcenter.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.ReceiveEvent(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + adminGroup := engine.Group("/resident-activity/task-center") + adminGroup.Use(consoleAuthMiddleware(javaClient)) + adminGroup.GET("/config", func(c *gin.Context) { + resp, err := service.GetConfig(c.Request.Context(), c.Query("sysOrigin"), c.Query("taskCategory")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + adminGroup.POST("/config/save", func(c *gin.Context) { + var req taskcenter.SaveConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, taskcenter.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.SaveConfig(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + adminGroup.GET("/claim-record/page", func(c *gin.Context) { + resp, err := service.PageClaimRecords( + c.Request.Context(), + c.Query("sysOrigin"), + c.Query("taskCategory"), + taskcenter.ParseInt64(c.Query("userId")), + c.Query("taskCode"), + c.Query("status"), + int(taskcenter.ParseInt64(c.Query("cursor"))), + int(taskcenter.ParseInt64(c.Query("limit"))), + ) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + adminGroup.GET("/event/page", func(c *gin.Context) { + resp, err := service.PageEvents( + c.Request.Context(), + c.Query("sysOrigin"), + c.Query("taskCategory"), + taskcenter.ParseInt64(c.Query("userId")), + c.Query("taskCode"), + c.Query("eventType"), + int(taskcenter.ParseInt64(c.Query("cursor"))), + int(taskcenter.ParseInt64(c.Query("limit"))), + ) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) +} diff --git a/internal/router/voice_room_red_packet_routes.go b/internal/router/voice_room_red_packet_routes.go new file mode 100644 index 0000000..9849925 --- /dev/null +++ b/internal/router/voice_room_red_packet_routes.go @@ -0,0 +1,164 @@ +package router + +import ( + "net/http" + "strconv" + "strings" + + "chatapp3-golang/internal/service/voiceroomredpacket" + + "github.com/gin-gonic/gin" +) + +// registerVoiceRoomRedPacketRoutes 注册语音房红包 App 和后台接口。 +func registerVoiceRoomRedPacketRoutes( + engine *gin.Engine, + javaClient authGateway, + service *voiceroomredpacket.Service, +) { + if service == nil { + return + } + + appGroup := engine.Group("/app/voice-room/red-packet") + appGroup.Use(authMiddleware(javaClient)) + appGroup.GET("/config", func(c *gin.Context) { + resp, err := service.GetConfig(c.Request.Context(), mustAuthUser(c)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.POST("/presence/heartbeat", func(c *gin.Context) { + var req voiceroomredpacket.PresenceRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, voiceroomredpacket.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.Heartbeat(c.Request.Context(), mustAuthUser(c), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.POST("/presence/leave", func(c *gin.Context) { + var req voiceroomredpacket.PresenceRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, voiceroomredpacket.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.Leave(c.Request.Context(), mustAuthUser(c), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.POST("/send", func(c *gin.Context) { + var req voiceroomredpacket.SendRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, voiceroomredpacket.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.Send(c.Request.Context(), mustAuthUser(c), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.POST("/claim", func(c *gin.Context) { + var req voiceroomredpacket.ClaimRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, voiceroomredpacket.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.Claim(c.Request.Context(), mustAuthUser(c), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.GET("/detail", func(c *gin.Context) { + resp, err := service.Detail(c.Request.Context(), mustAuthUser(c), c.Query("packetNo")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.GET("/claim-records", func(c *gin.Context) { + resp, err := service.ListClaimRecords(c.Request.Context(), mustAuthUser(c), c.Query("packetNo")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.GET("/sent-records", func(c *gin.Context) { + cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1"))) + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) + resp, err := service.PageSentRecords(c.Request.Context(), mustAuthUser(c), cursor, limit) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + adminGroup := engine.Group("/resident-activity/voice-room-red-packet") + adminGroup.Use(consoleAuthMiddleware(javaClient)) + adminGroup.GET("/config", func(c *gin.Context) { + resp, err := service.GetAdminConfig(c.Request.Context(), c.Query("sysOrigin")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + adminGroup.POST("/config/save", func(c *gin.Context) { + var req voiceroomredpacket.SaveConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, voiceroomredpacket.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.SaveAdminConfig(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + adminGroup.GET("/records", func(c *gin.Context) { + cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1"))) + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) + resp, err := service.PageAdminRecords( + c.Request.Context(), + c.Query("sysOrigin"), + c.Query("status"), + voiceroomredpacket.ParseUserID(c.Query("userId")), + cursor, + limit, + ) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + adminGroup.POST("/refund/retry", func(c *gin.Context) { + var req voiceroomredpacket.ClaimRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, voiceroomredpacket.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + if err := service.RetryRefund(c.Request.Context(), req.PacketNo); err != nil { + writeError(c, err) + return + } + writeOK(c, gin.H{"success": true}) + }) +} diff --git a/internal/service/apppopup/admin.go b/internal/service/apppopup/admin.go new file mode 100644 index 0000000..9085ece --- /dev/null +++ b/internal/service/apppopup/admin.go @@ -0,0 +1,237 @@ +package apppopup + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + + "gorm.io/gorm" +) + +// PageAdminConfigs 返回后台首页弹窗配置分页。 +func (s *Service) PageAdminConfigs(ctx context.Context, sysOrigin, scene, popupKey string, cursor, limit int) (*AdminConfigPageResponse, error) { + if s == nil || s.db == nil { + return nil, NewAppError(http.StatusServiceUnavailable, "app_popup_unavailable", "app popup service is unavailable") + } + sysOrigin = normalizeSysOrigin(sysOrigin) + scene = normalizeScene(scene) + cursor = normalizePageCursor(cursor) + limit = normalizePageLimit(limit) + + query := s.db.WithContext(ctx). + Model(&model.AppPopupConfig{}). + Where("sys_origin = ? AND scene = ?", sysOrigin, scene) + if key := normalizePopupKey(popupKey); key != "" { + query = query.Where("popup_key LIKE ?", "%"+key+"%") + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, err + } + + var rows []model.AppPopupConfig + if err := query. + Order("priority DESC"). + Order("id ASC"). + Limit(limit). + Offset((cursor - 1) * limit). + Find(&rows).Error; err != nil { + return nil, err + } + + items := make([]AdminConfigItem, 0, len(rows)) + for _, row := range rows { + items = append(items, adminItemFromModel(row)) + } + return &AdminConfigPageResponse{ + Records: items, + Total: total, + Current: cursor, + Size: limit, + }, nil +} + +// SaveAdminConfig 创建或更新后台首页弹窗配置。 +func (s *Service) SaveAdminConfig(ctx context.Context, req SaveAdminConfigRequest) (*AdminConfigItem, error) { + if s == nil || s.db == nil { + return nil, NewAppError(http.StatusServiceUnavailable, "app_popup_unavailable", "app popup service is unavailable") + } + sysOrigin := normalizeSysOrigin(req.SysOrigin) + scene := normalizeScene(req.Scene) + popupKey := normalizePopupKey(req.PopupKey) + if popupKey == "" { + return nil, NewAppError(http.StatusBadRequest, "popup_key_required", "popupKey is required") + } + if req.LimitDays < 0 { + return nil, NewAppError(http.StatusBadRequest, "invalid_limit_days", "limitDays must be greater than or equal to 0") + } + + var savedID int64 + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var row model.AppPopupConfig + requestID := req.ID.Int64() + if requestID > 0 { + err := tx.Where("id = ?", requestID).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return NewAppError(http.StatusNotFound, "app_popup_config_not_found", "config not found") + } + if err != nil { + return err + } + } else { + id, err := utils.NextID() + if err != nil { + return err + } + row.ID = id + row.CreateTime = time.Now() + } + + var duplicate model.AppPopupConfig + err := tx.Where("sys_origin = ? AND scene = ? AND popup_key = ? AND id <> ?", sysOrigin, scene, popupKey, row.ID). + First(&duplicate).Error + if err == nil { + return NewAppError(http.StatusConflict, "duplicate_popup_key", "popupKey already exists") + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + now := time.Now() + row.SysOrigin = sysOrigin + row.Scene = scene + row.PopupKey = popupKey + row.Name = defaultIfBlank(req.Name, popupKey) + row.Enabled = req.Enabled + row.LimitDays = req.LimitDays + row.Priority = req.Priority + row.Version = normalizeVersion(req.Version) + row.ContentJSON = mustContentJSON(req.Title, req.Image) + row.JumpType = strings.TrimSpace(req.JumpType) + row.JumpURL = strings.TrimSpace(req.JumpURL) + row.Remark = strings.TrimSpace(req.Description) + row.PlatformsJSON = normalizeJSONField(req.PlatformsJSON) + row.MinAppVersion = strings.TrimSpace(req.MinAppVersion) + row.MaxAppVersion = strings.TrimSpace(req.MaxAppVersion) + row.UserScopeType = "ALL" + if strings.TrimSpace(row.UserScopeJSON) == "" { + row.UserScopeJSON = "{}" + } + row.UpdateTime = now + if row.CreateTime.IsZero() { + row.CreateTime = now + } + + if err := tx.Save(&row).Error; err != nil { + return err + } + savedID = row.ID + return nil + }); err != nil { + return nil, err + } + + s.ClearConfigCache(ctx, sysOrigin, scene) + var saved model.AppPopupConfig + if err := s.db.WithContext(ctx).Where("id = ?", savedID).First(&saved).Error; err != nil { + return nil, err + } + item := adminItemFromModel(saved) + return &item, nil +} + +// DeleteAdminConfig 删除后台首页弹窗配置。 +func (s *Service) DeleteAdminConfig(ctx context.Context, id int64) error { + if s == nil || s.db == nil { + return NewAppError(http.StatusServiceUnavailable, "app_popup_unavailable", "app popup service is unavailable") + } + if id <= 0 { + return NewAppError(http.StatusBadRequest, "invalid_id", "id is required") + } + var row model.AppPopupConfig + if err := s.db.WithContext(ctx).Where("id = ?", id).First(&row).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return NewAppError(http.StatusNotFound, "app_popup_config_not_found", "config not found") + } + return err + } + if err := s.db.WithContext(ctx).Delete(&row).Error; err != nil { + return err + } + s.ClearConfigCache(ctx, row.SysOrigin, row.Scene) + return nil +} + +// ClearConfigCache 清理指定场景的配置缓存,供后台配置保存后调用。 +func (s *Service) ClearConfigCache(ctx context.Context, sysOrigin, scene string) { + if s == nil || s.cache == nil { + return + } + _ = s.cache.Del(ctx, appPopupConfigCacheKey(sysOrigin, scene)).Err() +} + +func normalizePageCursor(cursor int) int { + if cursor <= 0 { + return 1 + } + return cursor +} + +func normalizePageLimit(limit int) int { + if limit <= 0 { + return 20 + } + if limit > 100 { + return 100 + } + return limit +} + +func defaultIfBlank(value, fallback string) string { + value = strings.TrimSpace(value) + if value != "" { + return value + } + return strings.TrimSpace(fallback) +} + +func mustContentJSON(title, image string) string { + payload := map[string]any{} + if title = strings.TrimSpace(title); title != "" { + payload["title"] = title + } + if image = strings.TrimSpace(image); image != "" { + payload["image"] = image + } + if len(payload) == 0 { + return "{}" + } + raw, err := json.Marshal(payload) + if err != nil { + return "" + } + return string(raw) +} + +func normalizeJSONField(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "null" + } + var value any + if err := json.Unmarshal([]byte(raw), &value); err != nil { + return "null" + } + encoded, err := json.Marshal(value) + if err != nil { + return "null" + } + return string(encoded) +} diff --git a/internal/service/apppopup/query.go b/internal/service/apppopup/query.go new file mode 100644 index 0000000..d453f87 --- /dev/null +++ b/internal/service/apppopup/query.go @@ -0,0 +1,299 @@ +package apppopup + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/model" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +// QueryEntryPopups 返回 APP 进场弹窗配置。服务端不做用户展示频控。 +func (s *Service) QueryEntryPopups(ctx context.Context, user AuthUser, req EntryQueryRequest) (*EntryQueryResponse, error) { + if s == nil || s.db == nil { + return nil, NewAppError(http.StatusServiceUnavailable, "app_popup_unavailable", "app popup service is unavailable") + } + + sysOrigin := normalizeSysOrigin(user.SysOrigin) + scene := normalizeScene(req.Scene) + configs, err := s.loadConfigs(ctx, sysOrigin, scene) + if err != nil { + return nil, err + } + + now := s.now() + resp := &EntryQueryResponse{ + RequestID: newRequestID(), + Queue: []string{}, + Items: map[string]PopupItem{}, + } + for _, cfg := range configs { + key := normalizePopupKey(cfg.PopupKey) + if key == "" { + continue + } + item := s.buildPopupItem(cfg, req, user.UserID, now) + resp.set(key, item) + if item.Value { + resp.Queue = append(resp.Queue, key) + } + } + sort.SliceStable(resp.Queue, func(i, j int) bool { + left := resp.Items[resp.Queue[i]] + right := resp.Items[resp.Queue[j]] + if left.Priority == right.Priority { + return resp.Queue[i] < resp.Queue[j] + } + return left.Priority > right.Priority + }) + return resp, nil +} + +func (s *Service) loadConfigs(ctx context.Context, sysOrigin, scene string) ([]cachedConfig, error) { + cacheKey := appPopupConfigCacheKey(sysOrigin, scene) + if s.cache != nil { + raw, err := s.cache.Get(ctx, cacheKey).Result() + if err == nil && strings.TrimSpace(raw) != "" { + var configs []cachedConfig + if jsonErr := json.Unmarshal([]byte(raw), &configs); jsonErr == nil { + return configs, nil + } + } else if err != nil && !errors.Is(err, redis.Nil) { + return nil, NewAppError(http.StatusServiceUnavailable, "app_popup_cache_failed", err.Error()) + } + } + + var rows []model.AppPopupConfig + err := s.db.WithContext(ctx). + Where("sys_origin = ? AND scene = ?", sysOrigin, scene). + Order("priority DESC"). + Order("id ASC"). + Find(&rows).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return []cachedConfig{}, nil + } + if err != nil { + return nil, err + } + + configs := make([]cachedConfig, 0, len(rows)) + for _, row := range rows { + configs = append(configs, toCachedConfig(row)) + } + if s.cache != nil { + if encoded, err := json.Marshal(configs); err == nil { + _ = s.cache.Set(ctx, cacheKey, string(encoded), configCacheExpiration).Err() + } + } + return configs, nil +} + +func appPopupConfigCacheKey(sysOrigin, scene string) string { + return "app_popup:configs:" + normalizeSysOrigin(sysOrigin) + ":" + normalizeScene(scene) +} + +func (s *Service) buildPopupItem(cfg cachedConfig, req EntryQueryRequest, userID int64, now time.Time) PopupItem { + content := parsePopupContent(cfg.ContentJSON) + item := PopupItem{ + Value: true, + Limit: normalizeLimitDays(cfg.LimitDays), + PopupID: cfg.ID, + Version: normalizeVersion(cfg.Version), + Priority: cfg.Priority, + Title: content.Title, + Image: content.Image, + JumpType: strings.TrimSpace(cfg.JumpType), + JumpURL: strings.TrimSpace(cfg.JumpURL), + Reason: "", + Content: content.Raw, + } + + if !cfg.Enabled { + item.Value = false + item.Reason = reasonDisabled + return item + } + if cfg.StartTime != nil && !cfg.StartTime.IsZero() && now.Before(*cfg.StartTime) { + item.Value = false + item.Reason = reasonNotStarted + return item + } + if cfg.EndTime != nil && !cfg.EndTime.IsZero() && !now.Before(*cfg.EndTime) { + item.Value = false + item.Reason = reasonExpired + return item + } + if !matchPlatforms(cfg.PlatformsJSON, req.Platform) { + item.Value = false + item.Reason = reasonPlatform + return item + } + if !matchMinVersion(req.AppVersion, cfg.MinAppVersion) { + item.Value = false + item.Reason = reasonMinVersion + return item + } + if !matchMaxVersion(req.AppVersion, cfg.MaxAppVersion) { + item.Value = false + item.Reason = reasonMaxVersion + return item + } + if !matchUserScope(cfg.UserScopeType, cfg.UserScopeJSON, userID) { + item.Value = false + item.Reason = reasonUserScope + return item + } + return item +} + +func normalizeLimitDays(limit int) int { + if limit < 0 { + return 0 + } + return limit +} + +func normalizeVersion(version int) int { + if version <= 0 { + return 1 + } + return version +} + +func matchPlatforms(raw, platform string) bool { + raw = strings.TrimSpace(raw) + platform = normalizePlatform(platform) + if raw == "" || raw == "null" || platform == "" { + return true + } + var platforms []string + if err := json.Unmarshal([]byte(raw), &platforms); err != nil { + return false + } + if len(platforms) == 0 { + return true + } + for _, item := range platforms { + if normalizePlatform(item) == platform { + return true + } + } + return false +} + +func matchMinVersion(appVersion, minVersion string) bool { + appVersion = strings.TrimSpace(appVersion) + minVersion = strings.TrimSpace(minVersion) + if appVersion == "" || minVersion == "" { + return true + } + return compareVersion(appVersion, minVersion) >= 0 +} + +func matchMaxVersion(appVersion, maxVersion string) bool { + appVersion = strings.TrimSpace(appVersion) + maxVersion = strings.TrimSpace(maxVersion) + if appVersion == "" || maxVersion == "" { + return true + } + return compareVersion(appVersion, maxVersion) <= 0 +} + +func compareVersion(left, right string) int { + leftParts := parseVersion(left) + rightParts := parseVersion(right) + maxLen := len(leftParts) + if len(rightParts) > maxLen { + maxLen = len(rightParts) + } + for i := 0; i < maxLen; i++ { + var lv, rv int + if i < len(leftParts) { + lv = leftParts[i] + } + if i < len(rightParts) { + rv = rightParts[i] + } + if lv > rv { + return 1 + } + if lv < rv { + return -1 + } + } + return 0 +} + +func parseVersion(version string) []int { + version = strings.TrimSpace(version) + if version == "" { + return []int{0} + } + parts := strings.Split(version, ".") + result := make([]int, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + result = append(result, 0) + continue + } + digits := strings.Builder{} + for _, r := range part { + if r < '0' || r > '9' { + break + } + digits.WriteRune(r) + } + if digits.Len() == 0 { + result = append(result, 0) + continue + } + value, err := strconv.Atoi(digits.String()) + if err != nil { + result = append(result, 0) + continue + } + result = append(result, value) + } + return result +} + +func matchUserScope(scopeType, raw string, userID int64) bool { + switch strings.ToUpper(strings.TrimSpace(scopeType)) { + case "", "ALL": + return true + case "WHITELIST": + return userID > 0 && userScopeContains(raw, userID) + case "BLACKLIST": + return userID <= 0 || !userScopeContains(raw, userID) + default: + return false + } +} + +func userScopeContains(raw string, userID int64) bool { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "null" { + return false + } + var payload struct { + UserIDs []int64 `json:"userIds"` + } + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return false + } + for _, item := range payload.UserIDs { + if item == userID { + return true + } + } + return false +} diff --git a/internal/service/apppopup/query_test.go b/internal/service/apppopup/query_test.go new file mode 100644 index 0000000..6d25d31 --- /dev/null +++ b/internal/service/apppopup/query_test.go @@ -0,0 +1,351 @@ +package apppopup + +import ( + "context" + "encoding/json" + "strconv" + "testing" + "time" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestQueryEntryPopupsReturnsConfigWithoutUserCooldown(t *testing.T) { + db := newTestDB(t) + now := time.Date(2026, 5, 7, 12, 0, 0, 0, time.UTC) + mustCreatePopupConfig(t, db, model.AppPopupConfig{ + ID: 1001, + SysOrigin: "LIKEI", + PopupKey: "invite", + Name: "邀请活动弹窗", + Scene: defaultScene, + Enabled: true, + LimitDays: 7, + Priority: 100, + Version: 3, + ContentJSON: `{"title":"Invite friends","image":"https://cdn.example.com/invite.png"}`, + JumpType: "H5", + JumpURL: "/h5/app-invite/landing.html", + }) + mustCreatePopupConfig(t, db, model.AppPopupConfig{ + ID: 1002, + SysOrigin: "LIKEI", + PopupKey: "game", + Name: "游戏引导弹窗", + Scene: defaultScene, + Enabled: true, + LimitDays: 0, + Priority: 90, + Version: 1, + ContentJSON: `{"title":"Play games","image":"https://cdn.example.com/game.png"}`, + JumpType: "APP_ROUTE", + JumpURL: "/game-center", + }) + + service := NewService(config.Config{}, db, nil) + service.now = func() time.Time { return now } + resp, err := service.QueryEntryPopups(context.Background(), AuthUser{ + UserID: 123, + SysOrigin: "LIKEI", + }, EntryQueryRequest{Scene: defaultScene, Platform: "ios", AppVersion: "3.12.0"}) + if err != nil { + t.Fatalf("QueryEntryPopups() error = %v", err) + } + + if len(resp.Queue) != 2 || resp.Queue[0] != "invite" || resp.Queue[1] != "game" { + t.Fatalf("queue = %#v, want [invite game]", resp.Queue) + } + invite := resp.Items["invite"] + if !invite.Value || invite.Limit != 7 || invite.PopupID != 1001 || invite.Version != 3 { + t.Fatalf("invite = %+v, want enabled limit 7 id 1001 version 3", invite) + } + if invite.Title != "Invite friends" || invite.Image != "https://cdn.example.com/invite.png" { + t.Fatalf("invite content = title %q image %q", invite.Title, invite.Image) + } + game := resp.Items["game"] + if !game.Value || game.Limit != 0 { + t.Fatalf("game = %+v, want enabled limit 0", game) + } +} + +func TestQueryEntryPopupsFiltersByConfigRulesOnly(t *testing.T) { + db := newTestDB(t) + now := time.Date(2026, 5, 7, 12, 0, 0, 0, time.UTC) + future := now.Add(time.Hour) + past := now.Add(-time.Hour) + mustCreatePopupConfig(t, db, model.AppPopupConfig{ + ID: 1001, + SysOrigin: "LIKEI", + PopupKey: "invite", + Scene: defaultScene, + Enabled: false, + LimitDays: 7, + Priority: 100, + Version: 1, + UserScopeType: "ALL", + }) + mustCreatePopupConfig(t, db, model.AppPopupConfig{ + ID: 1002, + SysOrigin: "LIKEI", + PopupKey: "game", + Scene: defaultScene, + Enabled: true, + LimitDays: 7, + Priority: 90, + Version: 1, + PlatformsJSON: `["android"]`, + UserScopeType: "ALL", + }) + mustCreatePopupConfig(t, db, model.AppPopupConfig{ + ID: 1003, + SysOrigin: "LIKEI", + PopupKey: "future", + Scene: defaultScene, + Enabled: true, + LimitDays: 7, + Priority: 80, + Version: 1, + StartTime: &future, + UserScopeType: "ALL", + }) + mustCreatePopupConfig(t, db, model.AppPopupConfig{ + ID: 1004, + SysOrigin: "LIKEI", + PopupKey: "expired", + Scene: defaultScene, + Enabled: true, + LimitDays: 7, + Priority: 70, + Version: 1, + EndTime: &past, + UserScopeType: "ALL", + }) + + service := NewService(config.Config{}, db, nil) + service.now = func() time.Time { return now } + resp, err := service.QueryEntryPopups(context.Background(), AuthUser{ + UserID: 123, + SysOrigin: "LIKEI", + }, EntryQueryRequest{Platform: "ios", AppVersion: "3.12.0"}) + if err != nil { + t.Fatalf("QueryEntryPopups() error = %v", err) + } + + if len(resp.Queue) != 0 { + t.Fatalf("queue = %#v, want empty", resp.Queue) + } + assertReason(t, resp.Items["invite"], reasonDisabled) + assertReason(t, resp.Items["game"], reasonPlatform) + assertReason(t, resp.Items["future"], reasonNotStarted) + assertReason(t, resp.Items["expired"], reasonExpired) +} + +func TestQueryEntryPopupsVersionBoundaries(t *testing.T) { + db := newTestDB(t) + mustCreatePopupConfig(t, db, model.AppPopupConfig{ + ID: 1001, + SysOrigin: "LIKEI", + PopupKey: "invite", + Scene: defaultScene, + Enabled: true, + LimitDays: 7, + Priority: 100, + Version: 1, + MinAppVersion: "3.9.8", + MaxAppVersion: "3.13.0", + UserScopeType: "ALL", + }) + + service := NewService(config.Config{}, db, nil) + resp, err := service.QueryEntryPopups(context.Background(), AuthUser{ + UserID: 123, + SysOrigin: "LIKEI", + }, EntryQueryRequest{AppVersion: "3.12.0"}) + if err != nil { + t.Fatalf("QueryEntryPopups() error = %v", err) + } + if !resp.Items["invite"].Value { + t.Fatalf("invite = %+v, want value true", resp.Items["invite"]) + } + + resp, err = service.QueryEntryPopups(context.Background(), AuthUser{ + UserID: 123, + SysOrigin: "LIKEI", + }, EntryQueryRequest{AppVersion: "3.8.9"}) + if err != nil { + t.Fatalf("QueryEntryPopups() error = %v", err) + } + assertReason(t, resp.Items["invite"], reasonMinVersion) + + resp, err = service.QueryEntryPopups(context.Background(), AuthUser{ + UserID: 123, + SysOrigin: "LIKEI", + }, EntryQueryRequest{AppVersion: "3.14.0"}) + if err != nil { + t.Fatalf("QueryEntryPopups() error = %v", err) + } + assertReason(t, resp.Items["invite"], reasonMaxVersion) +} + +func TestEntryQueryResponseMarshalFlattensPopupKeys(t *testing.T) { + resp := EntryQueryResponse{ + RequestID: "req-1", + Queue: []string{"invite"}, + Items: map[string]PopupItem{ + "invite": {Value: true, Limit: 7}, + }, + } + raw, err := json.Marshal(resp) + if err != nil { + t.Fatalf("MarshalJSON() error = %v", err) + } + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("Unmarshal response error = %v", err) + } + if decoded["requestId"] != "req-1" { + t.Fatalf("requestId = %v, want req-1", decoded["requestId"]) + } + invite, ok := decoded["invite"].(map[string]any) + if !ok { + t.Fatalf("invite field missing in %s", raw) + } + if invite["value"] != true || invite["limit"] != float64(7) { + t.Fatalf("invite = %#v, want value true limit 7", invite) + } +} + +func TestAdminConfigCRUD(t *testing.T) { + db := newTestDB(t) + service := NewService(config.Config{}, db, nil) + + saved, err := service.SaveAdminConfig(context.Background(), SaveAdminConfigRequest{ + SysOrigin: "LIKEI", + Scene: defaultScene, + PopupKey: "invite", + Name: "邀请弹窗", + Enabled: true, + LimitDays: 7, + Priority: 100, + Version: 1, + Title: "Invite friends", + Image: "https://cdn.example.com/invite.png", + JumpType: "H5", + JumpURL: "/h5/app-invite/landing.html", + Description: "邀请活动首页弹窗", + }) + if err != nil { + t.Fatalf("SaveAdminConfig() error = %v", err) + } + if saved.ID == "" || saved.PopupKey != "invite" || saved.LimitDays != 7 || saved.Description != "邀请活动首页弹窗" { + t.Fatalf("saved = %+v", saved) + } + savedID, err := strconv.ParseInt(saved.ID, 10, 64) + if err != nil { + t.Fatalf("parse saved id: %v", err) + } + + page, err := service.PageAdminConfigs(context.Background(), "LIKEI", defaultScene, "inv", 1, 20) + if err != nil { + t.Fatalf("PageAdminConfigs() error = %v", err) + } + if page.Total != 1 || len(page.Records) != 1 { + t.Fatalf("page = %+v, want one record", page) + } + + updated, err := service.SaveAdminConfig(context.Background(), SaveAdminConfigRequest{ + ID: flexibleInt64{value: savedID}, + SysOrigin: "LIKEI", + Scene: defaultScene, + PopupKey: "invite", + Name: "邀请弹窗", + Enabled: false, + LimitDays: 0, + Priority: 90, + Version: 2, + Description: "每次进入都弹", + }) + if err != nil { + t.Fatalf("SaveAdminConfig(update) error = %v", err) + } + if updated.Enabled || updated.LimitDays != 0 || updated.Version != 2 { + t.Fatalf("updated = %+v, want disabled limit 0 version 2", updated) + } + + if err := service.DeleteAdminConfig(context.Background(), savedID); err != nil { + t.Fatalf("DeleteAdminConfig() error = %v", err) + } + page, err = service.PageAdminConfigs(context.Background(), "LIKEI", defaultScene, "", 1, 20) + if err != nil { + t.Fatalf("PageAdminConfigs(after delete) error = %v", err) + } + if page.Total != 0 { + t.Fatalf("total = %d, want 0", page.Total) + } +} + +func TestAdminConfigRejectsDuplicateKey(t *testing.T) { + db := newTestDB(t) + service := NewService(config.Config{}, db, nil) + _, err := service.SaveAdminConfig(context.Background(), SaveAdminConfigRequest{ + SysOrigin: "LIKEI", + Scene: defaultScene, + PopupKey: "invite", + }) + if err != nil { + t.Fatalf("SaveAdminConfig(first) error = %v", err) + } + _, err = service.SaveAdminConfig(context.Background(), SaveAdminConfigRequest{ + SysOrigin: "LIKEI", + Scene: defaultScene, + PopupKey: "invite", + }) + if err == nil { + t.Fatalf("SaveAdminConfig(duplicate) error = nil, want error") + } +} + +func newTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.AppPopupConfig{}); err != nil { + t.Fatalf("migrate app popup config: %v", err) + } + return db +} + +func mustCreatePopupConfig(t *testing.T, db *gorm.DB, row model.AppPopupConfig) { + t.Helper() + if row.Scene == "" { + row.Scene = defaultScene + } + if row.SysOrigin == "" { + row.SysOrigin = defaultSysOrigin + } + if row.Version == 0 { + row.Version = 1 + } + if row.UserScopeType == "" { + row.UserScopeType = "ALL" + } + if err := db.Create(&row).Error; err != nil { + t.Fatalf("create popup config %s: %v", row.PopupKey, err) + } +} + +func assertReason(t *testing.T, item PopupItem, want string) { + t.Helper() + if item.Value { + t.Fatalf("item = %+v, want value false", item) + } + if item.Reason != want { + t.Fatalf("reason = %q, want %q", item.Reason, want) + } +} diff --git a/internal/service/apppopup/types.go b/internal/service/apppopup/types.go new file mode 100644 index 0000000..43f1e36 --- /dev/null +++ b/internal/service/apppopup/types.go @@ -0,0 +1,353 @@ +package apppopup + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +const ( + defaultScene = "APP_ENTER" + defaultSysOrigin = "LIKEI" + adminTimeLayout = "2006-01-02 15:04:05" + + reasonDisabled = "DISABLED" + reasonNotStarted = "NOT_STARTED" + reasonExpired = "EXPIRED" + reasonPlatform = "PLATFORM_NOT_MATCH" + reasonMinVersion = "MIN_VERSION_NOT_MATCH" + reasonMaxVersion = "MAX_VERSION_NOT_MATCH" + reasonUserScope = "USER_SCOPE_NOT_MATCH" + reasonInvalidConfig = "INVALID_CONFIG" + configCacheExpiration = 60 * time.Second +) + +type AppError = common.AppError +type AuthUser = common.AuthUser + +var NewAppError = common.NewAppError + +type dbHandle interface { + WithContext(ctx context.Context) *gorm.DB +} + +// Service 负责 APP 进场弹窗配置查询。 +type Service struct { + cfg config.Config + db dbHandle + cache redis.Cmdable + now func() time.Time +} + +// NewService 创建 APP 进场弹窗服务。 +func NewService(cfg config.Config, db dbHandle, cache redis.Cmdable) *Service { + return &Service{ + cfg: cfg, + db: db, + cache: cache, + now: time.Now, + } +} + +// EntryQueryRequest 是 App 查询进场弹窗的请求。 +type EntryQueryRequest struct { + Scene string `json:"scene"` + Platform string `json:"platform"` + AppVersion string `json:"appVersion"` + DeviceID string `json:"deviceId"` + Language string `json:"language"` +} + +// EntryQueryResponse 是 App 查询进场弹窗的返回。 +type EntryQueryResponse struct { + RequestID string `json:"requestId"` + Queue []string `json:"queue"` + Items map[string]PopupItem `json:"-"` +} + +// MarshalJSON 把弹窗 key 展平成 response 顶层字段,例如 invite/game。 +func (r EntryQueryResponse) MarshalJSON() ([]byte, error) { + body := map[string]any{ + "requestId": r.RequestID, + "queue": r.Queue, + } + for key, item := range r.Items { + if key == "" || key == "requestId" || key == "queue" { + continue + } + body[key] = item + } + return json.Marshal(body) +} + +func (r *EntryQueryResponse) set(key string, item PopupItem) { + if r.Items == nil { + r.Items = make(map[string]PopupItem) + } + r.Items[key] = item +} + +// PopupItem 是单个弹窗对 App 的返回结构。 +type PopupItem struct { + Value bool `json:"value"` + Limit int `json:"limit"` + PopupID int64 `json:"popupId,omitempty"` + Version int `json:"version,omitempty"` + Priority int `json:"priority,omitempty"` + Title string `json:"title,omitempty"` + Image string `json:"image,omitempty"` + JumpType string `json:"jumpType,omitempty"` + JumpURL string `json:"jumpUrl,omitempty"` + Reason string `json:"reason"` + Content map[string]any `json:"content,omitempty"` +} + +type popupContent struct { + Title string `json:"title"` + Image string `json:"image"` + Raw map[string]any `json:"-"` +} + +func normalizeScene(scene string) string { + scene = strings.ToUpper(strings.TrimSpace(scene)) + if scene == "" { + return defaultScene + } + return scene +} + +func normalizeSysOrigin(sysOrigin string) string { + sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin)) + if sysOrigin == "" { + return defaultSysOrigin + } + return sysOrigin +} + +func normalizePopupKey(key string) string { + return strings.ToLower(strings.TrimSpace(key)) +} + +func normalizePlatform(platform string) string { + return strings.ToLower(strings.TrimSpace(platform)) +} + +func newRequestID() string { + id, err := utils.NextID() + if err == nil { + return fmt.Sprintf("%d", id) + } + return fmt.Sprintf("%d", time.Now().UnixNano()) +} + +func parsePopupContent(raw string) popupContent { + raw = strings.TrimSpace(raw) + if raw == "" { + return popupContent{} + } + var values map[string]any + if err := json.Unmarshal([]byte(raw), &values); err != nil { + return popupContent{} + } + return popupContent{ + Title: stringFromMap(values, "title"), + Image: firstStringFromMap(values, "image", "imageUrl", "cover"), + Raw: values, + } +} + +func stringFromMap(values map[string]any, key string) string { + if values == nil { + return "" + } + value, _ := values[key].(string) + return strings.TrimSpace(value) +} + +func firstStringFromMap(values map[string]any, keys ...string) string { + for _, key := range keys { + if value := stringFromMap(values, key); value != "" { + return value + } + } + return "" +} + +type cachedConfig struct { + ID int64 `json:"id"` + SysOrigin string `json:"sysOrigin"` + PopupKey string `json:"popupKey"` + Name string `json:"name"` + Scene string `json:"scene"` + Enabled bool `json:"enabled"` + LimitDays int `json:"limitDays"` + Priority int `json:"priority"` + Version int `json:"version"` + StartTime *time.Time `json:"startTime"` + EndTime *time.Time `json:"endTime"` + PlatformsJSON string `json:"platformsJson"` + MinAppVersion string `json:"minAppVersion"` + MaxAppVersion string `json:"maxAppVersion"` + UserScopeType string `json:"userScopeType"` + UserScopeJSON string `json:"userScopeJson"` + ContentJSON string `json:"contentJson"` + JumpType string `json:"jumpType"` + JumpURL string `json:"jumpUrl"` +} + +// AdminConfigPageResponse 是后台首页弹窗配置分页响应。 +type AdminConfigPageResponse struct { + Records []AdminConfigItem `json:"records"` + Total int64 `json:"total"` + Current int `json:"current"` + Size int `json:"size"` +} + +// AdminConfigItem 是后台首页弹窗配置项。 +type AdminConfigItem struct { + ID string `json:"id"` + SysOrigin string `json:"sysOrigin"` + Scene string `json:"scene"` + PopupKey string `json:"popupKey"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + LimitDays int `json:"limitDays"` + Priority int `json:"priority"` + Version int `json:"version"` + Title string `json:"title"` + Image string `json:"image"` + JumpType string `json:"jumpType"` + JumpURL string `json:"jumpUrl"` + Description string `json:"description"` + StartTime string `json:"startTime,omitempty"` + EndTime string `json:"endTime,omitempty"` + PlatformsJSON string `json:"platformsJson,omitempty"` + MinAppVersion string `json:"minAppVersion,omitempty"` + MaxAppVersion string `json:"maxAppVersion,omitempty"` + CreateTime string `json:"createTime,omitempty"` + UpdateTime string `json:"updateTime,omitempty"` +} + +// SaveAdminConfigRequest 是后台保存首页弹窗配置请求。 +type SaveAdminConfigRequest struct { + ID flexibleInt64 `json:"id"` + SysOrigin string `json:"sysOrigin"` + Scene string `json:"scene"` + PopupKey string `json:"popupKey"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + LimitDays int `json:"limitDays"` + Priority int `json:"priority"` + Version int `json:"version"` + Title string `json:"title"` + Image string `json:"image"` + JumpType string `json:"jumpType"` + JumpURL string `json:"jumpUrl"` + Description string `json:"description"` + PlatformsJSON string `json:"platformsJson"` + MinAppVersion string `json:"minAppVersion"` + MaxAppVersion string `json:"maxAppVersion"` +} + +type flexibleInt64 struct { + value int64 +} + +func (v *flexibleInt64) UnmarshalJSON(data []byte) error { + var raw any + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + switch value := raw.(type) { + case float64: + v.value = int64(value) + case string: + parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil { + v.value = 0 + return nil + } + v.value = parsed + } + return nil +} + +func (v flexibleInt64) Int64() int64 { + return v.value +} + +func toCachedConfig(row model.AppPopupConfig) cachedConfig { + return cachedConfig{ + ID: row.ID, + SysOrigin: row.SysOrigin, + PopupKey: row.PopupKey, + Name: row.Name, + Scene: row.Scene, + Enabled: row.Enabled, + LimitDays: row.LimitDays, + Priority: row.Priority, + Version: row.Version, + StartTime: row.StartTime, + EndTime: row.EndTime, + PlatformsJSON: row.PlatformsJSON, + MinAppVersion: row.MinAppVersion, + MaxAppVersion: row.MaxAppVersion, + UserScopeType: row.UserScopeType, + UserScopeJSON: row.UserScopeJSON, + ContentJSON: row.ContentJSON, + JumpType: row.JumpType, + JumpURL: row.JumpURL, + } +} + +func adminItemFromModel(row model.AppPopupConfig) AdminConfigItem { + content := parsePopupContent(row.ContentJSON) + return AdminConfigItem{ + ID: strconv.FormatInt(row.ID, 10), + SysOrigin: row.SysOrigin, + Scene: row.Scene, + PopupKey: row.PopupKey, + Name: row.Name, + Enabled: row.Enabled, + LimitDays: row.LimitDays, + Priority: row.Priority, + Version: normalizeVersion(row.Version), + Title: content.Title, + Image: content.Image, + JumpType: row.JumpType, + JumpURL: row.JumpURL, + Description: row.Remark, + StartTime: formatAdminTimePtr(row.StartTime), + EndTime: formatAdminTimePtr(row.EndTime), + PlatformsJSON: row.PlatformsJSON, + MinAppVersion: row.MinAppVersion, + MaxAppVersion: row.MaxAppVersion, + CreateTime: formatAdminTime(row.CreateTime), + UpdateTime: formatAdminTime(row.UpdateTime), + } +} + +func formatAdminTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(adminTimeLayout) +} + +func formatAdminTimePtr(t *time.Time) string { + if t == nil { + return "" + } + return formatAdminTime(*t) +} diff --git a/internal/service/baishun/callback.go b/internal/service/baishun/callback.go index 9d88672..443b9e1 100644 --- a/internal/service/baishun/callback.go +++ b/internal/service/baishun/callback.go @@ -3,6 +3,7 @@ package baishun import ( "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" + "chatapp3-golang/internal/service/taskcenter" "chatapp3-golang/internal/utils" "context" "crypto/md5" @@ -10,6 +11,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "log" "strconv" "strings" "time" @@ -237,6 +239,7 @@ func (s *BaishunService) HandleChangeBalance(ctx context.Context, req BaishunCha } s.persistOrderRecord(ctx, session.SysOrigin, req, walletEventID, baishunOrderStatusSuccess, rawJSON, utils.MustJSONString(resp, ""), nil) s.saveCallbackLog(ctx, "change-balance", rawJSON, utils.MustJSONString(resp, ""), session.SysOrigin, req.UserID, &req.OrderID, &req.GameRoundID, baishunCallbackStatusSuccess, 0, resp.Message) + s.reportGameConsumeTaskEvent(ctx, session, req) return resp } @@ -255,6 +258,27 @@ func (s *BaishunService) HandleReport(ctx context.Context, payload map[string]an return resp } +func (s *BaishunService) reportGameConsumeTaskEvent(ctx context.Context, session model.BaishunLaunchSession, req BaishunChangeBalanceRequest) { + if s.taskReporter == nil || req.CurrencyDiff >= 0 { + return + } + deltaValue := abs64(req.CurrencyDiff) + if deltaValue <= 0 { + return + } + payload := map[string]any{ + "provider": "BAISHUN", + "orderId": strings.TrimSpace(req.OrderID), + "gameId": req.GameID, + "gameRoundId": strings.TrimSpace(req.GameRoundID), + "msgType": strings.TrimSpace(req.MsgType), + "diffMsg": strings.TrimSpace(req.DiffMsg), + } + if err := s.taskReporter.ReportSimpleEvent(ctx, session.SysOrigin, fmt.Sprintf("BAISHUN_GAME_CONSUME:%s:%s", session.SysOrigin, strings.TrimSpace(req.OrderID)), taskcenter.EventTypeGameConsumeGold, session.UserID, deltaValue, payload); err != nil { + log.Printf("report baishun task event failed: %v", err) + } +} + // HandleBalanceInfo 处理百顺查询余额回调。 func (s *BaishunService) HandleBalanceInfo(ctx context.Context, req BaishunBalanceInfoRequest, rawJSON string) baishunBalanceInfoResponse { if _, ok := s.validateCommonSignature(ctx, req.AppID, req.AppChannel, req.Signature, req.SignatureNonce, req.Timestamp); !ok { diff --git a/internal/service/baishun/types.go b/internal/service/baishun/types.go index a33fc29..8f5ff08 100644 --- a/internal/service/baishun/types.go +++ b/internal/service/baishun/types.go @@ -50,6 +50,10 @@ type baishunGateway interface { ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error } +type taskEventReporter interface { + ReportSimpleEvent(ctx context.Context, sysOrigin, eventID, eventType string, userID, deltaValue int64, payload map[string]any) error +} + type baishunPorts struct { DB baishunDB Redis redis.Cmdable @@ -57,10 +61,11 @@ type baishunPorts struct { // BaishunService 负责百顺目录同步、游戏启动、房间状态以及回调处理。 type BaishunService struct { - cfg config.Config - repo baishunPorts - java baishunGateway - httpClient *http.Client + cfg config.Config + repo baishunPorts + java baishunGateway + httpClient *http.Client + taskReporter taskEventReporter } // NewBaishunService 创建百顺接入服务实例。 @@ -75,6 +80,10 @@ func NewBaishunService(cfg config.Config, db baishunDB, cache redis.Cmdable, jav } } +func (s *BaishunService) SetTaskEventReporter(reporter taskEventReporter) { + s.taskReporter = reporter +} + // RoomGameListItem 是房间游戏列表中的单个游戏项。 type RoomGameListItem struct { ID int64 `json:"id"` diff --git a/internal/service/gameopen/service.go b/internal/service/gameopen/service.go index 412aa29..35b2364 100644 --- a/internal/service/gameopen/service.go +++ b/internal/service/gameopen/service.go @@ -3,9 +3,11 @@ package gameopen import ( "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" + "chatapp3-golang/internal/service/taskcenter" "chatapp3-golang/internal/utils" "context" "fmt" + "log" "net/http" "strconv" "strings" @@ -134,6 +136,7 @@ func (s *GameOpenService) HandleUpdateCoin(ctx context.Context, req UpdateCoinRe resp := successResponse(map[string]any{"coin": balance}) s.saveCallbackLog(ctx, "update-coin", rawJSON, resp, credential.SysOrigin, req, &userID, gameOpenLogStatusSuccess) + s.reportGameConsumeTaskEvent(ctx, credential.SysOrigin, userID, req) return resp } @@ -204,6 +207,30 @@ func (s *GameOpenService) readUserBalance(ctx context.Context, userID int64) (in return balances[userID], nil } +func (s *GameOpenService) reportGameConsumeTaskEvent(ctx context.Context, sysOrigin string, userID int64, req UpdateCoinRequest) { + if s.taskReporter == nil || receiptTypeFromType(req.Type) != "EXPENDITURE" { + return + } + deltaValue := req.Coin + if deltaValue < 0 { + deltaValue = -deltaValue + } + if deltaValue <= 0 { + return + } + payload := map[string]any{ + "provider": "GAME_OPEN", + "orderId": strings.TrimSpace(req.OrderID), + "gameId": strings.TrimSpace(req.GameID), + "roundId": strings.TrimSpace(req.RoundID), + "rewardType": req.RewardType, + "roomId": strings.TrimSpace(req.RoomID), + } + if err := s.taskReporter.ReportSimpleEvent(ctx, sysOrigin, "GAME_OPEN_CONSUME:"+strings.TrimSpace(req.OrderID), taskcenter.EventTypeGameConsumeGold, userID, deltaValue, payload); err != nil { + log.Printf("report game open task event failed: %v", err) + } +} + type resolvedGameOpenUser struct { userID int64 profile integration.UserProfile diff --git a/internal/service/gameopen/types.go b/internal/service/gameopen/types.go index 7c55910..c91dd95 100644 --- a/internal/service/gameopen/types.go +++ b/internal/service/gameopen/types.go @@ -37,15 +37,20 @@ type gameOpenGateway interface { ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error } +type taskEventReporter interface { + ReportSimpleEvent(ctx context.Context, sysOrigin, eventID, eventType string, userID, deltaValue int64, payload map[string]any) error +} + type gameOpenPorts struct { DB gameOpenDB } // GameOpenService 负责统一三方游戏回调接口。 type GameOpenService struct { - cfg config.Config - repo gameOpenPorts - java gameOpenGateway + cfg config.Config + repo gameOpenPorts + java gameOpenGateway + taskReporter taskEventReporter } // NewGameOpenService 创建统一三方游戏回调服务。 @@ -60,6 +65,10 @@ func NewGameOpenService(cfg config.Config, db gameOpenDB, javaGateway gameOpenGa } } +func (s *GameOpenService) SetTaskEventReporter(reporter taskEventReporter) { + s.taskReporter = reporter +} + // QueryUserRequest 是统一查询用户信息入参。 type QueryUserRequest struct { GameID string `json:"gameId"` diff --git a/internal/service/regionimgroup/broadcast.go b/internal/service/regionimgroup/broadcast.go new file mode 100644 index 0000000..41c127a --- /dev/null +++ b/internal/service/regionimgroup/broadcast.go @@ -0,0 +1,255 @@ +package regionimgroup + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "net/http" + "strconv" + "strings" + + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/service/tencentim" + + "gorm.io/gorm" +) + +// SendRegionBroadcast 向当前区域 IM 群发送自定义广播消息。 +func (s *Service) SendRegionBroadcast(ctx context.Context, req RegionBroadcastRequest) (*RegionBroadcastResponse, error) { + if s == nil || s.im == nil { + return nil, mapTencentIMError(tencentim.ErrNotConfigured) + } + sysOrigin := normalizeSysOrigin(req.SysOrigin) + messageType := strings.TrimSpace(req.Type) + if messageType == "" { + return nil, NewAppError(http.StatusBadRequest, "missing_broadcast_type", "type is required") + } + + data := cloneBroadcastData(req.Data) + senderUserID := req.SenderUserID.Int64() + if senderUserID <= 0 { + senderUserID = firstInt64Value(data["senderUserId"], data["sendUserId"], data["userId"]) + } + + regionCode := normalizeRegionCode(req.RegionCode) + if regionCode == "" { + if senderUserID <= 0 { + return nil, NewAppError(http.StatusBadRequest, "missing_sender_user_id", "senderUserId is required when regionCode is empty") + } + resolvedRegionCode, err := s.resolveUserRegion(ctx, sysOrigin, senderUserID) + if err != nil { + return nil, err + } + regionCode = resolvedRegionCode + } + + group, err := s.loadActiveRegionIMGroup(ctx, sysOrigin, regionCode) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, NewAppError(http.StatusNotFound, "region_im_group_not_configured", + fmt.Sprintf("region im group is not configured sysOrigin=%s regionCode=%s", sysOrigin, regionCode)) + } + if err != nil { + return nil, err + } + + data["sysOrigin"] = sysOrigin + data["regionCode"] = regionCode + if senderUserID > 0 { + if err := s.enrichBroadcastSender(ctx, data, senderUserID); err != nil { + return nil, err + } + } + if acceptUserID := firstAcceptUserID(data); acceptUserID > 0 { + if err := s.enrichBroadcastAcceptUser(ctx, data, acceptUserID); err != nil { + return nil, err + } + } + + body := map[string]any{ + "type": messageType, + "data": data, + } + if err := s.im.SendCustomGroupMessage(ctx, group.GroupID, body); err != nil { + return nil, mapTencentIMError(err) + } + return &RegionBroadcastResponse{ + SysOrigin: sysOrigin, + RegionCode: regionCode, + GroupID: group.GroupID, + Type: messageType, + }, nil +} + +func cloneBroadcastData(data map[string]any) map[string]any { + if len(data) == 0 { + return map[string]any{} + } + clone := make(map[string]any, len(data)) + for key, value := range data { + clone[key] = value + } + return clone +} + +func (s *Service) enrichBroadcastSender(ctx context.Context, data map[string]any, userID int64) error { + profile, err := s.loadBroadcastUserProfile(ctx, userID) + if err != nil { + return err + } + account := strings.TrimSpace(profile.Account) + if account == "" { + account = strconv.FormatInt(userID, 10) + } + setIfMissing(data, "sendUserId", strconv.FormatInt(userID, 10)) + setIfMissing(data, "senderUserId", strconv.FormatInt(userID, 10)) + setIfMissing(data, "account", account) + setIfMissing(data, "actualAccount", account) + setIfMissing(data, "userNickname", profile.UserNickname) + setIfMissing(data, "nickname", profile.UserNickname) + setIfMissing(data, "userAvatar", profile.UserAvatar) + setIfMissing(data, "countryCode", profile.CountryCode) + setIfMissing(data, "countryName", profile.CountryName) + return nil +} + +func (s *Service) enrichBroadcastAcceptUser(ctx context.Context, data map[string]any, userID int64) error { + profile, err := s.loadBroadcastUserProfile(ctx, userID) + if err != nil { + return err + } + account := strings.TrimSpace(profile.Account) + if account == "" { + account = strconv.FormatInt(userID, 10) + } + setIfMissing(data, "acceptUserId", strconv.FormatInt(userID, 10)) + setIfMissing(data, "toUserId", strconv.FormatInt(userID, 10)) + setIfMissing(data, "acceptAccount", account) + setIfMissing(data, "acceptNickname", profile.UserNickname) + setIfMissing(data, "toUserName", profile.UserNickname) + setIfMissing(data, "acceptUserAvatar", profile.UserAvatar) + setIfMissing(data, "toUserAvatarUrl", profile.UserAvatar) + return nil +} + +func (s *Service) loadBroadcastUserProfile(ctx context.Context, userID int64) (model.UserBaseInfo, error) { + var user model.UserBaseInfo + err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + account := strconv.FormatInt(userID, 10) + return model.UserBaseInfo{ + ID: userID, + Account: account, + UserNickname: account, + }, nil + } + if err != nil { + return model.UserBaseInfo{}, err + } + if strings.TrimSpace(user.Account) == "" { + user.Account = strconv.FormatInt(userID, 10) + } + if strings.TrimSpace(user.UserNickname) == "" { + user.UserNickname = user.Account + } + return user, nil +} + +func setIfMissing(data map[string]any, key string, value any) { + if data == nil || value == nil { + return + } + if existing, exists := data[key]; exists && strings.TrimSpace(fmt.Sprint(existing)) != "" { + return + } + data[key] = value +} + +func firstAcceptUserID(data map[string]any) int64 { + if id := firstInt64Value(data["acceptUserId"], data["toUserId"]); id > 0 { + return id + } + return firstInt64FromList(data["acceptUserIds"]) +} + +func firstInt64Value(values ...any) int64 { + for _, value := range values { + if parsed := int64FromAny(value); parsed > 0 { + return parsed + } + } + return 0 +} + +func firstInt64FromList(value any) int64 { + switch typed := value.(type) { + case []any: + for _, item := range typed { + if parsed := int64FromAny(item); parsed > 0 { + return parsed + } + } + case []int64: + for _, item := range typed { + if item > 0 { + return item + } + } + case []string: + for _, item := range typed { + if parsed := int64FromAny(item); parsed > 0 { + return parsed + } + } + default: + return int64FromAny(typed) + } + return 0 +} + +func int64FromAny(value any) int64 { + switch typed := value.(type) { + case nil: + return 0 + case int: + return int64(typed) + case int8: + return int64(typed) + case int16: + return int64(typed) + case int32: + return int64(typed) + case int64: + return typed + case uint: + if uint64(typed) > math.MaxInt64 { + return 0 + } + return int64(typed) + case uint8: + return int64(typed) + case uint16: + return int64(typed) + case uint32: + return int64(typed) + case uint64: + if typed > math.MaxInt64 { + return 0 + } + return int64(typed) + case float32: + return int64(typed) + case float64: + return int64(typed) + case json.Number: + parsed, _ := typed.Int64() + return parsed + case string: + parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) + return parsed + default: + parsed, _ := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(typed)), 10, 64) + return parsed + } +} diff --git a/internal/service/regionimgroup/region.go b/internal/service/regionimgroup/region.go new file mode 100644 index 0000000..68e533f --- /dev/null +++ b/internal/service/regionimgroup/region.go @@ -0,0 +1,43 @@ +package regionimgroup + +import ( + "context" + "errors" + "net/http" + "strings" + + "chatapp3-golang/internal/model" + + "gorm.io/gorm" +) + +func (s *Service) resolveUserRegion(ctx context.Context, sysOrigin string, userID int64) (string, error) { + var user model.UserBaseInfo + err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", NewAppError(http.StatusBadRequest, "voice_room_region_missing", "user region is missing") + } + if err != nil { + return "", err + } + countryCode := strings.ToUpper(strings.TrimSpace(user.CountryCode)) + if countryCode == "" { + return "", NewAppError(http.StatusBadRequest, "voice_room_region_missing", "user country code is missing") + } + + var mapping model.VoiceRoomRegionCountry + err = s.db.WithContext(ctx). + Where("sys_origin = ? AND country_code = ? AND enabled = ?", sysOrigin, countryCode, true). + First(&mapping).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return countryCode, nil + } + if err != nil { + return "", err + } + regionCode := strings.ToUpper(strings.TrimSpace(mapping.RegionCode)) + if regionCode == "" { + return countryCode, nil + } + return regionCode, nil +} diff --git a/internal/service/regionimgroup/service.go b/internal/service/regionimgroup/service.go new file mode 100644 index 0000000..6355124 --- /dev/null +++ b/internal/service/regionimgroup/service.go @@ -0,0 +1,295 @@ +package regionimgroup + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/service/tencentim" + "chatapp3-golang/internal/utils" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// ListRegionIMGroups 返回已配置的区域 IM 群组。 +func (s *Service) ListRegionIMGroups(ctx context.Context, sysOrigin string) ([]RegionIMGroupResponse, error) { + sysOrigin = normalizeSysOrigin(sysOrigin) + var rows []model.VoiceRoomRegionIMGroup + if err := s.db.WithContext(ctx). + Where("sys_origin = ?", sysOrigin). + Order("region_code asc"). + Find(&rows).Error; err != nil { + return nil, err + } + result := make([]RegionIMGroupResponse, 0, len(rows)) + for _, row := range rows { + result = append(result, regionIMGroupResponse(row)) + } + return result, nil +} + +// GetCurrentRegionIMGroup 返回当前登录用户所在区域的 IM 群组,供 App 加入区域广播群。 +func (s *Service) GetCurrentRegionIMGroup(ctx context.Context, user AuthUser) (*RegionIMGroupResponse, error) { + sysOrigin := normalizeSysOrigin(user.SysOrigin) + regionCode, err := s.resolveUserRegion(ctx, sysOrigin, user.UserID) + if err != nil { + return nil, err + } + row, err := s.loadActiveRegionIMGroup(ctx, sysOrigin, regionCode) + if errors.Is(err, gorm.ErrRecordNotFound) { + return &RegionIMGroupResponse{ + SysOrigin: sysOrigin, + RegionCode: regionCode, + Status: imGroupStatusPending, + Configured: false, + }, nil + } + if err != nil { + return nil, err + } + resp := regionIMGroupResponse(*row) + resp.Configured = strings.TrimSpace(resp.GroupID) != "" + return &resp, nil +} + +// SaveRegionIMGroup 保存人工录入的区域 IM 群组。 +func (s *Service) SaveRegionIMGroup(ctx context.Context, req RegionIMGroupRequest) (*RegionIMGroupResponse, error) { + row, err := s.buildRegionIMGroupRow(req, imGroupStatusActive, "") + if err != nil { + return nil, err + } + if strings.TrimSpace(row.GroupID) == "" { + row.Status = imGroupStatusPending + } + if err := s.upsertRegionIMGroup(ctx, row); err != nil { + return nil, err + } + return s.loadRegionIMGroupResponse(ctx, row.SysOrigin, row.RegionCode) +} + +// CreateRegionIMGroup 调用腾讯 IM 创建当前区域通用广播群并落库。 +func (s *Service) CreateRegionIMGroup(ctx context.Context, req RegionIMGroupRequest) (*RegionIMGroupResponse, error) { + row, err := s.buildRegionIMGroupRow(req, imGroupStatusPending, "") + if err != nil { + return nil, err + } + existing, err := s.loadRegionIMGroup(ctx, row.SysOrigin, row.RegionCode) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + if existing != nil && existing.Status == imGroupStatusActive && strings.TrimSpace(existing.GroupID) != "" && strings.TrimSpace(req.GroupID) == "" { + resp := regionIMGroupResponse(*existing) + return &resp, nil + } + if s.im == nil { + return nil, mapTencentIMError(tencentim.ErrNotConfigured) + } + + createdGroupID, createErr := s.im.CreateAVChatRoom(ctx, row.GroupID, row.GroupName) + if createErr != nil { + row.Status = imGroupStatusFailed + row.LastError = trimErrorMessage(createErr.Error()) + _ = s.upsertRegionIMGroup(context.Background(), row) + return nil, mapTencentIMError(createErr) + } + row.GroupID = createdGroupID + row.Status = imGroupStatusActive + row.LastError = "" + if err := s.upsertRegionIMGroup(ctx, row); err != nil { + return nil, err + } + return s.loadRegionIMGroupResponse(ctx, row.SysOrigin, row.RegionCode) +} + +// SendRegionCustomMessage 向指定区域通用 IM 群发送自定义消息。 +func (s *Service) SendRegionCustomMessage(ctx context.Context, sysOrigin string, regionCode string, body any) (string, error) { + if s == nil || s.im == nil { + return "", tencentim.ErrNotConfigured + } + group, err := s.loadActiveRegionIMGroup(ctx, sysOrigin, regionCode) + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", fmt.Errorf("region im group is not configured sysOrigin=%s regionCode=%s", normalizeSysOrigin(sysOrigin), normalizeRegionCode(regionCode)) + } + if err != nil { + return "", err + } + if err := s.im.SendCustomGroupMessage(ctx, group.GroupID, body); err != nil { + return "", err + } + return group.GroupID, nil +} + +func (s *Service) buildRegionIMGroupRow(req RegionIMGroupRequest, status string, lastError string) (model.VoiceRoomRegionIMGroup, error) { + sysOrigin := normalizeSysOrigin(req.SysOrigin) + regionCode := normalizeRegionCode(req.RegionCode) + if regionCode == "" { + return model.VoiceRoomRegionIMGroup{}, NewAppError(http.StatusBadRequest, "missing_region_code", "regionCode is required") + } + groupID := strings.TrimSpace(req.GroupID) + if groupID == "" { + groupID = defaultRegionIMGroupID(sysOrigin, regionCode) + } + groupName := strings.TrimSpace(req.GroupName) + if groupName == "" { + groupName = defaultRegionIMGroupName(sysOrigin, regionCode, req.RegionName) + } + id, err := utils.NextID() + if err != nil { + return model.VoiceRoomRegionIMGroup{}, err + } + now := time.Now() + return model.VoiceRoomRegionIMGroup{ + ID: id, + SysOrigin: sysOrigin, + RegionCode: regionCode, + RegionName: strings.TrimSpace(req.RegionName), + GroupID: groupID, + GroupName: groupName, + Status: status, + LastError: trimErrorMessage(lastError), + CreateTime: now, + UpdateTime: now, + }, nil +} + +func (s *Service) upsertRegionIMGroup(ctx context.Context, row model.VoiceRoomRegionIMGroup) error { + now := time.Now() + row.UpdateTime = now + if row.CreateTime.IsZero() { + row.CreateTime = now + } + return s.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "sys_origin"}, + {Name: "region_code"}, + }, + DoUpdates: clause.Assignments(map[string]any{ + "region_name": row.RegionName, + "group_id": row.GroupID, + "group_name": row.GroupName, + "status": row.Status, + "last_error": row.LastError, + "update_time": row.UpdateTime, + }), + }).Create(&row).Error +} + +func (s *Service) loadRegionIMGroupResponse(ctx context.Context, sysOrigin string, regionCode string) (*RegionIMGroupResponse, error) { + row, err := s.loadRegionIMGroup(ctx, sysOrigin, regionCode) + if err != nil { + return nil, err + } + resp := regionIMGroupResponse(*row) + return &resp, nil +} + +func (s *Service) loadRegionIMGroup(ctx context.Context, sysOrigin string, regionCode string) (*model.VoiceRoomRegionIMGroup, error) { + var row model.VoiceRoomRegionIMGroup + err := s.db.WithContext(ctx). + Where("sys_origin = ? AND region_code = ?", normalizeSysOrigin(sysOrigin), normalizeRegionCode(regionCode)). + First(&row).Error + if err != nil { + return nil, err + } + return &row, nil +} + +func (s *Service) loadActiveRegionIMGroup(ctx context.Context, sysOrigin string, regionCode string) (*model.VoiceRoomRegionIMGroup, error) { + var row model.VoiceRoomRegionIMGroup + err := s.db.WithContext(ctx). + Where("sys_origin = ? AND region_code = ? AND status = ? AND group_id <> ?", normalizeSysOrigin(sysOrigin), normalizeRegionCode(regionCode), imGroupStatusActive, ""). + First(&row).Error + if err != nil { + return nil, err + } + return &row, nil +} + +func regionIMGroupResponse(row model.VoiceRoomRegionIMGroup) RegionIMGroupResponse { + return RegionIMGroupResponse{ + ID: row.ID, + SysOrigin: row.SysOrigin, + RegionCode: row.RegionCode, + RegionName: row.RegionName, + GroupID: row.GroupID, + GroupName: row.GroupName, + Status: row.Status, + LastError: row.LastError, + CreateTime: formatTime(row.CreateTime), + UpdateTime: formatTime(row.UpdateTime), + Configured: row.Status == imGroupStatusActive && strings.TrimSpace(row.GroupID) != "", + } +} + +func normalizeRegionCode(value string) string { + return strings.ToUpper(strings.TrimSpace(value)) +} + +func defaultRegionIMGroupID(sysOrigin string, regionCode string) string { + normalizedSysOrigin := normalizeSysOrigin(sysOrigin) + normalizedRegionCode := normalizeRegionCode(regionCode) + groupSysOrigin := sanitizeIMGroupIDPart(normalizedSysOrigin) + if groupSysOrigin == "" { + groupSysOrigin = "SYS" + } + groupRegionCode := sanitizeIMGroupIDSegment(normalizedRegionCode) + if groupRegionCode == "" { + groupRegionCode = "REGION" + } + raw := "VRRP_" + groupSysOrigin + "_" + groupRegionCode + if groupRegionCode != normalizedRegionCode { + raw += "_" + shortRegionHash(normalizedSysOrigin, normalizedRegionCode) + } + if len(raw) <= 48 { + return raw + } + return raw[:39] + "_" + shortRegionHash(normalizedSysOrigin, normalizedRegionCode) +} + +func shortRegionHash(sysOrigin string, regionCode string) string { + sum := sha1.Sum([]byte(normalizeSysOrigin(sysOrigin) + "|" + normalizeRegionCode(regionCode))) + return strings.ToUpper(hex.EncodeToString(sum[:])[:8]) +} + +func sanitizeIMGroupIDSegment(value string) string { + value = strings.ToUpper(strings.TrimSpace(value)) + var builder strings.Builder + for _, r := range value { + if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' { + builder.WriteRune(r) + continue + } + builder.WriteByte('_') + } + return strings.Trim(builder.String(), "_-") +} + +func sanitizeIMGroupIDPart(value string) string { + result := sanitizeIMGroupIDSegment(value) + if result == "" { + return "VRRP_GROUP" + } + return result +} + +func defaultRegionIMGroupName(sysOrigin string, regionCode string, regionName string) string { + name := strings.TrimSpace(regionName) + if name == "" { + name = normalizeRegionCode(regionCode) + } + return fmt.Sprintf("region-im-group-%s-%s", normalizeSysOrigin(sysOrigin), name) +} + +func mapTencentIMError(err error) *AppError { + if errors.Is(err, tencentim.ErrNotConfigured) { + return NewAppError(http.StatusServiceUnavailable, "tencent_im_not_configured", "Tencent IM config is missing") + } + return NewAppError(http.StatusBadGateway, "tencent_im_request_failed", err.Error()) +} diff --git a/internal/service/regionimgroup/service_test.go b/internal/service/regionimgroup/service_test.go new file mode 100644 index 0000000..d8c4e6f --- /dev/null +++ b/internal/service/regionimgroup/service_test.go @@ -0,0 +1,193 @@ +package regionimgroup + +import ( + "context" + "strings" + "testing" + "time" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestCreateRegionIMGroupSavesActiveGroup(t *testing.T) { + service, db := newRegionIMGroupTestService(t) + fakeIM := &fakeIMGateway{} + service.im = fakeIM + + resp, err := service.CreateRegionIMGroup(context.Background(), RegionIMGroupRequest{ + SysOrigin: "LIKEI", + RegionCode: "AR", + RegionName: "Arab", + }) + if err != nil { + t.Fatalf("CreateRegionIMGroup() error = %v", err) + } + if resp.Status != imGroupStatusActive || resp.GroupID != defaultRegionIMGroupID("LIKEI", "AR") { + t.Fatalf("CreateRegionIMGroup() response = %+v", resp) + } + if len(fakeIM.created) != 1 || fakeIM.created[0].groupID != resp.GroupID { + t.Fatalf("created groups = %+v, want one %s", fakeIM.created, resp.GroupID) + } + var row model.VoiceRoomRegionIMGroup + if err := db.Where("sys_origin = ? AND region_code = ?", "LIKEI", "AR").First(&row).Error; err != nil { + t.Fatalf("load im group: %v", err) + } + if row.Status != imGroupStatusActive || row.GroupID != resp.GroupID { + t.Fatalf("stored im group = %+v", row) + } +} + +func TestDefaultRegionIMGroupIDKeepsASCIIRegionReadable(t *testing.T) { + got := defaultRegionIMGroupID("LIKEI", "IR") + if got != "VRRP_LIKEI_IR" { + t.Fatalf("defaultRegionIMGroupID() = %s, want VRRP_LIKEI_IR", got) + } +} + +func TestDefaultRegionIMGroupIDKeepsNonASCIIRegionsDistinct(t *testing.T) { + middleEast := defaultRegionIMGroupID("LIKEI", "\u4e2d\u4e1c") + southAsia := defaultRegionIMGroupID("LIKEI", "\u5357\u4e9a\u533a\u57df") + if middleEast == southAsia { + t.Fatalf("non-ascii regions share group id %s", middleEast) + } + if middleEast == "VRRP_LIKEI" || southAsia == "VRRP_LIKEI" { + t.Fatalf("non-ascii region group ids must not collapse: %s %s", middleEast, southAsia) + } + if !strings.HasPrefix(middleEast, "VRRP_LIKEI_REGION_") || + !strings.HasPrefix(southAsia, "VRRP_LIKEI_REGION_") { + t.Fatalf("group ids = %s %s, want hashed region suffix", middleEast, southAsia) + } +} + +func TestDefaultRegionIMGroupNameIsGeneric(t *testing.T) { + got := defaultRegionIMGroupName("LIKEI", "IR", "Iran") + if got != "region-im-group-LIKEI-Iran" { + t.Fatalf("defaultRegionIMGroupName() = %s, want generic region name", got) + } + if strings.Contains(got, "red-packet") { + t.Fatalf("defaultRegionIMGroupName() contains red-packet: %s", got) + } +} + +func TestSendRegionBroadcastResolvesRegionAndEnrichesGiftPayload(t *testing.T) { + service, db := newRegionIMGroupTestService(t) + fakeIM := &fakeIMGateway{} + service.im = fakeIM + ctx := context.Background() + now := time.Now() + if err := db.Create(&model.VoiceRoomRegionIMGroup{ + ID: 3002, + SysOrigin: "LIKEI", + RegionCode: "AR", + RegionName: "Arab", + GroupID: "@region-ar", + GroupName: "region-ar", + Status: imGroupStatusActive, + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed im group: %v", err) + } + + resp, err := service.SendRegionBroadcast(ctx, RegionBroadcastRequest{ + SysOrigin: "LIKEI", + SenderUserID: flexibleInt64(1001), + Type: "SEND_GIFT", + Data: map[string]any{ + "roomId": "9001", + "giftId": "5001", + "giftQuantity": 2, + "acceptUserIds": []any{"1002"}, + }, + }) + if err != nil { + t.Fatalf("SendRegionBroadcast() error = %v", err) + } + if resp.RegionCode != "AR" || resp.GroupID != "@region-ar" || resp.Type != "SEND_GIFT" { + t.Fatalf("SendRegionBroadcast() response = %+v", resp) + } + if len(fakeIM.sent) != 1 { + t.Fatalf("sent messages = %+v, want one region message", fakeIM.sent) + } + if fakeIM.sent[0].groupID != "@region-ar" { + t.Fatalf("sent group = %s, want @region-ar", fakeIM.sent[0].groupID) + } + body, ok := fakeIM.sent[0].body.(map[string]any) + if !ok { + t.Fatalf("sent body type = %T", fakeIM.sent[0].body) + } + if body["type"] != "SEND_GIFT" { + t.Fatalf("sent type = %v, want SEND_GIFT", body["type"]) + } + data, ok := body["data"].(map[string]any) + if !ok { + t.Fatalf("sent data type = %T", body["data"]) + } + if data["regionCode"] != "AR" || data["actualAccount"] != "1001" || + data["userNickname"] != "sender" || data["acceptNickname"] != "claimer" { + t.Fatalf("sent data = %+v", data) + } +} + +func newRegionIMGroupTestService(t *testing.T) (*Service, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate( + &model.UserBaseInfo{}, + &model.VoiceRoomRegionCountry{}, + &model.VoiceRoomRegionIMGroup{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + now := time.Now() + if err := db.Create(&[]model.UserBaseInfo{ + {ID: 1001, Account: "1001", UserNickname: "sender", UserAvatar: "sender.png", OriginSys: "LIKEI", CountryCode: "SA", CountryName: "Saudi Arabia", CreateTime: now}, + {ID: 1002, Account: "1002", UserNickname: "claimer", UserAvatar: "claimer.png", OriginSys: "LIKEI", CountryCode: "SA", CountryName: "Saudi Arabia", CreateTime: now}, + }).Error; err != nil { + t.Fatalf("seed users: %v", err) + } + if err := db.Create(&model.VoiceRoomRegionCountry{ + ID: 1, + SysOrigin: "LIKEI", + RegionCode: "AR", + CountryCode: "SA", + Enabled: true, + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed region: %v", err) + } + return NewService(config.Config{}, db), db +} + +type fakeIMCreateRecord struct { + groupID string + groupName string +} + +type fakeIMSendRecord struct { + groupID string + body any +} + +type fakeIMGateway struct { + created []fakeIMCreateRecord + sent []fakeIMSendRecord +} + +func (g *fakeIMGateway) CreateAVChatRoom(_ context.Context, groupID string, groupName string) (string, error) { + g.created = append(g.created, fakeIMCreateRecord{groupID: groupID, groupName: groupName}) + return groupID, nil +} + +func (g *fakeIMGateway) SendCustomGroupMessage(_ context.Context, groupID string, body any) error { + g.sent = append(g.sent, fakeIMSendRecord{groupID: groupID, body: body}) + return nil +} diff --git a/internal/service/regionimgroup/types.go b/internal/service/regionimgroup/types.go new file mode 100644 index 0000000..22b83f3 --- /dev/null +++ b/internal/service/regionimgroup/types.go @@ -0,0 +1,140 @@ +package regionimgroup + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/service/tencentim" + + "gorm.io/gorm" +) + +const ( + imGroupStatusPending = "PENDING" + imGroupStatusActive = "ACTIVE" + imGroupStatusFailed = "FAILED" + + defaultSysOrigin = "LIKEI" +) + +type AppError = common.AppError +type AuthUser = common.AuthUser + +var NewAppError = common.NewAppError + +type dbHandle interface { + WithContext(ctx context.Context) *gorm.DB +} + +type imGateway interface { + CreateAVChatRoom(ctx context.Context, groupID string, groupName string) (string, error) + SendCustomGroupMessage(ctx context.Context, groupID string, body any) error +} + +// Service 负责区域通用 IM 群组创建、查询和区域广播。 +type Service struct { + cfg config.Config + db dbHandle + im imGateway +} + +func NewService(cfg config.Config, db dbHandle) *Service { + return &Service{ + cfg: cfg, + db: db, + im: tencentim.NewClient(cfg.TencentIM), + } +} + +type RegionIMGroupRequest struct { + SysOrigin string `json:"sysOrigin"` + RegionCode string `json:"regionCode"` + RegionName string `json:"regionName"` + GroupID string `json:"groupId"` + GroupName string `json:"groupName"` +} + +type RegionIMGroupResponse struct { + ID int64 `json:"id,string"` + SysOrigin string `json:"sysOrigin"` + RegionCode string `json:"regionCode"` + RegionName string `json:"regionName"` + GroupID string `json:"groupId"` + GroupName string `json:"groupName"` + Status string `json:"status"` + LastError string `json:"lastError"` + CreateTime string `json:"createTime,omitempty"` + UpdateTime string `json:"updateTime,omitempty"` + Configured bool `json:"configured"` +} + +type RegionBroadcastRequest struct { + SysOrigin string `json:"sysOrigin"` + RegionCode string `json:"regionCode"` + SenderUserID flexibleInt64 `json:"senderUserId"` + Type string `json:"type"` + Data map[string]any `json:"data"` +} + +type RegionBroadcastResponse struct { + SysOrigin string `json:"sysOrigin"` + RegionCode string `json:"regionCode"` + GroupID string `json:"groupId"` + Type string `json:"type"` +} + +type flexibleInt64 int64 + +func (v *flexibleInt64) UnmarshalJSON(data []byte) error { + raw := strings.TrimSpace(string(data)) + switch raw { + case "", "null", `""`: + *v = 0 + return nil + } + if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) { + unquoted, err := strconv.Unquote(raw) + if err != nil { + return err + } + raw = strings.TrimSpace(unquoted) + } + parsed, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return fmt.Errorf("invalid int64 value: %w", err) + } + *v = flexibleInt64(parsed) + return nil +} + +func (v flexibleInt64) Int64() int64 { + return int64(v) +} + +func normalizeSysOrigin(sysOrigin string) string { + sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin)) + if sysOrigin == "" { + return defaultSysOrigin + } + return sysOrigin +} + +func formatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(time.RFC3339) +} + +func trimErrorMessage(message string) string { + message = strings.TrimSpace(message) + if len(message) > 1024 { + return message[:1024] + } + return message +} diff --git a/internal/service/taskcenter/admin.go b/internal/service/taskcenter/admin.go new file mode 100644 index 0000000..c69b679 --- /dev/null +++ b/internal/service/taskcenter/admin.go @@ -0,0 +1,438 @@ +package taskcenter + +import ( + "context" + "errors" + "net/http" + "sort" + "strings" + "time" + + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + + "gorm.io/gorm" +) + +// GetConfig 返回后台任务配置。 +func (s *Service) GetConfig(ctx context.Context, sysOrigin string, taskCategory string) (*ConfigResponse, error) { + sysOrigin = s.normalizeSysOrigin(sysOrigin) + category := normalizeCategory(taskCategory) + if err := validateCategory(category); err != nil { + return nil, err + } + + var configRow model.TaskCenterConfig + err := s.db.WithContext(ctx). + Where("sys_origin = ?", sysOrigin). + First(&configRow).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return &ConfigResponse{ + Configured: false, + SysOrigin: sysOrigin, + Enabled: false, + Timezone: s.defaultTimezone(), + TaskCategory: category, + Tasks: []TaskConfigPayload{}, + }, nil + } + if err != nil { + return nil, err + } + + tasks, err := s.loadTaskConfigs(ctx, configRow.ID, category, false) + 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), + }, nil +} + +// SaveConfig 保存后台任务配置。后台每日任务页支持 DAILY 和一次性的 NEWBIE 专属任务。 +func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*ConfigResponse, error) { + requestID := req.ID.Int64() + sysOrigin := s.normalizeSysOrigin(req.SysOrigin) + category := normalizeCategory(req.TaskCategory) + if err := validateCategory(category); err != nil { + return nil, err + } + timezone := normalizeTimezone(req.Timezone, s.defaultTimezone()) + if _, err := time.LoadLocation(timezone); err != nil { + return nil, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error()) + } + + tasks, err := s.normalizeTaskInputs(req.Tasks, category) + if err != nil { + return nil, err + } + if req.Enabled && !hasEnabledTask(tasks) { + return nil, NewAppError(http.StatusBadRequest, "invalid_tasks", "enabled config must contain at least one enabled task") + } + + var savedConfigID int64 + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + now := time.Now() + var configRow model.TaskCenterConfig + if requestID > 0 { + err := tx.Where("id = ?", requestID).First(&configRow).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return NewAppError(http.StatusNotFound, "task_center_config_not_found", "config not found") + } + if err != nil { + return err + } + if strings.TrimSpace(req.SysOrigin) == "" { + sysOrigin = configRow.SysOrigin + } + } else { + err := tx.Where("sys_origin = ?", sysOrigin).First(&configRow).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + configID, idErr := utils.NextID() + if idErr != nil { + return idErr + } + configRow = model.TaskCenterConfig{ + ID: configID, + SysOrigin: sysOrigin, + CreateTime: now, + } + } else if err != nil { + return err + } + } + + configRow.SysOrigin = sysOrigin + configRow.Enabled = req.Enabled + configRow.Timezone = timezone + configRow.UpdateTime = now + if configRow.CreateTime.IsZero() { + configRow.CreateTime = now + } + if err := tx.Save(&configRow).Error; err != nil { + return err + } + + keepIDs := make([]int64, 0, len(tasks)) + for index := range tasks { + if tasks[index].ID > 0 { + var existing model.TaskCenterTaskConfig + err := tx.Where("id = ? AND config_id = ? AND task_category = ?", tasks[index].ID, configRow.ID, category). + First(&existing).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return NewAppError(http.StatusNotFound, "task_config_not_found", "task config not found") + } + if err != nil { + return err + } + tasks[index].CreateTime = existing.CreateTime + } else { + taskID, idErr := utils.NextID() + if idErr != nil { + return idErr + } + tasks[index].ID = taskID + tasks[index].CreateTime = now + } + tasks[index].ConfigID = configRow.ID + tasks[index].SysOrigin = sysOrigin + tasks[index].TaskCategory = category + tasks[index].UpdateTime = now + if tasks[index].CreateTime.IsZero() { + tasks[index].CreateTime = now + } + if err := tx.Save(&tasks[index]).Error; err != nil { + return err + } + keepIDs = append(keepIDs, tasks[index].ID) + } + + deleteQuery := tx.Where("config_id = ? AND task_category = ?", configRow.ID, category) + if len(keepIDs) > 0 { + deleteQuery = deleteQuery.Where("id NOT IN ?", keepIDs) + } + if err := deleteQuery.Delete(&model.TaskCenterTaskConfig{}).Error; err != nil { + return err + } + savedConfigID = configRow.ID + return nil + }); err != nil { + return nil, err + } + + return s.GetConfig(ctx, taskCenterSysOriginBySavedID(ctx, s.db, savedConfigID, sysOrigin), category) +} + +// PageClaimRecords 分页查询领取记录。 +func (s *Service) PageClaimRecords(ctx context.Context, sysOrigin string, taskCategory string, userID int64, taskCode string, status string, cursor int, limit int) (*PageResponse[ClaimRecordView], error) { + sysOrigin = s.normalizeSysOrigin(sysOrigin) + category := normalizeCategory(taskCategory) + if err := validateCategory(category); err != nil { + return nil, err + } + cursor, limit = NormalizePage(cursor, limit) + + query := s.db.WithContext(ctx).Model(&model.TaskCenterClaimRecord{}). + Where("sys_origin = ? AND task_category = ?", sysOrigin, category) + if userID > 0 { + query = query.Where("user_id = ?", userID) + } + if code := strings.ToUpper(strings.TrimSpace(taskCode)); code != "" { + query = query.Where("task_code = ?", code) + } + if normalizedStatus := strings.ToUpper(strings.TrimSpace(status)); normalizedStatus != "" { + query = query.Where("status = ?", normalizedStatus) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, err + } + var rows []model.TaskCenterClaimRecord + if err := query.Order("create_time desc, id desc"). + Offset((cursor - 1) * limit). + Limit(limit). + Find(&rows).Error; err != nil { + return nil, err + } + records := make([]ClaimRecordView, 0, len(rows)) + for _, row := range rows { + records = append(records, claimRecordView(row)) + } + return &PageResponse[ClaimRecordView]{Records: records, Total: total, Current: cursor, Size: limit}, nil +} + +// PageEvents 分页查询事件流水。 +func (s *Service) PageEvents(ctx context.Context, sysOrigin string, taskCategory string, userID int64, taskCode string, eventType string, cursor int, limit int) (*PageResponse[EventView], error) { + sysOrigin = s.normalizeSysOrigin(sysOrigin) + category := normalizeCategory(taskCategory) + if err := validateCategory(category); err != nil { + return nil, err + } + cursor, limit = NormalizePage(cursor, limit) + + query := s.db.WithContext(ctx).Model(&model.TaskCenterEvent{}). + Where("sys_origin = ? AND task_category = ?", sysOrigin, category) + if userID > 0 { + query = query.Where("user_id = ?", userID) + } + if code := strings.ToUpper(strings.TrimSpace(taskCode)); code != "" { + query = query.Where("task_code = ?", code) + } + if normalizedEventType := strings.ToUpper(strings.TrimSpace(eventType)); normalizedEventType != "" { + query = query.Where("event_type = ?", normalizedEventType) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, err + } + var rows []model.TaskCenterEvent + if err := query.Order("create_time desc, id desc"). + Offset((cursor - 1) * limit). + Limit(limit). + Find(&rows).Error; err != nil { + return nil, err + } + records := make([]EventView, 0, len(rows)) + for _, row := range rows { + records = append(records, eventView(row)) + } + return &PageResponse[EventView]{Records: records, Total: total, Current: cursor, Size: limit}, nil +} + +func (s *Service) normalizeTaskInputs(inputs []SaveTaskConfigInput, category string) ([]model.TaskCenterTaskConfig, error) { + if len(inputs) == 0 { + return nil, NewAppError(http.StatusBadRequest, "invalid_tasks", "tasks is required") + } + + seenCodes := make(map[string]struct{}, len(inputs)) + rows := make([]model.TaskCenterTaskConfig, 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, NewAppError(http.StatusBadRequest, "invalid_task_category", "taskCategory does not match request category") + } + taskCode := strings.ToUpper(strings.TrimSpace(item.TaskCode)) + if taskCode == "" { + return nil, NewAppError(http.StatusBadRequest, "invalid_task_code", "taskCode is required") + } + if _, exists := seenCodes[taskCode]; exists { + return nil, NewAppError(http.StatusBadRequest, "duplicate_task_code", "taskCode must not duplicate") + } + seenCodes[taskCode] = struct{}{} + + taskName := strings.TrimSpace(item.TaskName) + if taskName == "" { + return nil, NewAppError(http.StatusBadRequest, "invalid_task_name", "taskName is required") + } + conditionType := strings.ToUpper(strings.TrimSpace(item.ConditionType)) + if conditionType == "" { + return nil, NewAppError(http.StatusBadRequest, "invalid_condition_type", "conditionType is required") + } + if !isAllowedConditionType(conditionType) { + return nil, NewAppError(http.StatusBadRequest, "invalid_condition_type", "conditionType is not supported") + } + if item.TargetValue <= 0 { + return nil, NewAppError(http.StatusBadRequest, "invalid_target_value", "targetValue must be greater than 0") + } + if item.RewardGold <= 0 { + return nil, NewAppError(http.StatusBadRequest, "invalid_reward_gold", "rewardGold must be greater than 0") + } + rows = append(rows, model.TaskCenterTaskConfig{ + ID: item.ID.Int64(), + TaskCode: taskCode, + TaskCategory: category, + Enabled: item.Enabled, + TaskName: taskName, + TaskDesc: strings.TrimSpace(item.TaskDesc), + ConditionType: conditionType, + TargetValue: item.TargetValue, + TargetUnit: strings.TrimSpace(item.TargetUnit), + RewardGold: item.RewardGold, + JumpType: normalizeJumpType(item.JumpType), + JumpPage: strings.TrimSpace(item.JumpPage), + TaskIcon: strings.TrimSpace(item.TaskIcon), + Cover: strings.TrimSpace(item.Cover), + SortOrder: item.SortOrder, + }) + } + + sort.SliceStable(rows, func(i, j int) bool { + if rows[i].SortOrder == rows[j].SortOrder { + return rows[i].TaskCode < rows[j].TaskCode + } + return rows[i].SortOrder < rows[j].SortOrder + }) + return rows, nil +} + +func (s *Service) loadTaskConfigs(ctx context.Context, configID int64, category string, enabledOnly bool) ([]model.TaskCenterTaskConfig, error) { + if configID <= 0 { + return []model.TaskCenterTaskConfig{}, nil + } + query := s.db.WithContext(ctx). + Where("config_id = ? AND task_category = ?", configID, normalizeCategory(category)) + if enabledOnly { + query = query.Where("enabled = ?", true) + } + var rows []model.TaskCenterTaskConfig + if err := query.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 { + result = append(result, TaskConfigPayload{ + ID: row.ID, + TaskCode: row.TaskCode, + TaskCategory: row.TaskCategory, + Enabled: row.Enabled, + TaskName: row.TaskName, + TaskDesc: row.TaskDesc, + ConditionType: row.ConditionType, + TargetValue: row.TargetValue, + TargetUnit: row.TargetUnit, + RewardGold: row.RewardGold, + JumpType: row.JumpType, + JumpPage: row.JumpPage, + TaskIcon: row.TaskIcon, + Cover: row.Cover, + SortOrder: row.SortOrder, + }) + } + return result +} + +func claimRecordView(row model.TaskCenterClaimRecord) ClaimRecordView { + return ClaimRecordView{ + ID: row.ID, + SysOrigin: row.SysOrigin, + UserID: row.UserID, + TaskID: row.TaskID, + TaskCode: row.TaskCode, + TaskName: row.TaskName, + TaskCategory: row.TaskCategory, + CycleKey: row.CycleKey, + RewardGold: row.RewardGold, + WalletEventID: row.WalletEventID, + Status: row.Status, + FailureReason: row.FailureReason, + ClaimedAt: formatDateTimePtr(row.ClaimedAt), + CreateTime: formatDateTime(row.CreateTime), + UpdateTime: formatDateTime(row.UpdateTime), + } +} + +func eventView(row model.TaskCenterEvent) EventView { + return EventView{ + ID: row.ID, + SysOrigin: row.SysOrigin, + EventID: row.EventID, + EventType: row.EventType, + UserID: row.UserID, + TaskID: row.TaskID, + TaskCode: row.TaskCode, + TaskCategory: row.TaskCategory, + CycleKey: row.CycleKey, + DeltaValue: row.DeltaValue, + CompletedValue: row.CompletedValue, + OccurredAt: formatDateTime(row.OccurredAt), + CreateTime: formatDateTime(row.CreateTime), + UpdateTime: formatDateTime(row.UpdateTime), + } +} + +func taskCenterSysOriginBySavedID(ctx context.Context, db taskCenterDB, configID int64, fallback string) string { + if configID <= 0 { + return fallback + } + var row model.TaskCenterConfig + if err := db.WithContext(ctx).Where("id = ?", configID).First(&row).Error; err == nil && strings.TrimSpace(row.SysOrigin) != "" { + return row.SysOrigin + } + return fallback +} + +func hasEnabledTask(tasks []model.TaskCenterTaskConfig) bool { + for _, task := range tasks { + if task.Enabled { + return true + } + } + return false +} + +func normalizeTimezone(value string, fallback string) string { + timezone := strings.TrimSpace(value) + if timezone == "" { + timezone = strings.TrimSpace(fallback) + } + if timezone == "" { + timezone = defaultTaskCenterTimezone + } + return timezone +} + +func normalizeJumpType(value string) string { + jumpType := strings.ToUpper(strings.TrimSpace(value)) + if jumpType == "" { + jumpType = "NONE" + } + return jumpType +} diff --git a/internal/service/taskcenter/app.go b/internal/service/taskcenter/app.go new file mode 100644 index 0000000..7cf12e3 --- /dev/null +++ b/internal/service/taskcenter/app.go @@ -0,0 +1,322 @@ +package taskcenter + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + + "gorm.io/gorm" +) + +// ListTasks 返回 App 任务列表,字段兼容旧 Flutter 模型。 +func (s *Service) ListTasks(ctx context.Context, user AuthUser) ([]AppTaskItem, error) { + sysOrigin := s.normalizeSysOrigin(user.SysOrigin) + bundle, err := s.loadEnabledBundle(ctx, sysOrigin) + if err != nil { + return nil, err + } + if bundle == nil { + return []AppTaskItem{}, nil + } + + result := make([]AppTaskItem, 0, len(bundle.Tasks)) + for _, task := range bundle.Tasks { + cycleKey := currentCycleKey(task.TaskCategory, bundle.Location, time.Now()) + progress, exists, err := s.loadProgress(ctx, sysOrigin, user.UserID, task.ID, cycleKey) + if err != nil { + return nil, err + } + if task.TaskCategory == TaskCategoryNewbie && exists && progress.RewardCollected { + continue + } + + completedValue := int64(0) + rewardCollected := 0 + if exists { + completedValue = progress.CompletedValue + if progress.RewardCollected { + rewardCollected = 1 + } + } + taskStatus := 0 + if completedValue >= task.TargetValue { + taskStatus = 1 + } + result = append(result, AppTaskItem{ + TaskType: appTaskType(task.TaskCategory), + TaskName: task.TaskName, + TaskDesc: task.TaskDesc, + JumpPage: resolveJumpPage(task), + ConditionType: task.ConditionType, + SortOrder: task.SortOrder, + TaskID: task.ID, + TaskStatus: taskStatus, + Cover: task.Cover, + TaskIcon: task.TaskIcon, + ConditionValue: task.ConditionType, + CompletedValue: completedValue, + IsRewardCollected: rewardCollected, + TargetValue: task.TargetValue, + Quantity: task.RewardGold, + }) + } + return result, nil +} + +// ClaimableCount 返回可领取任务数量。 +func (s *Service) ClaimableCount(ctx context.Context, user AuthUser) (*ClaimableCountResponse, error) { + tasks, err := s.ListTasks(ctx, user) + if err != nil { + return nil, err + } + count := 0 + for _, task := range tasks { + if task.TaskStatus == 1 && task.IsRewardCollected == 0 { + count++ + } + } + return &ClaimableCountResponse{Count: count}, nil +} + +// Claim 领取任务奖励。 +func (s *Service) Claim(ctx context.Context, user AuthUser, taskID int64) (bool, error) { + if taskID <= 0 { + return false, NewAppError(http.StatusBadRequest, "invalid_task_id", "taskId is required") + } + sysOrigin := s.normalizeSysOrigin(user.SysOrigin) + bundle, err := s.loadEnabledBundle(ctx, sysOrigin) + if err != nil { + return false, err + } + if bundle == nil { + return false, NewAppError(http.StatusNotFound, "task_center_not_configured", "task center is not configured") + } + + var task model.TaskCenterTaskConfig + found := false + for _, candidate := range bundle.Tasks { + if candidate.ID == taskID { + task = candidate + found = true + break + } + } + if !found { + return false, NewAppError(http.StatusNotFound, "task_not_found", "task not found") + } + cycleKey := currentCycleKey(task.TaskCategory, bundle.Location, time.Now()) + progress, exists, err := s.loadProgress(ctx, sysOrigin, user.UserID, task.ID, cycleKey) + if err != nil { + return false, err + } + if !exists || progress.CompletedValue < task.TargetValue { + return false, NewAppError(http.StatusBadRequest, "task_not_completed", "task is not completed") + } + if progress.RewardCollected { + return true, nil + } + + walletEventID := buildWalletEventID(sysOrigin, user.UserID, task.TaskCode, cycleKey) + if err := s.dispatchClaimGold(ctx, sysOrigin, user.UserID, task, walletEventID); err != nil { + _ = s.saveClaimRecord(ctx, task, progress, walletEventID, ClaimStatusFailed, err.Error(), nil) + return false, err + } + + now := time.Now() + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := s.saveClaimRecordTx(tx, task, progress, walletEventID, ClaimStatusSuccess, "", &now); err != nil { + return err + } + return tx.Model(&model.TaskCenterUserProgress{}). + Where("id = ?", progress.ID). + Updates(map[string]any{ + "status": TaskStatusClaimed, + "reward_collected": true, + "claimed_at": now, + "update_time": now, + }).Error + }); err != nil { + return false, err + } + return true, nil +} + +func (s *Service) dispatchClaimGold(ctx context.Context, sysOrigin string, userID int64, task model.TaskCenterTaskConfig, walletEventID string) error { + if task.RewardGold <= 0 { + return nil + } + if s.java == nil { + return NewAppError(http.StatusBadGateway, "wallet_gateway_missing", "wallet gateway is not configured") + } + exists, err := s.java.ExistsGoldEvent(ctx, walletEventID) + if err != nil { + return NewAppError(http.StatusBadGateway, "wallet_event_check_failed", err.Error()) + } + if exists { + 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", + }); err != nil { + return NewAppError(http.StatusBadGateway, "wallet_change_failed", err.Error()) + } + return nil +} + +func (s *Service) saveClaimRecord(ctx context.Context, task model.TaskCenterTaskConfig, progress model.TaskCenterUserProgress, walletEventID string, status string, failureReason string, claimedAt *time.Time) error { + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return s.saveClaimRecordTx(tx, task, progress, walletEventID, status, failureReason, claimedAt) + }) +} + +func (s *Service) saveClaimRecordTx(tx *gorm.DB, task model.TaskCenterTaskConfig, progress model.TaskCenterUserProgress, walletEventID string, status string, failureReason string, claimedAt *time.Time) error { + now := time.Now() + var row model.TaskCenterClaimRecord + err := tx.Where("wallet_event_id = ?", walletEventID).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + id, idErr := utils.NextID() + if idErr != nil { + return idErr + } + row = model.TaskCenterClaimRecord{ + ID: id, + SysOrigin: progress.SysOrigin, + UserID: progress.UserID, + TaskID: task.ID, + TaskCode: task.TaskCode, + TaskName: task.TaskName, + TaskCategory: task.TaskCategory, + CycleKey: progress.CycleKey, + RewardGold: task.RewardGold, + WalletEventID: walletEventID, + CreateTime: now, + } + } else if err != nil { + return err + } + row.Status = status + row.FailureReason = strings.TrimSpace(failureReason) + row.ClaimedAt = claimedAt + row.UpdateTime = now + return tx.Save(&row).Error +} + +type taskCenterBundle struct { + Config model.TaskCenterConfig + Tasks []model.TaskCenterTaskConfig + Location *time.Location +} + +func (s *Service) loadEnabledBundle(ctx context.Context, sysOrigin string) (*taskCenterBundle, error) { + var configRow model.TaskCenterConfig + err := s.db.WithContext(ctx).Where("sys_origin = ?", sysOrigin).First(&configRow).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + if !configRow.Enabled { + return nil, nil + } + location := s.location + if parsed, parseErr := time.LoadLocation(normalizeTimezone(configRow.Timezone, s.defaultTimezone())); parseErr == nil { + location = parsed + } + + var tasks []model.TaskCenterTaskConfig + if err := s.db.WithContext(ctx). + Where("config_id = ? AND enabled = ?", configRow.ID, true). + Order("sort_order asc, id asc"). + Find(&tasks).Error; err != nil { + return nil, err + } + return &taskCenterBundle{Config: configRow, Tasks: tasks, Location: location}, nil +} + +func (s *Service) loadProgress(ctx context.Context, sysOrigin string, userID int64, taskID int64, cycleKey string) (model.TaskCenterUserProgress, bool, error) { + var row model.TaskCenterUserProgress + err := s.db.WithContext(ctx). + Where("sys_origin = ? AND user_id = ? AND task_id = ? AND cycle_key = ?", sysOrigin, userID, taskID, cycleKey). + First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.TaskCenterUserProgress{}, false, nil + } + if err != nil { + return model.TaskCenterUserProgress{}, false, err + } + return row, true, nil +} + +func appTaskType(category string) int { + if normalizeCategory(category) == TaskCategoryDaily { + return 0 + } + return 1 +} + +func resolveJumpPage(task model.TaskCenterTaskConfig) string { + jumpPage := strings.TrimSpace(task.JumpPage) + if jumpPage != "" { + return jumpPage + } + switch normalizeJumpType(task.JumpType) { + case "ROOM_RANDOM", "ROOM_RANDOM_WITH_MIC": + return "/room" + case "NONE": + return "" + default: + return jumpPage + } +} + +func buildWalletEventID(sysOrigin string, userID int64, taskCode string, cycleKey string) string { + normalizedSysOrigin := compactWalletEventPart(sysOrigin, 8) + if normalizedSysOrigin == "" { + normalizedSysOrigin = "SYS" + } + raw := strings.Join([]string{ + strings.ToUpper(strings.TrimSpace(sysOrigin)), + strconv.FormatInt(userID, 10), + strings.ToUpper(strings.TrimSpace(taskCode)), + strings.TrimSpace(cycleKey), + }, "|") + sum := sha1.Sum([]byte(raw)) + return fmt.Sprintf("TC:%s:%s:%s", normalizedSysOrigin, strconv.FormatInt(userID, 36), hex.EncodeToString(sum[:])[:16]) +} + +func compactWalletEventPart(value string, maxLen int) string { + value = strings.ToUpper(strings.TrimSpace(value)) + if maxLen <= 0 { + return "" + } + var builder strings.Builder + for _, char := range value { + if (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') { + builder.WriteRune(char) + if builder.Len() >= maxLen { + break + } + } + } + return builder.String() +} diff --git a/internal/service/taskcenter/event.go b/internal/service/taskcenter/event.go new file mode 100644 index 0000000..e2a9ff9 --- /dev/null +++ b/internal/service/taskcenter/event.go @@ -0,0 +1,201 @@ +package taskcenter + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + + "gorm.io/gorm" +) + +// ReportSimpleEvent 供 Go 内部业务模块上报任务进度事件。 +// 上报方只需要传标准 int64,内部再转换成兼容 HTTP 入参的 EventRequest。 +func (s *Service) ReportSimpleEvent(ctx context.Context, sysOrigin, eventID, eventType string, userID, deltaValue int64, payload map[string]any) error { + return s.ReportSimpleEventAt(ctx, sysOrigin, eventID, eventType, userID, deltaValue, time.Now(), payload) +} + +// ReportSimpleEventAt 和 ReportSimpleEvent 相同,但允许 MQ 等异步事件传入真实发生时间。 +func (s *Service) ReportSimpleEventAt(ctx context.Context, sysOrigin, eventID, eventType string, userID, deltaValue int64, occurredAt time.Time, payload map[string]any) error { + _, err := s.ReceiveEvent(ctx, EventRequest{ + SysOrigin: sysOrigin, + EventID: eventID, + EventType: eventType, + UserID: flexibleInt64(userID), + DeltaValue: deltaValue, + OccurredAt: occurredAt.Format(time.RFC3339Nano), + Payload: payload, + }) + if err != nil { + return fmt.Errorf("report task event %s failed: %w", eventID, err) + } + return nil +} + +// ReceiveEvent 接收后端业务事件并更新命中的任务进度。 +func (s *Service) ReceiveEvent(ctx context.Context, req EventRequest) (*EventResponse, error) { + sysOrigin := s.normalizeSysOrigin(req.SysOrigin) + eventID := strings.TrimSpace(req.EventID) + if eventID == "" { + return nil, NewAppError(http.StatusBadRequest, "invalid_event_id", "eventId is required") + } + eventType := strings.ToUpper(strings.TrimSpace(req.EventType)) + if eventType == "" { + return nil, NewAppError(http.StatusBadRequest, "invalid_event_type", "eventType is required") + } + if !isAllowedConditionType(eventType) { + return &EventResponse{Processed: false, Updated: 0}, nil + } + userID := req.UserID.Int64() + if userID <= 0 { + return nil, NewAppError(http.StatusBadRequest, "invalid_user_id", "userId is required") + } + if req.DeltaValue <= 0 { + return nil, NewAppError(http.StatusBadRequest, "invalid_delta_value", "deltaValue must be greater than 0") + } + + var existingCount int64 + if err := s.db.WithContext(ctx).Model(&model.TaskCenterEvent{}). + Where("sys_origin = ? AND event_id = ?", sysOrigin, eventID). + Count(&existingCount).Error; err != nil { + return nil, err + } + if existingCount > 0 { + return &EventResponse{Processed: false, Updated: 0}, nil + } + + bundle, err := s.loadEnabledBundle(ctx, sysOrigin) + if err != nil { + return nil, err + } + if bundle == nil { + return &EventResponse{Processed: false, Updated: 0}, nil + } + + matchedTasks := make([]model.TaskCenterTaskConfig, 0) + for _, task := range bundle.Tasks { + if strings.EqualFold(task.ConditionType, eventType) { + matchedTasks = append(matchedTasks, task) + } + } + if len(matchedTasks) == 0 { + return &EventResponse{Processed: false, Updated: 0}, nil + } + + occurredAt := parseOccurredAt(req.OccurredAt, time.Now()) + rawJSON := "" + if len(req.Payload) > 0 { + if bytes, marshalErr := json.Marshal(req.Payload); marshalErr == nil { + rawJSON = string(bytes) + } + } + + updated := 0 + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var duplicateCount int64 + if err := tx.Model(&model.TaskCenterEvent{}). + Where("sys_origin = ? AND event_id = ?", sysOrigin, eventID). + Count(&duplicateCount).Error; err != nil { + return err + } + if duplicateCount > 0 { + return nil + } + + for _, task := range matchedTasks { + cycleKey := currentCycleKey(task.TaskCategory, bundle.Location, occurredAt) + progress, err := s.loadOrCreateProgressTx(tx, sysOrigin, userID, task, cycleKey) + if err != nil { + return err + } + if progress.RewardCollected { + continue + } + + progress.CompletedValue += req.DeltaValue + if progress.CompletedValue >= progress.TargetValue { + progress.Status = TaskStatusCompleted + } else { + progress.Status = TaskStatusInProgress + } + progress.UpdateTime = time.Now() + if err := tx.Save(&progress).Error; err != nil { + return err + } + + eventRowID, idErr := utils.NextID() + if idErr != nil { + return idErr + } + eventRow := model.TaskCenterEvent{ + ID: eventRowID, + SysOrigin: sysOrigin, + EventID: eventID, + EventType: eventType, + UserID: userID, + TaskID: task.ID, + TaskCode: task.TaskCode, + TaskCategory: task.TaskCategory, + CycleKey: cycleKey, + DeltaValue: req.DeltaValue, + CompletedValue: progress.CompletedValue, + RawJSON: rawJSON, + OccurredAt: occurredAt, + CreateTime: time.Now(), + UpdateTime: time.Now(), + } + if err := tx.Create(&eventRow).Error; err != nil { + return err + } + updated++ + } + return nil + }); err != nil { + return nil, err + } + + return &EventResponse{Processed: updated > 0, Updated: updated}, nil +} + +func (s *Service) loadOrCreateProgressTx(tx *gorm.DB, sysOrigin string, userID int64, task model.TaskCenterTaskConfig, cycleKey string) (model.TaskCenterUserProgress, error) { + var progress model.TaskCenterUserProgress + err := tx.Where("sys_origin = ? AND user_id = ? AND task_id = ? AND cycle_key = ?", sysOrigin, userID, task.ID, cycleKey). + First(&progress).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + id, idErr := utils.NextID() + if idErr != nil { + return model.TaskCenterUserProgress{}, idErr + } + now := time.Now() + progress = model.TaskCenterUserProgress{ + ID: id, + SysOrigin: sysOrigin, + UserID: userID, + TaskID: task.ID, + TaskCode: task.TaskCode, + TaskCategory: task.TaskCategory, + CycleKey: cycleKey, + ConditionType: task.ConditionType, + CompletedValue: 0, + TargetValue: task.TargetValue, + Status: TaskStatusInProgress, + CreateTime: now, + UpdateTime: now, + } + return progress, nil + } + if err != nil { + return model.TaskCenterUserProgress{}, err + } + if progress.TargetValue != task.TargetValue || progress.ConditionType != task.ConditionType { + progress.TargetValue = task.TargetValue + progress.ConditionType = task.ConditionType + } + return progress, nil +} diff --git a/internal/service/taskcenter/taskcenter_test.go b/internal/service/taskcenter/taskcenter_test.go new file mode 100644 index 0000000..b3302b4 --- /dev/null +++ b/internal/service/taskcenter/taskcenter_test.go @@ -0,0 +1,234 @@ +package taskcenter + +import ( + "context" + "testing" + + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func newTestService(t *testing.T, java taskCenterJavaGateway) (*Service, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate( + &model.TaskCenterConfig{}, + &model.TaskCenterTaskConfig{}, + &model.TaskCenterUserProgress{}, + &model.TaskCenterEvent{}, + &model.TaskCenterClaimRecord{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + service := NewService(config.Config{ + TaskCenter: config.TaskCenterConfig{ + DefaultSysOrigin: "LIKEI", + Timezone: "Asia/Riyadh", + }, + }, db, java) + return service, db +} + +func TestDailyTaskConfigEventListAndClaim(t *testing.T) { + java := &taskCenterTestGateway{events: map[string]bool{}} + service, _ := newTestService(t, java) + ctx := context.Background() + + configResp, err := service.SaveConfig(ctx, SaveConfigRequest{ + SysOrigin: "likei", + Enabled: true, + Timezone: "Asia/Riyadh", + TaskCategory: TaskCategoryDaily, + Tasks: []SaveTaskConfigInput{ + { + TaskCode: "daily_mic_duration", + Enabled: true, + TaskName: "使用麦克风 %d 分钟。", + TaskDesc: "进度 / 目标", + ConditionType: "mic_duration_seconds", + TargetValue: 60, + TargetUnit: "second", + RewardGold: 1100, + JumpType: "ROOM_RANDOM_WITH_MIC", + SortOrder: 10, + }, + }, + }) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + if !configResp.Configured || !configResp.Enabled || len(configResp.Tasks) != 1 { + t.Fatalf("SaveConfig() resp = %+v", configResp) + } + + user := common.AuthUser{UserID: 1001, SysOrigin: "LIKEI"} + tasks, err := service.ListTasks(ctx, user) + if err != nil { + t.Fatalf("ListTasks() error = %v", err) + } + if len(tasks) != 1 || tasks[0].TaskStatus != 0 || tasks[0].CompletedValue != 0 { + t.Fatalf("initial tasks = %+v", tasks) + } + + eventResp, err := service.ReceiveEvent(ctx, EventRequest{ + SysOrigin: "LIKEI", + EventID: "event-1", + EventType: "MIC_DURATION_SECONDS", + UserID: flexibleInt64(user.UserID), + DeltaValue: 60, + }) + if err != nil { + t.Fatalf("ReceiveEvent() error = %v", err) + } + if !eventResp.Processed || eventResp.Updated != 1 { + t.Fatalf("ReceiveEvent() resp = %+v", eventResp) + } + + tasks, err = service.ListTasks(ctx, user) + if err != nil { + t.Fatalf("ListTasks() after event error = %v", err) + } + if len(tasks) != 1 || tasks[0].TaskStatus != 1 || tasks[0].CompletedValue != 60 { + t.Fatalf("completed tasks = %+v", tasks) + } + + ok, err := service.Claim(ctx, user, tasks[0].TaskID) + if err != nil { + t.Fatalf("Claim() error = %v", err) + } + if !ok { + t.Fatalf("Claim() = false, want true") + } + if len(java.changedEvents) != 1 { + t.Fatalf("wallet changes = %d, want 1", len(java.changedEvents)) + } + if len(java.changedEvents[0]) > 50 { + t.Fatalf("wallet event id length = %d, want <= 50: %s", len(java.changedEvents[0]), java.changedEvents[0]) + } + + ok, err = service.Claim(ctx, user, tasks[0].TaskID) + if err != nil { + t.Fatalf("Claim() duplicate error = %v", err) + } + if !ok || len(java.changedEvents) != 1 { + t.Fatalf("duplicate claim ok=%v changes=%d, want true/1", ok, len(java.changedEvents)) + } +} + +func TestExclusiveTaskConfigUsesLifetimeCycleAndHidesAfterClaim(t *testing.T) { + java := &taskCenterTestGateway{events: map[string]bool{}} + service, _ := newTestService(t, java) + ctx := context.Background() + + _, err := service.SaveConfig(ctx, SaveConfigRequest{ + SysOrigin: "LIKEI", + Enabled: true, + Timezone: "Asia/Riyadh", + TaskCategory: TaskCategoryNewbie, + Tasks: []SaveTaskConfigInput{ + { + TaskCode: "EXCLUSIVE_RECHARGE", + Enabled: true, + TaskName: "专属充值任务", + ConditionType: "RECHARGE_GOLD", + TargetValue: 10, + TargetUnit: "gold", + RewardGold: 100, + }, + }, + }) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + user := common.AuthUser{UserID: 2001, SysOrigin: "LIKEI"} + tasks, err := service.ListTasks(ctx, user) + if err != nil { + t.Fatalf("ListTasks() error = %v", err) + } + if len(tasks) != 1 || tasks[0].TaskType != 1 { + t.Fatalf("exclusive tasks = %+v, want one taskType=1", tasks) + } + + eventResp, err := service.ReceiveEvent(ctx, EventRequest{ + SysOrigin: "LIKEI", + EventID: "recharge-1", + EventType: "RECHARGE_GOLD", + UserID: flexibleInt64(user.UserID), + DeltaValue: 10, + }) + if err != nil { + t.Fatalf("ReceiveEvent() error = %v", err) + } + if !eventResp.Processed || eventResp.Updated != 1 { + t.Fatalf("ReceiveEvent() resp = %+v", eventResp) + } + + ok, err := service.Claim(ctx, user, tasks[0].TaskID) + if err != nil { + t.Fatalf("Claim() error = %v", err) + } + if !ok { + t.Fatalf("Claim() = false, want true") + } + + tasks, err = service.ListTasks(ctx, user) + if err != nil { + t.Fatalf("ListTasks() after claim error = %v", err) + } + if len(tasks) != 0 { + t.Fatalf("exclusive tasks after claim = %+v, want hidden", tasks) + } +} + +func TestTaskConfigRejectsRemovedConditionTypes(t *testing.T) { + service, _ := newTestService(t, nil) + + for _, conditionType := range []string{"POST_MOOD_COUNT", "INTERACTIVE_GAME_COUNT", "CP_BIND_COUNT"} { + t.Run(conditionType, func(t *testing.T) { + _, err := service.SaveConfig(context.Background(), SaveConfigRequest{ + SysOrigin: "LIKEI", + Enabled: true, + Timezone: "Asia/Riyadh", + TaskCategory: TaskCategoryDaily, + Tasks: []SaveTaskConfigInput{ + { + TaskCode: "REMOVED_" + conditionType, + Enabled: true, + TaskName: "Removed condition", + ConditionType: conditionType, + TargetValue: 1, + TargetUnit: "count", + RewardGold: 100, + }, + }, + }) + if err == nil { + t.Fatalf("SaveConfig() error = nil, want invalid condition") + } + }) + } +} + +type taskCenterTestGateway struct { + changedEvents []string + events map[string]bool +} + +func (g *taskCenterTestGateway) ExistsGoldEvent(_ context.Context, eventID string) (bool, error) { + return g.events[eventID], nil +} + +func (g *taskCenterTestGateway) ChangeGoldBalance(_ context.Context, cmd integration.GoldReceiptCommand) error { + g.changedEvents = append(g.changedEvents, cmd.EventID) + g.events[cmd.EventID] = true + return nil +} diff --git a/internal/service/taskcenter/types.go b/internal/service/taskcenter/types.go new file mode 100644 index 0000000..66c499d --- /dev/null +++ b/internal/service/taskcenter/types.go @@ -0,0 +1,344 @@ +package taskcenter + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + + "gorm.io/gorm" +) + +const ( + TaskCategoryDaily = "DAILY" + TaskCategoryNewbie = "NEWBIE" + + EventTypeMicDurationSeconds = "MIC_DURATION_SECONDS" + EventTypeGameConsumeGold = "GAME_CONSUME_GOLD" + EventTypeGiftConsumeGold = "GIFT_CONSUME_GOLD" + EventTypeRechargeGold = "RECHARGE_GOLD" + + TaskStatusInProgress = "IN_PROGRESS" + TaskStatusCompleted = "COMPLETED" + TaskStatusClaimed = "CLAIMED" + + ClaimStatusSuccess = "SUCCESS" + ClaimStatusFailed = "FAILED" + + defaultTaskCenterTimezone = "Asia/Riyadh" +) + +type AppError = common.AppError +type AuthUser = common.AuthUser + +var NewAppError = common.NewAppError + +type taskCenterDB interface { + WithContext(ctx context.Context) *gorm.DB +} + +type taskCenterJavaGateway interface { + ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error + ExistsGoldEvent(ctx context.Context, eventID string) (bool, error) +} + +// Service 负责任务配置、进度、领取和事件入库。 +type Service struct { + cfg config.Config + db taskCenterDB + java taskCenterJavaGateway + location *time.Location +} + +func NewService(cfg config.Config, db taskCenterDB, java taskCenterJavaGateway) *Service { + timezone := strings.TrimSpace(cfg.TaskCenter.Timezone) + if timezone == "" { + timezone = defaultTaskCenterTimezone + } + location, err := time.LoadLocation(timezone) + if err != nil { + location = time.UTC + } + return &Service{cfg: cfg, db: db, java: java, location: location} +} + +// TaskConfigPayload 是后台配置页的任务读模型。 +type TaskConfigPayload struct { + ID int64 `json:"id,string"` + TaskCode string `json:"taskCode"` + TaskCategory string `json:"taskCategory"` + Enabled bool `json:"enabled"` + TaskName string `json:"taskName"` + TaskDesc string `json:"taskDesc"` + ConditionType string `json:"conditionType"` + TargetValue int64 `json:"targetValue"` + TargetUnit string `json:"targetUnit"` + RewardGold int64 `json:"rewardGold"` + JumpType string `json:"jumpType"` + JumpPage string `json:"jumpPage"` + TaskIcon string `json:"taskIcon"` + Cover string `json:"cover"` + SortOrder int `json:"sortOrder"` +} + +// SaveTaskConfigInput 是后台保存单个任务的入参。 +type SaveTaskConfigInput struct { + ID flexibleInt64 `json:"id"` + TaskCode string `json:"taskCode"` + TaskCategory string `json:"taskCategory"` + Enabled bool `json:"enabled"` + TaskName string `json:"taskName"` + TaskDesc string `json:"taskDesc"` + ConditionType string `json:"conditionType"` + TargetValue int64 `json:"targetValue"` + TargetUnit string `json:"targetUnit"` + RewardGold int64 `json:"rewardGold"` + JumpType string `json:"jumpType"` + JumpPage string `json:"jumpPage"` + TaskIcon string `json:"taskIcon"` + Cover string `json:"cover"` + 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"` +} + +// 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"` +} + +// AppTaskItem 兼容 Flutter 现有 SCTaskListRes 字段。 +type AppTaskItem struct { + TaskType int `json:"taskType"` + TaskName string `json:"taskName"` + TaskDesc string `json:"taskDesc"` + JumpPage string `json:"jumpPage"` + ConditionType string `json:"conditionType"` + SortOrder int `json:"sortOrder"` + TaskID int64 `json:"taskId,string"` + TaskStatus int `json:"taskStatus"` + Cover string `json:"cover"` + TaskIcon string `json:"taskIcon"` + ConditionValue string `json:"conditionValue"` + CompletedValue int64 `json:"completedValue"` + IsRewardCollected int `json:"isRewardCollected"` + TargetValue int64 `json:"targetValue"` + Quantity int64 `json:"quantity"` +} + +type ClaimRequest struct { + TaskID flexibleInt64 `json:"taskId"` +} + +type ClaimableCountResponse struct { + Count int `json:"count"` +} + +// EventRequest 是 Java 或其他后端业务上报的任务事件。 +type EventRequest struct { + SysOrigin string `json:"sysOrigin"` + EventID string `json:"eventId"` + EventType string `json:"eventType"` + UserID flexibleInt64 `json:"userId"` + DeltaValue int64 `json:"deltaValue"` + OccurredAt string `json:"occurredAt"` + Payload map[string]any `json:"payload"` +} + +type EventResponse struct { + Processed bool `json:"processed"` + Updated int `json:"updated"` +} + +type PageResponse[T any] struct { + Records []T `json:"records"` + Total int64 `json:"total"` + Current int `json:"current"` + Size int `json:"size"` +} + +type ClaimRecordView struct { + ID int64 `json:"id,string"` + SysOrigin string `json:"sysOrigin"` + UserID int64 `json:"userId,string"` + TaskID int64 `json:"taskId,string"` + TaskCode string `json:"taskCode"` + TaskName string `json:"taskName"` + TaskCategory string `json:"taskCategory"` + CycleKey string `json:"cycleKey"` + RewardGold int64 `json:"rewardGold"` + WalletEventID string `json:"walletEventId"` + Status string `json:"status"` + FailureReason string `json:"failureReason"` + ClaimedAt string `json:"claimedAt,omitempty"` + CreateTime string `json:"createTime"` + UpdateTime string `json:"updateTime"` +} + +type EventView struct { + ID int64 `json:"id,string"` + SysOrigin string `json:"sysOrigin"` + EventID string `json:"eventId"` + EventType string `json:"eventType"` + UserID int64 `json:"userId,string"` + TaskID int64 `json:"taskId,string"` + TaskCode string `json:"taskCode"` + TaskCategory string `json:"taskCategory"` + CycleKey string `json:"cycleKey"` + DeltaValue int64 `json:"deltaValue"` + CompletedValue int64 `json:"completedValue"` + OccurredAt string `json:"occurredAt"` + CreateTime string `json:"createTime"` + UpdateTime string `json:"updateTime"` +} + +type flexibleInt64 int64 + +func (v *flexibleInt64) UnmarshalJSON(data []byte) error { + raw := strings.TrimSpace(string(data)) + switch raw { + case "", "null", `""`: + *v = 0 + return nil + } + if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) { + unquoted, err := strconv.Unquote(raw) + if err != nil { + return err + } + raw = strings.TrimSpace(unquoted) + } + parsed, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return fmt.Errorf("invalid int64 value: %w", err) + } + *v = flexibleInt64(parsed) + return nil +} + +func (v flexibleInt64) Int64() int64 { + return int64(v) +} + +func (v flexibleInt64) MarshalJSON() ([]byte, error) { + return json.Marshal(strconv.FormatInt(int64(v), 10)) +} + +func ParseInt64(raw string) int64 { + value, _ := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + return value +} + +func NormalizePage(cursor int, limit int) (int, int) { + if cursor <= 0 { + cursor = 1 + } + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + return cursor, limit +} + +func (s *Service) normalizeSysOrigin(sysOrigin string) string { + value := strings.ToUpper(strings.TrimSpace(sysOrigin)) + if value == "" { + value = strings.ToUpper(strings.TrimSpace(s.cfg.TaskCenter.DefaultSysOrigin)) + } + if value == "" { + value = "LIKEI" + } + return value +} + +func (s *Service) defaultTimezone() string { + timezone := strings.TrimSpace(s.cfg.TaskCenter.Timezone) + if timezone == "" { + timezone = defaultTaskCenterTimezone + } + return timezone +} + +func normalizeCategory(category string) string { + value := strings.ToUpper(strings.TrimSpace(category)) + if value == "" { + value = TaskCategoryDaily + } + return value +} + +func validateCategory(category string) error { + switch normalizeCategory(category) { + case TaskCategoryDaily, TaskCategoryNewbie: + return nil + default: + return NewAppError(http.StatusBadRequest, "invalid_task_category", "taskCategory must be DAILY or NEWBIE") + } +} + +func isAllowedConditionType(conditionType string) bool { + switch strings.ToUpper(strings.TrimSpace(conditionType)) { + case EventTypeMicDurationSeconds, EventTypeGameConsumeGold, EventTypeGiftConsumeGold, EventTypeRechargeGold: + return true + default: + return false + } +} + +func currentCycleKey(category string, location *time.Location, at time.Time) string { + if normalizeCategory(category) == TaskCategoryDaily { + return at.In(location).Format("2006-01-02") + } + return "LIFETIME" +} + +func parseOccurredAt(raw string, fallback time.Time) time.Time { + value := strings.TrimSpace(raw) + if value == "" { + return fallback + } + for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05", "2006-01-02 15:04:05.000"} { + if parsed, err := time.Parse(layout, value); err == nil { + return parsed + } + } + return fallback +} + +func formatDateTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.Format("2006-01-02 15:04:05") +} + +func formatDateTimePtr(value *time.Time) string { + if value == nil || value.IsZero() { + return "" + } + return formatDateTime(*value) +} diff --git a/internal/service/tencentim/client.go b/internal/service/tencentim/client.go new file mode 100644 index 0000000..6e0aeb6 --- /dev/null +++ b/internal/service/tencentim/client.go @@ -0,0 +1,234 @@ +package tencentim + +import ( + "bytes" + "compress/zlib" + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/config" +) + +var ErrNotConfigured = errors.New("tencent im config is missing") + +type Client struct { + cfg config.TencentIMConfig + httpClient *http.Client +} + +type apiResponse struct { + ActionStatus string `json:"ActionStatus"` + ErrorCode int `json:"ErrorCode"` + ErrorInfo string `json:"ErrorInfo"` + GroupID string `json:"GroupId"` +} + +type apiError struct { + code int + status string + info string +} + +func (e *apiError) Error() string { + return fmt.Sprintf("tencent im error code=%d status=%s info=%s", e.code, e.status, e.info) +} + +func NewClient(cfg config.TencentIMConfig) *Client { + timeoutSeconds := cfg.RequestTimeoutSeconds + if timeoutSeconds <= 0 { + timeoutSeconds = 8 + } + return &Client{ + cfg: cfg, + httpClient: &http.Client{ + Timeout: time.Duration(timeoutSeconds) * time.Second, + }, + } +} + +func (c *Client) CreateAVChatRoom(ctx context.Context, groupID string, groupName string) (string, error) { + groupID = strings.TrimSpace(groupID) + groupName = strings.TrimSpace(groupName) + if groupID == "" { + return "", errors.New("group id is required") + } + if groupName == "" { + groupName = groupID + } + resp, err := c.post(ctx, "/v4/group_open_http_svc/create_group", map[string]any{ + "Type": "AVChatRoom", + "GroupId": groupID, + "Name": groupName, + "ApplyJoinOption": "FreeAccess", + }) + if err != nil { + if isTencentIMGroupAlreadyExists(err) { + return groupID, nil + } + return "", err + } + if strings.TrimSpace(resp.GroupID) != "" { + return strings.TrimSpace(resp.GroupID), nil + } + return groupID, nil +} + +func (c *Client) SendCustomGroupMessage(ctx context.Context, groupID string, body any) error { + groupID = strings.TrimSpace(groupID) + if groupID == "" { + return errors.New("group id is required") + } + data, err := json.Marshal(body) + if err != nil { + return err + } + _, err = c.post(ctx, "/v4/group_open_http_svc/send_group_msg", map[string]any{ + "GroupId": groupID, + "Random": randomInt31(), + "MsgBody": []map[string]any{ + { + "MsgType": "TIMCustomElem", + "MsgContent": map[string]any{ + "Data": string(data), + "Desc": "customBody", + "Sound": "dingdong.aiff", + }, + }, + }, + }) + return err +} + +func (c *Client) Post(ctx context.Context, path string, payload any) error { + _, err := c.post(ctx, path, payload) + return err +} + +func (c *Client) post(ctx context.Context, path string, payload any) (*apiResponse, error) { + if c == nil || c.cfg.AppID <= 0 || strings.TrimSpace(c.cfg.Key) == "" || strings.TrimSpace(c.cfg.Identifier) == "" { + return nil, ErrNotConfigured + } + baseEndpoint := strings.TrimRight(strings.TrimSpace(c.cfg.BaseEndpoint), "/") + if baseEndpoint == "" { + baseEndpoint = "https://console.tim.qq.com" + } + expireSeconds := c.cfg.UserSigExpireSeconds + if expireSeconds <= 0 { + expireSeconds = 7200000 + } + userSig, err := genTencentIMUserSig(c.cfg.AppID, strings.TrimSpace(c.cfg.Key), strings.TrimSpace(c.cfg.Identifier), expireSeconds, time.Now().Unix()) + if err != nil { + return nil, err + } + query := url.Values{} + query.Set("sdkappid", strconv.FormatInt(c.cfg.AppID, 10)) + query.Set("identifier", strings.TrimSpace(c.cfg.Identifier)) + query.Set("usersig", userSig) + query.Set("random", strconv.FormatInt(randomInt31(), 10)) + query.Set("contenttype", "json") + + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseEndpoint+path+"?"+query.Encode(), bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + respBody, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, readErr + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("tencent im http status %d: %s", resp.StatusCode, string(respBody)) + } + var apiResp apiResponse + if err := json.Unmarshal(respBody, &apiResp); err != nil { + return nil, fmt.Errorf("decode tencent im response: %w body=%s", err, string(respBody)) + } + if apiResp.ErrorCode != 0 || (apiResp.ActionStatus != "" && !strings.EqualFold(apiResp.ActionStatus, "OK")) { + if strings.TrimSpace(apiResp.ErrorInfo) == "" { + apiResp.ErrorInfo = string(respBody) + } + return nil, &apiError{code: apiResp.ErrorCode, status: apiResp.ActionStatus, info: apiResp.ErrorInfo} + } + return &apiResp, nil +} + +func isTencentIMGroupAlreadyExists(err error) bool { + var apiErr *apiError + if !errors.As(err, &apiErr) { + return false + } + if apiErr.code == 10021 || apiErr.code == 10025 { + return true + } + info := strings.ToLower(strings.TrimSpace(apiErr.info)) + return strings.Contains(info, "group id in use") || strings.Contains(info, "group id has been used") +} + +func genTencentIMUserSig(appID int64, key string, identifier string, expireSeconds int64, now int64) (string, error) { + if appID <= 0 || strings.TrimSpace(key) == "" || strings.TrimSpace(identifier) == "" { + return "", ErrNotConfigured + } + if expireSeconds <= 0 { + expireSeconds = 7200000 + } + content := "TLS.identifier:" + identifier + "\n" + + "TLS.sdkappid:" + strconv.FormatInt(appID, 10) + "\n" + + "TLS.time:" + strconv.FormatInt(now, 10) + "\n" + + "TLS.expire:" + strconv.FormatInt(expireSeconds, 10) + "\n" + mac := hmac.New(sha256.New, []byte(key)) + _, _ = mac.Write([]byte(content)) + sig := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + + raw, err := json.Marshal(map[string]any{ + "TLS.ver": "2.0", + "TLS.identifier": identifier, + "TLS.sdkappid": appID, + "TLS.expire": expireSeconds, + "TLS.time": now, + "TLS.sig": sig, + }) + if err != nil { + return "", err + } + var compressed bytes.Buffer + writer := zlib.NewWriter(&compressed) + if _, err := writer.Write(raw); err != nil { + _ = writer.Close() + return "", err + } + if err := writer.Close(); err != nil { + return "", err + } + return strings.NewReplacer("+", "*", "/", "-", "=", "_").Replace(base64.StdEncoding.EncodeToString(compressed.Bytes())), nil +} + +func randomInt31() int64 { + n, err := rand.Int(rand.Reader, big.NewInt(2147483647)) + if err != nil { + return time.Now().UnixNano() & 0x7fffffff + } + return n.Int64() +} diff --git a/internal/service/tencentim/client_test.go b/internal/service/tencentim/client_test.go new file mode 100644 index 0000000..01c4603 --- /dev/null +++ b/internal/service/tencentim/client_test.go @@ -0,0 +1,36 @@ +package tencentim + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "chatapp3-golang/internal/config" +) + +func TestCreateGroupTreatsDuplicateAsSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v4/group_open_http_svc/create_group" { + t.Fatalf("path = %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ActionStatus":"FAIL","ErrorCode":10021,"ErrorInfo":"group id has been used"}`)) + })) + defer server.Close() + + client := NewClient(config.TencentIMConfig{ + AppID: 123456, + Key: "secret", + Identifier: "administrator", + BaseEndpoint: server.URL, + RequestTimeoutSeconds: 1, + }) + groupID, err := client.CreateAVChatRoom(context.Background(), "VRRP_LIKEI_AR", "region-ar") + if err != nil { + t.Fatalf("CreateAVChatRoom() error = %v", err) + } + if groupID != "VRRP_LIKEI_AR" { + t.Fatalf("groupID = %s, want VRRP_LIKEI_AR", groupID) + } +} diff --git a/internal/service/voiceroomredpacket/amount.go b/internal/service/voiceroomredpacket/amount.go new file mode 100644 index 0000000..1ea7371 --- /dev/null +++ b/internal/service/voiceroomredpacket/amount.go @@ -0,0 +1,59 @@ +package voiceroomredpacket + +import ( + "math/rand" + "net/http" + "time" +) + +// splitRandomAmounts 按二倍均值法预拆拼手气金额。 +func splitRandomAmounts(total int64, count int, seed int64) ([]int64, error) { + if total <= 0 { + return nil, NewAppError(http.StatusBadRequest, "voice_room_red_packet_amount_invalid", "totalAmount must be greater than 0") + } + if count <= 0 { + return nil, NewAppError(http.StatusBadRequest, "voice_room_red_packet_count_invalid", "totalCount must be greater than 0") + } + if total < int64(count) { + return nil, NewAppError(http.StatusBadRequest, "voice_room_red_packet_amount_invalid", "totalAmount must be greater than or equal to totalCount") + } + if seed == 0 { + seed = time.Now().UnixNano() + } + + random := rand.New(rand.NewSource(seed)) + amounts := make([]int64, 0, count) + remainAmount := total + remainCount := count + for remainCount > 1 { + avg := remainAmount / int64(remainCount) + maxAmount := avg * 2 + keepForRest := int64(remainCount - 1) + if maxAmount > remainAmount-keepForRest { + maxAmount = remainAmount - keepForRest + } + if maxAmount < 1 { + maxAmount = 1 + } + amount := int64(1) + if maxAmount > 1 { + amount = random.Int63n(maxAmount) + 1 + } + amounts = append(amounts, amount) + remainAmount -= amount + remainCount-- + } + amounts = append(amounts, remainAmount) + random.Shuffle(len(amounts), func(i, j int) { + amounts[i], amounts[j] = amounts[j], amounts[i] + }) + return amounts, nil +} + +func sumAmounts(values []int64) int64 { + var total int64 + for _, value := range values { + total += value + } + return total +} diff --git a/internal/service/voiceroomredpacket/claim.go b/internal/service/voiceroomredpacket/claim.go new file mode 100644 index 0000000..ec80332 --- /dev/null +++ b/internal/service/voiceroomredpacket/claim.go @@ -0,0 +1,294 @@ +package voiceroomredpacket + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// Claim 领取语音房红包。 +func (s *Service) Claim(ctx context.Context, user AuthUser, req ClaimRequest) (*ClaimResponse, error) { + packetNo := strings.TrimSpace(req.PacketNo) + if packetNo == "" { + return nil, NewAppError(http.StatusBadRequest, "missing_packet_no", "packetNo is required") + } + sysOrigin := normalizeSysOrigin(user.SysOrigin) + lockKey := fmt.Sprintf("voice_room:red_packet:claim_lock:%s:%d", packetNo, user.UserID) + lockOK, err := s.redis.SetNX(ctx, lockKey, "1", 15*time.Second).Result() + if err != nil { + return nil, NewAppError(http.StatusServiceUnavailable, "voice_room_red_packet_lock_failed", err.Error()) + } + if !lockOK { + return nil, NewAppError(http.StatusConflict, "voice_room_red_packet_claim_in_progress", "red packet claim is in progress") + } + defer s.redis.Del(context.Background(), lockKey) + + packet, err := s.loadPacketByNo(ctx, packetNo) + if err != nil { + return nil, err + } + if packet == nil { + return nil, NewAppError(http.StatusNotFound, "voice_room_red_packet_not_found", "red packet not found") + } + if existing, err := s.loadUserClaim(ctx, packet.ID, user.UserID); err != nil { + return nil, err + } else if existing != nil { + return s.resumeExistingClaim(ctx, *packet, *existing) + } + + userRegion, err := s.resolveCachedUserRegion(ctx, sysOrigin, user.UserID) + if err != nil { + return nil, err + } + + var claim model.VoiceRoomRedPacketClaim + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var locked model.VoiceRoomRedPacket + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("packet_no = ?", packetNo). + First(&locked).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return NewAppError(http.StatusNotFound, "voice_room_red_packet_not_found", "red packet not found") + } + if err != nil { + return err + } + if err := validateClaimable(locked, userRegion); err != nil { + return err + } + existing, err := loadUserClaimTx(tx, locked.ID, user.UserID) + if err != nil { + return err + } + if existing != nil { + claim = *existing + return nil + } + + var slice model.VoiceRoomRedPacketSlice + err = tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("packet_id = ? AND status = ?", locked.ID, sliceStatusUnclaimed). + Order("sort_no asc"). + First(&slice).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + _ = tx.Model(&model.VoiceRoomRedPacket{}). + Where("id = ?", locked.ID). + Updates(map[string]any{"status": packetStatusFinished, "update_time": time.Now()}).Error + return NewAppError(http.StatusBadRequest, "voice_room_red_packet_finished", "red packet is finished") + } + if err != nil { + return err + } + + claimID, err := utils.NextID() + if err != nil { + return err + } + now := time.Now() + claim = model.VoiceRoomRedPacketClaim{ + ID: claimID, + PacketID: locked.ID, + PacketNo: locked.PacketNo, + SliceID: slice.ID, + SysOrigin: locked.SysOrigin, + RoomID: locked.RoomID, + UserID: user.UserID, + UserRegionCode: userRegion, + Amount: slice.Amount, + WalletEventID: walletEventID(walletOriginClaim, locked.SysOrigin, locked.PacketNo, claimID), + WalletStatus: walletStatusPending, + ClaimTime: now, + CreateTime: now, + UpdateTime: now, + } + if err := tx.Create(&claim).Error; err != nil { + return err + } + if err := tx.Model(&model.VoiceRoomRedPacketSlice{}). + Where("id = ? AND status = ?", slice.ID, sliceStatusUnclaimed). + Updates(map[string]any{ + "status": sliceStatusClaiming, + "claim_user_id": user.UserID, + "claim_record_id": claim.ID, + "update_time": now, + }).Error; err != nil { + return err + } + remainAmount := locked.RemainAmount - slice.Amount + remainCount := locked.RemainCount - 1 + if remainAmount < 0 { + remainAmount = 0 + } + if remainCount < 0 { + remainCount = 0 + } + return tx.Model(&model.VoiceRoomRedPacket{}). + Where("id = ?", locked.ID). + Updates(map[string]any{ + "remain_amount": remainAmount, + "remain_count": remainCount, + "update_time": now, + }).Error + }); err != nil { + return nil, err + } + + return s.completeClaimWallet(ctx, claim) +} + +func (s *Service) resumeExistingClaim(ctx context.Context, packet model.VoiceRoomRedPacket, claim model.VoiceRoomRedPacketClaim) (*ClaimResponse, error) { + if claim.WalletStatus == walletStatusSuccess { + return s.buildClaimResponse(ctx, claim) + } + if err := validateClaimableForRetry(packet); err != nil { + return nil, err + } + return s.completeClaimWallet(ctx, claim) +} + +func (s *Service) completeClaimWallet(ctx context.Context, claim model.VoiceRoomRedPacketClaim) (*ClaimResponse, error) { + if claim.WalletStatus != walletStatusSuccess { + err := s.wallet.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ + ReceiptType: walletReceiptIncome, + UserID: claim.UserID, + SysOrigin: claim.SysOrigin, + EventID: claim.WalletEventID, + Remark: fmt.Sprintf("voice room red packet claim %s", claim.PacketNo), + Amount: integration.NewPennyAmountPayloadFromDollar(claim.Amount), + CloseDelayAsset: false, + OpUserType: "APP", + CustomizeOrigin: walletOriginClaim, + CustomizeOriginDesc: walletOriginClaimDesc, + }) + if err != nil { + _ = s.db.WithContext(context.Background()).Model(&model.VoiceRoomRedPacketClaim{}). + Where("id = ?", claim.ID). + Updates(map[string]any{ + "wallet_status": walletStatusFailed, + "error_message": trimErrorMessage(err.Error()), + "update_time": time.Now(), + }).Error + return nil, mapWalletError("voice_room_red_packet_wallet_income_failed", err) + } + now := time.Now() + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.VoiceRoomRedPacketClaim{}). + Where("id = ?", claim.ID). + Updates(map[string]any{ + "wallet_status": walletStatusSuccess, + "error_message": "", + "update_time": now, + }).Error; err != nil { + return err + } + if err := tx.Model(&model.VoiceRoomRedPacketSlice{}). + Where("id = ?", claim.SliceID). + Updates(map[string]any{ + "status": sliceStatusClaimed, + "update_time": now, + }).Error; err != nil { + return err + } + return tx.Model(&model.VoiceRoomRedPacket{}). + Where("id = ? AND remain_count = 0 AND status = ?", claim.PacketID, packetStatusActive). + Updates(map[string]any{ + "status": packetStatusFinished, + "update_time": now, + }).Error + }); err != nil { + return nil, err + } + claim.WalletStatus = walletStatusSuccess + claim.UpdateTime = now + } + return s.buildClaimResponse(ctx, claim) +} + +func validateClaimable(packet model.VoiceRoomRedPacket, userRegion string) error { + if packet.Status != packetStatusActive { + if packet.Status == packetStatusFinished { + return NewAppError(http.StatusBadRequest, "voice_room_red_packet_finished", "red packet is finished") + } + if packet.Status == packetStatusExpired || packet.Status == packetStatusRefunded || packet.Status == packetStatusRefundFailed { + return NewAppError(http.StatusBadRequest, "voice_room_red_packet_expired", "red packet is expired") + } + return NewAppError(http.StatusBadRequest, "voice_room_red_packet_unavailable", "red packet is unavailable") + } + now := time.Now() + if now.Before(packet.ClaimStartTime) { + return NewAppError(http.StatusBadRequest, "voice_room_red_packet_not_started", "red packet is not started") + } + if !now.Before(packet.ExpireTime) { + return NewAppError(http.StatusBadRequest, "voice_room_red_packet_expired", "red packet is expired") + } + if !strings.EqualFold(strings.TrimSpace(packet.SenderRegionCode), strings.TrimSpace(userRegion)) { + return NewAppError(http.StatusForbidden, "voice_room_region_not_match", "user region does not match") + } + if packet.RemainCount <= 0 || packet.RemainAmount <= 0 { + return NewAppError(http.StatusBadRequest, "voice_room_red_packet_finished", "red packet is finished") + } + return nil +} + +func validateClaimableForRetry(packet model.VoiceRoomRedPacket) error { + if packet.Status == packetStatusExpired || packet.Status == packetStatusRefunded || packet.Status == packetStatusRefundFailed { + return NewAppError(http.StatusBadRequest, "voice_room_red_packet_expired", "red packet is expired") + } + return nil +} + +func (s *Service) loadPacketByNo(ctx context.Context, packetNo string) (*model.VoiceRoomRedPacket, error) { + var row model.VoiceRoomRedPacket + err := s.db.WithContext(ctx).Where("packet_no = ?", packetNo).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &row, nil +} + +func (s *Service) loadUserClaim(ctx context.Context, packetID, userID int64) (*model.VoiceRoomRedPacketClaim, error) { + return loadUserClaimTx(s.db.WithContext(ctx), packetID, userID) +} + +func loadUserClaimTx(tx *gorm.DB, packetID, userID int64) (*model.VoiceRoomRedPacketClaim, error) { + var row model.VoiceRoomRedPacketClaim + err := tx.Where("packet_id = ? AND user_id = ?", packetID, userID).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &row, nil +} + +func (s *Service) buildClaimResponse(ctx context.Context, claim model.VoiceRoomRedPacketClaim) (*ClaimResponse, error) { + packet, err := s.loadPacketByNo(ctx, claim.PacketNo) + if err != nil { + return nil, err + } + if packet == nil { + return nil, NewAppError(http.StatusNotFound, "voice_room_red_packet_not_found", "red packet not found") + } + return &ClaimResponse{ + PacketNo: claim.PacketNo, + Amount: claim.Amount, + RemainCount: packet.RemainCount, + TotalCount: packet.TotalCount, + ClaimTime: formatTime(claim.ClaimTime), + Status: claim.WalletStatus, + }, nil +} diff --git a/internal/service/voiceroomredpacket/config.go b/internal/service/voiceroomredpacket/config.go new file mode 100644 index 0000000..c9dc973 --- /dev/null +++ b/internal/service/voiceroomredpacket/config.go @@ -0,0 +1,194 @@ +package voiceroomredpacket + +import ( + "context" + "errors" + "net/http" + "time" + + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + + "gorm.io/gorm" +) + +// GetConfig 返回 App 当前红包配置和用户今日发送次数。 +func (s *Service) GetConfig(ctx context.Context, user AuthUser) (*ConfigResponse, error) { + snapshot, err := s.loadConfig(ctx, user.SysOrigin) + if err != nil { + return nil, err + } + todayCount := 0 + if user.UserID > 0 { + location := resolveLocation(snapshot.Timezone) + count, err := s.loadDailySendCount(ctx, snapshot.SysOrigin, user.UserID, dayKey(time.Now().In(location))) + if err != nil { + return nil, err + } + todayCount = count + } + resp := configResponse(snapshot) + resp.TodaySendCount = todayCount + return resp, nil +} + +// GetAdminConfig 返回后台配置。 +func (s *Service) GetAdminConfig(ctx context.Context, sysOrigin string) (*ConfigResponse, error) { + snapshot, err := s.loadConfig(ctx, sysOrigin) + if err != nil { + return nil, err + } + return configResponse(snapshot), nil +} + +// SaveAdminConfig 保存后台配置。 +func (s *Service) SaveAdminConfig(ctx context.Context, req SaveConfigRequest) (*ConfigResponse, error) { + sysOrigin := normalizeSysOrigin(req.SysOrigin) + timezone := normalizeTimezone(req.Timezone) + if _, err := time.LoadLocation(timezone); err != nil { + return nil, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error()) + } + amountOptions := req.AmountOptions + if len(amountOptions) == 0 { + amountOptions = defaultAmountOptions() + } + countOptions := req.CountOptions + if len(countOptions) == 0 { + countOptions = defaultCountOptions() + } + dailyLimit := req.DailySendLimit + if dailyLimit <= 0 { + dailyLimit = defaultDailySendLimit + } + delaySeconds := req.DelaySeconds + if delaySeconds <= 0 { + delaySeconds = defaultDelaySeconds + } + refundAfterSeconds := req.RefundAfterSeconds + if refundAfterSeconds <= 0 { + refundAfterSeconds = defaultRefundAfterSeconds + } + + var configID int64 + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + now := time.Now() + var row model.VoiceRoomRedPacketConfig + err := tx.Where("sys_origin = ?", sysOrigin).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + id, idErr := utils.NextID() + if idErr != nil { + return idErr + } + row = model.VoiceRoomRedPacketConfig{ + ID: id, + SysOrigin: sysOrigin, + CreateTime: now, + } + } else if err != nil { + return err + } + row.Enabled = req.Enabled + row.DailySendLimit = dailyLimit + row.DelaySeconds = delaySeconds + row.RefundAfterSeconds = refundAfterSeconds + row.Timezone = timezone + row.AmountOptions = joinInt64CSV(amountOptions) + row.CountOptions = joinIntCSV(countOptions) + row.UpdateTime = now + if row.CreateTime.IsZero() { + row.CreateTime = now + } + if err := tx.Save(&row).Error; err != nil { + return err + } + configID = row.ID + return nil + }); err != nil { + return nil, err + } + _ = configID + return s.GetAdminConfig(ctx, sysOrigin) +} + +func (s *Service) loadConfig(ctx context.Context, sysOrigin string) (configSnapshot, error) { + sysOrigin = normalizeSysOrigin(sysOrigin) + var row model.VoiceRoomRedPacketConfig + err := s.db.WithContext(ctx).Where("sys_origin = ?", sysOrigin).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return configSnapshot{ + SysOrigin: sysOrigin, + Enabled: false, + DailySendLimit: defaultDailySendLimit, + DelaySeconds: defaultDelaySeconds, + RefundAfterSeconds: defaultRefundAfterSeconds, + Timezone: defaultTimezone, + AmountOptions: defaultAmountOptions(), + CountOptions: defaultCountOptions(), + }, nil + } + if err != nil { + return configSnapshot{}, err + } + return configSnapshot{ + ID: row.ID, + SysOrigin: row.SysOrigin, + Enabled: row.Enabled, + DailySendLimit: positiveOrDefault(row.DailySendLimit, defaultDailySendLimit), + DelaySeconds: positiveOrDefault(row.DelaySeconds, defaultDelaySeconds), + RefundAfterSeconds: positiveOrDefault(row.RefundAfterSeconds, defaultRefundAfterSeconds), + Timezone: normalizeTimezone(row.Timezone), + AmountOptions: parseInt64CSV(row.AmountOptions, defaultAmountOptions()), + CountOptions: parseIntCSV(row.CountOptions, defaultCountOptions()), + Configured: true, + UpdateTime: row.UpdateTime, + }, nil +} + +func configResponse(snapshot configSnapshot) *ConfigResponse { + return &ConfigResponse{ + Configured: snapshot.Configured, + ID: snapshot.ID, + SysOrigin: snapshot.SysOrigin, + Enabled: snapshot.Enabled, + DailySendLimit: snapshot.DailySendLimit, + DelaySeconds: snapshot.DelaySeconds, + RefundAfterSeconds: snapshot.RefundAfterSeconds, + Timezone: snapshot.Timezone, + AmountOptions: append([]int64(nil), snapshot.AmountOptions...), + CountOptions: append([]int(nil), snapshot.CountOptions...), + UpdateTime: formatTime(snapshot.UpdateTime), + } +} + +func positiveOrDefault(value, fallback int) int { + if value <= 0 { + return fallback + } + return value +} + +func resolveLocation(timezone string) *time.Location { + location, err := time.LoadLocation(normalizeTimezone(timezone)) + if err != nil { + return time.UTC + } + return location +} + +func dayKey(t time.Time) string { + return t.Format("20060102") +} + +func (s *Service) loadDailySendCount(ctx context.Context, sysOrigin string, userID int64, day string) (int, error) { + var row model.VoiceRoomRedPacketDailyCounter + err := s.db.WithContext(ctx). + Where("sys_origin = ? AND user_id = ? AND day_key = ?", sysOrigin, userID, day). + First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, nil + } + if err != nil { + return 0, err + } + return row.SendCount, nil +} diff --git a/internal/service/voiceroomredpacket/notify.go b/internal/service/voiceroomredpacket/notify.go new file mode 100644 index 0000000..469f11e --- /dev/null +++ b/internal/service/voiceroomredpacket/notify.go @@ -0,0 +1,222 @@ +package voiceroomredpacket + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "strconv" + "strings" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/service/tencentim" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +type redisNotifier struct { + redis redis.Cmdable + streamKey string +} + +func newRedisNotifier(cache redis.Cmdable, streamKey string) *redisNotifier { + return &redisNotifier{redis: cache, streamKey: streamKey} +} + +func (n *redisNotifier) publish(ctx context.Context, scope string, regionCode string, excludeRoomID int64, message notificationMessage) error { + if n == nil || n.redis == nil { + return nil + } + body, err := json.Marshal(message) + if err != nil { + return err + } + return n.redis.XAdd(ctx, &redis.XAddArgs{ + Stream: n.streamKey, + Values: map[string]any{ + "scope": scope, + "regionCode": regionCode, + "excludeRoomId": strconv.FormatInt(excludeRoomID, 10), + "roomId": strconv.FormatInt(message.RoomID, 10), + "packetNo": message.PacketNo, + "body": string(body), + }, + }).Err() +} + +func (s *Service) notifyCreated(ctx context.Context, packet model.VoiceRoomRedPacket) error { + message := notificationMessage{ + Type: "VOICE_ROOM_RED_PACKET", + Event: "CREATED", + PacketNo: packet.PacketNo, + RoomID: packet.RoomID, + PacketMode: packet.PacketMode, + SenderUserID: packet.SenderUserID, + TotalAmount: packet.TotalAmount, + TotalCount: packet.TotalCount, + ClaimStartTime: packet.ClaimStartTime.UnixMilli(), + ExpireTime: packet.ExpireTime.UnixMilli(), + } + if packet.PacketMode == packetModeDelayed { + var notifyErr error + if err := s.notifier.publish(ctx, "REGION", packet.SenderRegionCode, packet.RoomID, message); err != nil { + notifyErr = err + } + if err := s.sendRegionIMRedPacketNotification(ctx, packet); err != nil { + return mergeNotifyErrors(notifyErr, err) + } + return notifyErr + } + var notifyErr error + if err := s.notifier.publish(ctx, "ROOM", packet.SenderRegionCode, 0, message); err != nil { + notifyErr = err + } + if err := s.sendRoomIMRedPacketNotification(ctx, packet); err != nil { + return mergeNotifyErrors(notifyErr, err) + } + return notifyErr +} + +func (s *Service) sendRegionIMRedPacketNotification(ctx context.Context, packet model.VoiceRoomRedPacket) error { + if s == nil || s.regionIM == nil { + return tencentim.ErrNotConfigured + } + body, err := s.buildRoomRedPacketIMBody(ctx, packet) + if err != nil { + return err + } + _, err = s.regionIM.SendRegionCustomMessage(ctx, packet.SysOrigin, packet.SenderRegionCode, body) + return err +} + +func (s *Service) sendRoomIMRedPacketNotification(ctx context.Context, packet model.VoiceRoomRedPacket) error { + if s == nil || s.im == nil { + return tencentim.ErrNotConfigured + } + groupID, err := s.resolveRoomIMGroupID(ctx, packet.RoomID) + if err != nil { + return err + } + body, err := s.buildRoomRedPacketIMBodyWithRoomAccount(ctx, packet, groupID) + if err != nil { + return err + } + return s.im.SendCustomGroupMessage(ctx, groupID, body) +} + +func (s *Service) buildRoomRedPacketIMBody(ctx context.Context, packet model.VoiceRoomRedPacket) (map[string]any, error) { + roomAccount, _ := s.resolveRoomIMGroupID(ctx, packet.RoomID) + return s.buildRoomRedPacketIMBodyWithRoomAccount(ctx, packet, roomAccount) +} + +func (s *Service) buildRoomRedPacketIMBodyWithRoomAccount(ctx context.Context, packet model.VoiceRoomRedPacket, roomAccount string) (map[string]any, error) { + profile, err := s.loadBroadcastUserProfile(ctx, packet.SenderUserID) + if err != nil { + return nil, err + } + return map[string]any{ + "type": "ROOM_RED_PACKET", + "data": map[string]any{ + "userNickname": profile.UserNickname, + "account": profile.Account, + "actualAccount": profile.Account, + "userAvatar": profile.UserAvatar, + "countryCode": profile.CountryCode, + "countryName": profile.CountryName, + "roomAccount": roomAccount, + "roomId": strconv.FormatInt(packet.RoomID, 10), + "packetId": packet.PacketNo, + "packetMode": packet.PacketMode, + "totalAmount": packet.TotalAmount, + "totalCount": packet.TotalCount, + "regionCode": packet.SenderRegionCode, + "claimStartTime": packet.ClaimStartTime.UnixMilli(), + "expireTime": packet.ExpireTime.UnixMilli(), + }, + }, nil +} + +func (s *Service) resolveRoomIMGroupID(ctx context.Context, roomID int64) (string, error) { + if roomID <= 0 { + return "", fmt.Errorf("room id is required") + } + if s != nil && s.roomProfiles != nil { + profiles, err := s.roomProfiles.MapRoomProfiles(ctx, []int64{roomID}) + if err == nil { + if profile, ok := profiles[roomID]; ok { + if roomAccount := strings.TrimSpace(profile.RoomAccount); roomAccount != "" { + return roomAccount, nil + } + } + for _, profile := range profiles { + if int64(profile.ID) == roomID { + if roomAccount := strings.TrimSpace(profile.RoomAccount); roomAccount != "" { + return roomAccount, nil + } + } + } + } + } + + var room model.RoomLiveBroadcastVIP + err := s.db.WithContext(ctx). + Where("room_id = ? OR id = ?", roomID, roomID). + Order("update_time DESC"). + First(&room).Error + if err == nil && room.RoomAccount > 0 { + return strconv.FormatInt(room.RoomAccount, 10), nil + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return "", err + } + return strconv.FormatInt(roomID, 10), nil +} + +func mergeNotifyErrors(first error, second error) error { + if first == nil { + return second + } + if second == nil { + return first + } + return fmt.Errorf("%v; %w", first, second) +} + +func (s *Service) loadBroadcastUserProfile(ctx context.Context, userID int64) (model.UserBaseInfo, error) { + var user model.UserBaseInfo + err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + account := strconv.FormatInt(userID, 10) + return model.UserBaseInfo{ + ID: userID, + Account: account, + UserNickname: account, + }, nil + } + if err != nil { + return model.UserBaseInfo{}, err + } + if strings.TrimSpace(user.Account) == "" { + user.Account = strconv.FormatInt(userID, 10) + } + if strings.TrimSpace(user.UserNickname) == "" { + user.UserNickname = user.Account + } + return user, nil +} + +func notifyStreamKey(cfg config.Config) string { + if value := cfg.VoiceRoomRedPacket.NotifyStreamKey; value != "" { + return value + } + return defaultNotifyStreamKey +} + +func logNotifyFailure(packetNo string, err error) { + if err != nil { + log.Printf("voice room red packet notify failed packetNo=%s err=%v", packetNo, err) + } +} diff --git a/internal/service/voiceroomredpacket/presence.go b/internal/service/voiceroomredpacket/presence.go new file mode 100644 index 0000000..c2b43ef --- /dev/null +++ b/internal/service/voiceroomredpacket/presence.go @@ -0,0 +1,150 @@ +package voiceroomredpacket + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/model" + + "gorm.io/gorm" +) + +// Heartbeat 记录用户当前语音房在线态,供 Go 侧红包广播和房间校验使用。 +func (s *Service) Heartbeat(ctx context.Context, user AuthUser, req PresenceRequest) (*PresenceResponse, error) { + roomID := req.RoomID.Int64() + if roomID <= 0 { + return nil, NewAppError(http.StatusBadRequest, "missing_room_id", "roomId is required") + } + sysOrigin := normalizeSysOrigin(user.SysOrigin) + regionCode, err := s.resolveUserRegion(ctx, sysOrigin, user.UserID) + if err != nil { + return nil, err + } + ttl := presenceTTL(s.cfg) + roomKey := onlineRoomKey(sysOrigin, roomID) + if err := s.redis.SAdd(ctx, roomKey, user.UserID).Err(); err != nil { + return nil, NewAppError(http.StatusServiceUnavailable, "voice_room_presence_failed", err.Error()) + } + if err := s.redis.Expire(ctx, roomKey, ttl).Err(); err != nil { + return nil, NewAppError(http.StatusServiceUnavailable, "voice_room_presence_failed", err.Error()) + } + if err := s.redis.Set(ctx, userRoomKey(sysOrigin, user.UserID), strconv.FormatInt(roomID, 10), ttl).Err(); err != nil { + return nil, NewAppError(http.StatusServiceUnavailable, "voice_room_presence_failed", err.Error()) + } + if err := s.redis.Set(ctx, userRegionKey(sysOrigin, user.UserID), regionCode, ttl).Err(); err != nil { + return nil, NewAppError(http.StatusServiceUnavailable, "voice_room_presence_failed", err.Error()) + } + if err := s.redis.Set(ctx, userSeenKey(sysOrigin, user.UserID), strconv.FormatInt(time.Now().UnixMilli(), 10), ttl).Err(); err != nil { + return nil, NewAppError(http.StatusServiceUnavailable, "voice_room_presence_failed", err.Error()) + } + return &PresenceResponse{ + UserID: user.UserID, + SysOrigin: sysOrigin, + RoomID: roomID, + RegionCode: regionCode, + TTLSeconds: int(ttl / time.Second), + }, nil +} + +// Leave 清理用户当前房间在线态。 +func (s *Service) Leave(ctx context.Context, user AuthUser, req PresenceRequest) (*PresenceResponse, error) { + roomID := req.RoomID.Int64() + if roomID <= 0 { + return nil, NewAppError(http.StatusBadRequest, "missing_room_id", "roomId is required") + } + sysOrigin := normalizeSysOrigin(user.SysOrigin) + regionCode, _ := s.resolveCachedUserRegion(ctx, sysOrigin, user.UserID) + if err := s.redis.SRem(ctx, onlineRoomKey(sysOrigin, roomID), user.UserID).Err(); err != nil { + return nil, NewAppError(http.StatusServiceUnavailable, "voice_room_presence_failed", err.Error()) + } + if err := s.redis.Del(ctx, userRoomKey(sysOrigin, user.UserID), userSeenKey(sysOrigin, user.UserID)).Err(); err != nil { + return nil, NewAppError(http.StatusServiceUnavailable, "voice_room_presence_failed", err.Error()) + } + return &PresenceResponse{ + UserID: user.UserID, + SysOrigin: sysOrigin, + RoomID: roomID, + RegionCode: regionCode, + TTLSeconds: int(presenceTTL(s.cfg) / time.Second), + }, nil +} + +func (s *Service) ensureUserInRoom(ctx context.Context, sysOrigin string, userID, roomID int64) error { + online, err := s.redis.SIsMember(ctx, onlineRoomKey(sysOrigin, roomID), userID).Result() + if err != nil { + return NewAppError(http.StatusServiceUnavailable, "voice_room_presence_failed", err.Error()) + } + if !online { + return NewAppError(http.StatusBadRequest, "voice_room_red_packet_room_presence_required", "user is not online in this room") + } + return nil +} + +func (s *Service) resolveCachedUserRegion(ctx context.Context, sysOrigin string, userID int64) (string, error) { + region, err := s.redis.Get(ctx, userRegionKey(sysOrigin, userID)).Result() + if err == nil && strings.TrimSpace(region) != "" { + return strings.ToUpper(strings.TrimSpace(region)), nil + } + return s.resolveUserRegion(ctx, sysOrigin, userID) +} + +func (s *Service) resolveUserRegion(ctx context.Context, sysOrigin string, userID int64) (string, error) { + var user model.UserBaseInfo + err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", NewAppError(http.StatusBadRequest, "voice_room_region_missing", "user region is missing") + } + if err != nil { + return "", err + } + countryCode := strings.ToUpper(strings.TrimSpace(user.CountryCode)) + if countryCode == "" { + return "", NewAppError(http.StatusBadRequest, "voice_room_region_missing", "user country code is missing") + } + + var mapping model.VoiceRoomRegionCountry + err = s.db.WithContext(ctx). + Where("sys_origin = ? AND country_code = ? AND enabled = ?", sysOrigin, countryCode, true). + First(&mapping).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return countryCode, nil + } + if err != nil { + return "", err + } + regionCode := strings.ToUpper(strings.TrimSpace(mapping.RegionCode)) + if regionCode == "" { + return countryCode, nil + } + return regionCode, nil +} + +func onlineRoomKey(sysOrigin string, roomID int64) string { + return fmt.Sprintf("voice_room:online:%s:%d", normalizeSysOrigin(sysOrigin), roomID) +} + +func userRoomKey(sysOrigin string, userID int64) string { + return fmt.Sprintf("voice_room:user_room:%s:%d", normalizeSysOrigin(sysOrigin), userID) +} + +func userRegionKey(sysOrigin string, userID int64) string { + return fmt.Sprintf("voice_room:user_region:%s:%d", normalizeSysOrigin(sysOrigin), userID) +} + +func userSeenKey(sysOrigin string, userID int64) string { + return fmt.Sprintf("voice_room:user_seen:%s:%d", normalizeSysOrigin(sysOrigin), userID) +} + +func presenceTTL(cfg config.Config) time.Duration { + seconds := cfg.VoiceRoomRedPacket.PresenceTTLSeconds + if seconds <= 0 { + seconds = defaultPresenceTTLSeconds + } + return time.Duration(seconds) * time.Second +} diff --git a/internal/service/voiceroomredpacket/records.go b/internal/service/voiceroomredpacket/records.go new file mode 100644 index 0000000..5d49319 --- /dev/null +++ b/internal/service/voiceroomredpacket/records.go @@ -0,0 +1,230 @@ +package voiceroomredpacket + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "chatapp3-golang/internal/model" + + "gorm.io/gorm" +) + +// Detail 返回红包详情和当前用户领取状态。 +func (s *Service) Detail(ctx context.Context, user AuthUser, packetNo string) (*DetailResponse, error) { + packetNo = strings.TrimSpace(packetNo) + if packetNo == "" { + return nil, NewAppError(http.StatusBadRequest, "missing_packet_no", "packetNo is required") + } + packet, err := s.loadPacketByNo(ctx, packetNo) + if err != nil { + return nil, err + } + if packet == nil { + return nil, NewAppError(http.StatusNotFound, "voice_room_red_packet_not_found", "red packet not found") + } + claims, err := s.loadClaims(ctx, packet.ID) + if err != nil { + return nil, err + } + resp := &DetailResponse{ + Packet: packetPayload(*packet), + Claims: make([]ClaimPayload, 0, len(claims)), + NowMillis: time.Now().UnixMilli(), + } + for _, claim := range claims { + payload := ClaimPayload{ + ID: claim.ID, + UserID: claim.UserID, + UserRegionCode: claim.UserRegionCode, + Amount: claim.Amount, + WalletStatus: claim.WalletStatus, + ClaimTime: formatTime(claim.ClaimTime), + } + resp.Claims = append(resp.Claims, payload) + if claim.UserID == user.UserID { + resp.Grabbed = true + resp.MyAmount = claim.Amount + } + } + region, _ := s.resolveCachedUserRegion(ctx, normalizeSysOrigin(user.SysOrigin), user.UserID) + resp.Claimable = validateClaimable(*packet, region) == nil + return resp, nil +} + +// ListClaimRecords 返回某个红包的成功领取列表,包含领取人资料、领取金额和领取时间。 +func (s *Service) ListClaimRecords(ctx context.Context, _ AuthUser, packetNo string) (*ClaimRecordListResponse, error) { + packetNo = strings.TrimSpace(packetNo) + if packetNo == "" { + return nil, NewAppError(http.StatusBadRequest, "missing_packet_no", "packetNo is required") + } + packet, err := s.loadPacketByNo(ctx, packetNo) + if err != nil { + return nil, err + } + if packet == nil { + return nil, NewAppError(http.StatusNotFound, "voice_room_red_packet_not_found", "red packet not found") + } + + claims, err := s.loadSuccessfulClaims(ctx, packet.ID) + if err != nil { + return nil, err + } + profiles, err := s.mapClaimUserProfiles(ctx, claims) + if err != nil { + return nil, err + } + + resp := &ClaimRecordListResponse{ + PacketNo: packet.PacketNo, + TotalCount: packet.TotalCount, + ClaimedCount: len(claims), + Records: make([]ClaimRecordPayload, 0, len(claims)), + } + for _, claim := range claims { + profile := profiles[claim.UserID] + resp.ClaimedAmount += claim.Amount + resp.Records = append(resp.Records, ClaimRecordPayload{ + ID: claim.ID, + UserID: claim.UserID, + Account: defaultIfBlank(profile.Account, fmt.Sprint(claim.UserID)), + UserNickname: defaultIfBlank(profile.UserNickname, defaultIfBlank(profile.Account, fmt.Sprint(claim.UserID))), + UserAvatar: profile.UserAvatar, + CountryCode: profile.CountryCode, + CountryName: profile.CountryName, + UserRegionCode: claim.UserRegionCode, + Amount: claim.Amount, + WalletStatus: claim.WalletStatus, + ClaimTime: formatTime(claim.ClaimTime), + }) + } + return resp, nil +} + +// PageSentRecords 返回当前用户发出的红包记录。 +func (s *Service) PageSentRecords(ctx context.Context, user AuthUser, cursor, limit int) (*SentRecordPageResponse, error) { + cursor, limit = normalizePage(cursor, limit) + var rows []model.VoiceRoomRedPacket + query := s.db.WithContext(ctx). + Where("sys_origin = ? AND sender_user_id = ?", normalizeSysOrigin(user.SysOrigin), user.UserID). + Order("create_time desc"). + Order("id desc") + var total int64 + if err := query.Model(&model.VoiceRoomRedPacket{}).Count(&total).Error; err != nil { + return nil, err + } + if err := query.Offset((cursor - 1) * limit).Limit(limit).Find(&rows).Error; err != nil { + return nil, err + } + return &SentRecordPageResponse{Records: packetPayloads(rows), Total: total}, nil +} + +// PageAdminRecords 返回后台红包记录。 +func (s *Service) PageAdminRecords(ctx context.Context, sysOrigin, status string, userID int64, cursor, limit int) (*SentRecordPageResponse, error) { + cursor, limit = normalizePage(cursor, limit) + query := s.db.WithContext(ctx).Model(&model.VoiceRoomRedPacket{}) + if strings.TrimSpace(sysOrigin) != "" { + query = query.Where("sys_origin = ?", normalizeSysOrigin(sysOrigin)) + } + if strings.TrimSpace(status) != "" { + query = query.Where("status = ?", strings.ToUpper(strings.TrimSpace(status))) + } + if userID > 0 { + query = query.Where("sender_user_id = ?", userID) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, err + } + var rows []model.VoiceRoomRedPacket + if err := query.Order("create_time desc").Order("id desc").Offset((cursor - 1) * limit).Limit(limit).Find(&rows).Error; err != nil { + return nil, err + } + return &SentRecordPageResponse{Records: packetPayloads(rows), Total: total}, nil +} + +func (s *Service) loadClaims(ctx context.Context, packetID int64) ([]model.VoiceRoomRedPacketClaim, error) { + var rows []model.VoiceRoomRedPacketClaim + if err := s.db.WithContext(ctx). + Where("packet_id = ?", packetID). + Order("claim_time asc"). + Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +func (s *Service) loadSuccessfulClaims(ctx context.Context, packetID int64) ([]model.VoiceRoomRedPacketClaim, error) { + var rows []model.VoiceRoomRedPacketClaim + if err := s.db.WithContext(ctx). + Where("packet_id = ? AND wallet_status = ?", packetID, walletStatusSuccess). + Order("claim_time asc"). + Order("id asc"). + Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +func (s *Service) mapClaimUserProfiles(ctx context.Context, claims []model.VoiceRoomRedPacketClaim) (map[int64]model.UserBaseInfo, error) { + userIDs := make([]int64, 0, len(claims)) + seen := map[int64]struct{}{} + for _, claim := range claims { + if claim.UserID <= 0 { + continue + } + if _, exists := seen[claim.UserID]; exists { + continue + } + seen[claim.UserID] = struct{}{} + userIDs = append(userIDs, claim.UserID) + } + if len(userIDs) == 0 { + return map[int64]model.UserBaseInfo{}, nil + } + + var users []model.UserBaseInfo + if err := s.db.WithContext(ctx).Where("id IN ?", userIDs).Find(&users).Error; err != nil { + return nil, err + } + result := make(map[int64]model.UserBaseInfo, len(users)) + for _, user := range users { + result[user.ID] = user + } + return result, nil +} + +func packetPayloads(rows []model.VoiceRoomRedPacket) []PacketPayload { + result := make([]PacketPayload, 0, len(rows)) + for _, row := range rows { + result = append(result, packetPayload(row)) + } + return result +} + +func defaultIfBlank(value string, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} + +func ParseUserID(raw string) int64 { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0 + } + var userID int64 + _, _ = fmt.Sscan(raw, &userID) + return userID +} + +func ignoreRecordNotFound(err error) error { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err +} diff --git a/internal/service/voiceroomredpacket/refund.go b/internal/service/voiceroomredpacket/refund.go new file mode 100644 index 0000000..33bb5b1 --- /dev/null +++ b/internal/service/voiceroomredpacket/refund.go @@ -0,0 +1,192 @@ +package voiceroomredpacket + +import ( + "context" + "errors" + "fmt" + "log" + "time" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// StartRefundWorker 启动过期红包退款扫描。 +func (s *Service) StartRefundWorker(ctx context.Context) { + interval := refundInterval(s.cfg) + ticker := time.NewTicker(interval) + go func() { + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if _, err := s.ProcessExpired(ctx, refundBatchSize(s.cfg)); err != nil { + log.Printf("voice room red packet refund worker failed: %v", err) + } + } + } + }() +} + +// ProcessExpired 扫描并处理过期红包退款。 +func (s *Service) ProcessExpired(ctx context.Context, limit int) (*RefundResult, error) { + if limit <= 0 { + limit = defaultRefundBatchSize + } + var rows []model.VoiceRoomRedPacket + if err := s.db.WithContext(ctx). + Where("(status = ? AND expire_time <= ?) OR status = ?", packetStatusActive, time.Now(), packetStatusRefundFailed). + Order("expire_time asc"). + Limit(limit). + Find(&rows).Error; err != nil { + return nil, err + } + result := &RefundResult{Scanned: len(rows)} + for _, row := range rows { + if err := s.refundOne(ctx, row.PacketNo, false); err != nil { + result.Failed++ + log.Printf("voice room red packet refund failed packetNo=%s err=%v", row.PacketNo, err) + continue + } + result.Success++ + } + return result, nil +} + +// RetryRefund 后台手动重试指定红包退款。 +func (s *Service) RetryRefund(ctx context.Context, packetNo string) error { + return s.refundOne(ctx, packetNo, true) +} + +func (s *Service) refundOne(ctx context.Context, packetNo string, force bool) error { + lockKey := fmt.Sprintf("voice_room:red_packet:refund_lock:%s", packetNo) + lockOK, err := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result() + if err != nil { + return err + } + if !lockOK { + return nil + } + defer s.redis.Del(context.Background(), lockKey) + + var packet model.VoiceRoomRedPacket + refundAmount := int64(0) + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("packet_no = ?", packetNo). + First(&packet).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + if err != nil { + return err + } + if packet.Status == packetStatusRefunded || packet.Status == packetStatusFinished { + return nil + } + if packet.Status != packetStatusActive && packet.Status != packetStatusRefundFailed { + return nil + } + if packet.Status == packetStatusActive && time.Now().Before(packet.ExpireTime) && !force { + return nil + } + now := time.Now() + if packet.RefundAmount > 0 && packet.Status == packetStatusRefundFailed { + refundAmount = packet.RefundAmount + } else { + var total struct { + Amount int64 + } + if err := tx.Model(&model.VoiceRoomRedPacketSlice{}). + Select("COALESCE(SUM(amount), 0) AS amount"). + Where("packet_id = ? AND status = ?", packet.ID, sliceStatusUnclaimed). + Scan(&total).Error; err != nil { + return err + } + refundAmount = total.Amount + if err := tx.Model(&model.VoiceRoomRedPacketSlice{}). + Where("packet_id = ? AND status = ?", packet.ID, sliceStatusUnclaimed). + Updates(map[string]any{"status": sliceStatusReleased, "update_time": now}).Error; err != nil { + return err + } + } + if refundAmount <= 0 { + return tx.Model(&model.VoiceRoomRedPacket{}). + Where("id = ?", packet.ID). + Updates(map[string]any{ + "status": packetStatusFinished, + "refund_status": refundStatusNone, + "refund_amount": 0, + "update_time": now, + }).Error + } + packet.RefundWalletEventID = walletEventID(walletOriginRefund, packet.SysOrigin, packet.PacketNo, 0) + packet.RefundAmount = refundAmount + return tx.Model(&model.VoiceRoomRedPacket{}). + Where("id = ?", packet.ID). + Updates(map[string]any{ + "status": packetStatusExpired, + "refund_status": refundStatusPending, + "refund_wallet_event_id": packet.RefundWalletEventID, + "refund_amount": refundAmount, + "update_time": now, + }).Error + }); err != nil { + return err + } + if packet.ID == 0 || refundAmount <= 0 { + return nil + } + + if err := s.wallet.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ + ReceiptType: walletReceiptIncome, + UserID: packet.SenderUserID, + SysOrigin: packet.SysOrigin, + EventID: packet.RefundWalletEventID, + Remark: fmt.Sprintf("voice room red packet refund %s", packet.PacketNo), + Amount: integration.NewPennyAmountPayloadFromDollar(refundAmount), + CloseDelayAsset: false, + OpUserType: "APP", + CustomizeOrigin: walletOriginRefund, + CustomizeOriginDesc: walletOriginRefundDesc, + }); err != nil { + _ = s.db.WithContext(context.Background()).Model(&model.VoiceRoomRedPacket{}). + Where("id = ?", packet.ID). + Updates(map[string]any{ + "status": packetStatusRefundFailed, + "refund_status": refundStatusFailed, + "error_message": trimErrorMessage(err.Error()), + "update_time": time.Now(), + }).Error + return err + } + + return s.db.WithContext(ctx).Model(&model.VoiceRoomRedPacket{}). + Where("id = ?", packet.ID). + Updates(map[string]any{ + "status": packetStatusRefunded, + "refund_status": refundStatusSuccess, + "error_message": "", + "update_time": time.Now(), + }).Error +} + +func refundInterval(cfg config.Config) time.Duration { + if cfg.VoiceRoomRedPacket.RefundScanIntervalSeconds <= 0 { + return defaultRefundInterval + } + return time.Duration(cfg.VoiceRoomRedPacket.RefundScanIntervalSeconds) * time.Second +} + +func refundBatchSize(cfg config.Config) int { + if cfg.VoiceRoomRedPacket.RefundBatchSize <= 0 { + return defaultRefundBatchSize + } + return cfg.VoiceRoomRedPacket.RefundBatchSize +} diff --git a/internal/service/voiceroomredpacket/send.go b/internal/service/voiceroomredpacket/send.go new file mode 100644 index 0000000..b6ebe02 --- /dev/null +++ b/internal/service/voiceroomredpacket/send.go @@ -0,0 +1,329 @@ +package voiceroomredpacket + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// Send 创建语音房红包并完成发送人扣款。 +func (s *Service) Send(ctx context.Context, user AuthUser, req SendRequest) (*SendResponse, error) { + req.RequestID = strings.TrimSpace(req.RequestID) + req.PacketMode = normalizePacketMode(req.PacketMode) + sysOrigin := normalizeSysOrigin(user.SysOrigin) + roomID := req.RoomID.Int64() + if req.RequestID == "" { + return nil, NewAppError(http.StatusBadRequest, "missing_request_id", "requestId is required") + } + if roomID <= 0 { + return nil, NewAppError(http.StatusBadRequest, "missing_room_id", "roomId is required") + } + if req.PacketMode != packetModeImmediate && req.PacketMode != packetModeDelayed { + return nil, NewAppError(http.StatusBadRequest, "voice_room_red_packet_mode_invalid", "packetMode is invalid") + } + + if existing, err := s.loadPacketByRequestID(ctx, req.RequestID); err != nil { + return nil, err + } else if existing != nil { + return s.buildSendResponse(ctx, *existing) + } + + lockKey := fmt.Sprintf("voice_room:red_packet:send_lock:%s:%s", sysOrigin, req.RequestID) + lockOK, err := s.redis.SetNX(ctx, lockKey, "1", 15*time.Second).Result() + if err != nil { + return nil, NewAppError(http.StatusServiceUnavailable, "voice_room_red_packet_lock_failed", err.Error()) + } + if !lockOK { + return nil, NewAppError(http.StatusConflict, "voice_room_red_packet_in_progress", "red packet send is in progress") + } + defer s.redis.Del(context.Background(), lockKey) + + if existing, err := s.loadPacketByRequestID(ctx, req.RequestID); err != nil { + return nil, err + } else if existing != nil { + return s.buildSendResponse(ctx, *existing) + } + + cfg, err := s.loadConfig(ctx, sysOrigin) + if err != nil { + return nil, err + } + if !cfg.Enabled { + return nil, NewAppError(http.StatusBadRequest, "voice_room_red_packet_disabled", "voice room red packet is disabled") + } + if !containsInt64(cfg.AmountOptions, req.TotalAmount) { + return nil, NewAppError(http.StatusBadRequest, "voice_room_red_packet_amount_invalid", "totalAmount is invalid") + } + if !containsInt(cfg.CountOptions, req.TotalCount) { + return nil, NewAppError(http.StatusBadRequest, "voice_room_red_packet_count_invalid", "totalCount is invalid") + } + if err := s.ensureUserInRoom(ctx, sysOrigin, user.UserID, roomID); err != nil { + return nil, err + } + regionCode, err := s.resolveCachedUserRegion(ctx, sysOrigin, user.UserID) + if err != nil { + return nil, err + } + + var packet model.VoiceRoomRedPacket + var day string + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if existing, err := loadPacketByRequestIDTx(tx, req.RequestID); err != nil { + return err + } else if existing != nil { + packet = *existing + return nil + } + + location := resolveLocation(cfg.Timezone) + now := time.Now() + localNow := now.In(location) + day = dayKey(localNow) + counter, err := s.incrementDailyCounterTx(tx, sysOrigin, user.UserID, day, cfg.DailySendLimit, now) + if err != nil { + return err + } + if counter.SendCount > cfg.DailySendLimit { + return NewAppError(http.StatusBadRequest, "voice_room_red_packet_daily_limit_exceeded", "daily send limit exceeded") + } + + packetID, err := utils.NextID() + if err != nil { + return err + } + packetNo := buildPacketNo(localNow, packetID) + claimStart := now + if req.PacketMode == packetModeDelayed { + claimStart = now.Add(time.Duration(cfg.DelaySeconds) * time.Second) + } + expireAt := now.Add(time.Duration(cfg.RefundAfterSeconds) * time.Second) + packet = model.VoiceRoomRedPacket{ + ID: packetID, + PacketNo: packetNo, + RequestID: req.RequestID, + SysOrigin: sysOrigin, + RoomID: roomID, + SenderUserID: user.UserID, + SenderRegionCode: regionCode, + PacketMode: req.PacketMode, + AmountMode: amountModeRandom, + TotalAmount: req.TotalAmount, + TotalCount: req.TotalCount, + RemainAmount: req.TotalAmount, + RemainCount: req.TotalCount, + ClaimStartTime: claimStart, + ExpireTime: expireAt, + Status: packetStatusInit, + SendWalletEventID: walletEventID(walletOriginSend, sysOrigin, packetNo, 0), + RefundStatus: refundStatusNone, + CreateTime: now, + UpdateTime: now, + } + if err := tx.Create(&packet).Error; err != nil { + return err + } + amounts, err := splitRandomAmounts(req.TotalAmount, req.TotalCount, packetID) + if err != nil { + return err + } + if sumAmounts(amounts) != req.TotalAmount { + return NewAppError(http.StatusInternalServerError, "voice_room_red_packet_amount_split_invalid", "split amount total mismatch") + } + slices := make([]model.VoiceRoomRedPacketSlice, 0, len(amounts)) + for index, amount := range amounts { + sliceID, err := utils.NextID() + if err != nil { + return err + } + slices = append(slices, model.VoiceRoomRedPacketSlice{ + ID: sliceID, + PacketID: packet.ID, + PacketNo: packet.PacketNo, + SortNo: index + 1, + Amount: amount, + Status: sliceStatusUnclaimed, + CreateTime: now, + UpdateTime: now, + }) + } + return tx.Create(&slices).Error + }); err != nil { + return nil, err + } + + if packet.Status != packetStatusInit { + return s.buildSendResponse(ctx, packet) + } + + if err := s.wallet.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ + ReceiptType: walletReceiptExpenditure, + UserID: user.UserID, + SysOrigin: sysOrigin, + EventID: packet.SendWalletEventID, + Remark: fmt.Sprintf("voice room red packet %s room=%d", packet.PacketNo, packet.RoomID), + Amount: integration.NewPennyAmountPayloadFromDollar(packet.TotalAmount), + CloseDelayAsset: false, + OpUserType: "APP", + CustomizeOrigin: walletOriginSend, + CustomizeOriginDesc: walletOriginSendDesc, + }); err != nil { + _ = s.markSendFailed(context.Background(), packet.ID, sysOrigin, user.UserID, day, err.Error()) + return nil, mapWalletError("voice_room_red_packet_wallet_deduct_failed", err) + } + + now := time.Now() + if err := s.db.WithContext(ctx).Model(&model.VoiceRoomRedPacket{}). + Where("id = ? AND status = ?", packet.ID, packetStatusInit). + Updates(map[string]any{ + "status": packetStatusActive, + "refund_status": refundStatusPending, + "error_message": "", + "update_time": now, + }).Error; err != nil { + return nil, err + } + packet.Status = packetStatusActive + packet.RefundStatus = refundStatusPending + packet.UpdateTime = now + + _ = s.redis.ZAdd(ctx, expireZSetKey(s.cfg), redis.Z{ + Score: float64(packet.ExpireTime.Unix()), + Member: packet.PacketNo, + }).Err() + if err := s.notifyCreated(ctx, packet); err != nil { + logNotifyFailure(packet.PacketNo, err) + _ = s.db.WithContext(context.Background()).Model(&model.VoiceRoomRedPacket{}). + Where("id = ?", packet.ID). + Update("error_message", trimErrorMessage(err.Error())).Error + } + return s.buildSendResponse(ctx, packet) +} + +func (s *Service) loadPacketByRequestID(ctx context.Context, requestID string) (*model.VoiceRoomRedPacket, error) { + return loadPacketByRequestIDTx(s.db.WithContext(ctx), requestID) +} + +func loadPacketByRequestIDTx(tx *gorm.DB, requestID string) (*model.VoiceRoomRedPacket, error) { + var row model.VoiceRoomRedPacket + err := tx.Where("request_id = ?", requestID).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &row, nil +} + +func (s *Service) incrementDailyCounterTx(tx *gorm.DB, sysOrigin string, userID int64, day string, limit int, now time.Time) (*model.VoiceRoomRedPacketDailyCounter, error) { + var row model.VoiceRoomRedPacketDailyCounter + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("sys_origin = ? AND user_id = ? AND day_key = ?", sysOrigin, userID, day). + First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + id, idErr := utils.NextID() + if idErr != nil { + return nil, idErr + } + row = model.VoiceRoomRedPacketDailyCounter{ + ID: id, + SysOrigin: sysOrigin, + UserID: userID, + DayKey: day, + SendCount: 1, + CreateTime: now, + UpdateTime: now, + } + if limit > 0 && row.SendCount > limit { + return &row, nil + } + if err := tx.Create(&row).Error; err != nil { + return nil, err + } + return &row, nil + } + if err != nil { + return nil, err + } + row.SendCount++ + row.UpdateTime = now + if limit > 0 && row.SendCount > limit { + return &row, nil + } + if err := tx.Save(&row).Error; err != nil { + return nil, err + } + return &row, nil +} + +func (s *Service) markSendFailed(ctx context.Context, packetID int64, sysOrigin string, userID int64, day string, message string) error { + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.VoiceRoomRedPacket{}). + Where("id = ?", packetID). + Updates(map[string]any{ + "status": packetStatusFailed, + "error_message": trimErrorMessage(message), + "update_time": time.Now(), + }).Error; err != nil { + return err + } + if day != "" { + if err := tx.Model(&model.VoiceRoomRedPacketDailyCounter{}). + Where("sys_origin = ? AND user_id = ? AND day_key = ? AND send_count > 0", sysOrigin, userID, day). + Update("send_count", gorm.Expr("send_count - 1")).Error; err != nil { + return err + } + } + return nil + }) +} + +func (s *Service) buildSendResponse(ctx context.Context, packet model.VoiceRoomRedPacket) (*SendResponse, error) { + if packet.Status == packetStatusFailed { + return nil, NewAppError(http.StatusBadRequest, "voice_room_red_packet_send_failed", packet.ErrorMessage) + } + balanceAfter := int64(0) + if s.wallet != nil { + balances, err := s.wallet.MapGoldBalance(ctx, []int64{packet.SenderUserID}) + if err != nil { + return nil, NewAppError(http.StatusBadGateway, "voice_room_red_packet_wallet_balance_failed", err.Error()) + } + balanceAfter = balances[packet.SenderUserID] + } + return &SendResponse{ + PacketNo: packet.PacketNo, + RoomID: packet.RoomID, + PacketMode: packet.PacketMode, + TotalAmount: packet.TotalAmount, + TotalCount: packet.TotalCount, + RemainAmount: packet.RemainAmount, + RemainCount: packet.RemainCount, + ClaimStartTime: formatTime(packet.ClaimStartTime), + ExpireTime: formatTime(packet.ExpireTime), + Status: packet.Status, + BalanceAfter: balanceAfter, + }, nil +} + +func buildPacketNo(now time.Time, id int64) string { + return fmt.Sprintf("VRP%s%d", now.Format("20060102150405"), id%100000000) +} + +func expireZSetKey(cfg config.Config) string { + if value := strings.TrimSpace(cfg.VoiceRoomRedPacket.ExpireZSetKey); value != "" { + return value + } + return defaultExpireZSetKey +} diff --git a/internal/service/voiceroomredpacket/service_test.go b/internal/service/voiceroomredpacket/service_test.go new file mode 100644 index 0000000..9d07bc8 --- /dev/null +++ b/internal/service/voiceroomredpacket/service_test.go @@ -0,0 +1,466 @@ +package voiceroomredpacket + +import ( + "context" + "strings" + "testing" + "time" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestSplitRandomAmountsKeepsTotalAndMinimum(t *testing.T) { + amounts, err := splitRandomAmounts(15000, 50, 1) + if err != nil { + t.Fatalf("splitRandomAmounts() error = %v", err) + } + if len(amounts) != 50 { + t.Fatalf("len(amounts) = %d, want 50", len(amounts)) + } + if got := sumAmounts(amounts); got != 15000 { + t.Fatalf("sumAmounts() = %d, want 15000", got) + } + for _, amount := range amounts { + if amount <= 0 { + t.Fatalf("amount = %d, want > 0", amount) + } + } +} + +func TestSendAndClaimImmediatePacket(t *testing.T) { + service, wallet, _ := newVoiceRoomRedPacketTestService(t) + ctx := context.Background() + sender := AuthUser{UserID: 1001, SysOrigin: "LIKEI"} + claimer := AuthUser{UserID: 1002, SysOrigin: "LIKEI"} + + if _, err := service.Heartbeat(ctx, sender, PresenceRequest{RoomID: 9001}); err != nil { + t.Fatalf("sender Heartbeat() error = %v", err) + } + if _, err := service.Heartbeat(ctx, claimer, PresenceRequest{RoomID: 9001}); err != nil { + t.Fatalf("claimer Heartbeat() error = %v", err) + } + sendResp, err := service.Send(ctx, sender, SendRequest{ + RequestID: "req-send-1", + RoomID: 9001, + PacketMode: packetModeImmediate, + TotalAmount: 15000, + TotalCount: 5, + }) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + if sendResp.PacketNo == "" || sendResp.Status != packetStatusActive { + t.Fatalf("Send() response = %+v", sendResp) + } + claimResp, err := service.Claim(ctx, claimer, ClaimRequest{PacketNo: sendResp.PacketNo}) + if err != nil { + t.Fatalf("Claim() error = %v", err) + } + if claimResp.Amount <= 0 || claimResp.RemainCount != 4 { + t.Fatalf("Claim() response = %+v", claimResp) + } + if wallet.balance[1001] != 85000 { + t.Fatalf("sender balance = %d, want 85000", wallet.balance[1001]) + } + if wallet.balance[1002] != 100000+claimResp.Amount { + t.Fatalf("claimer balance = %d, want %d", wallet.balance[1002], 100000+claimResp.Amount) + } + + repeated, err := service.Claim(ctx, claimer, ClaimRequest{PacketNo: sendResp.PacketNo}) + if err != nil { + t.Fatalf("repeat Claim() error = %v", err) + } + if repeated.Amount != claimResp.Amount || repeated.RemainCount != claimResp.RemainCount { + t.Fatalf("repeat Claim() = %+v, want original %+v", repeated, claimResp) + } + assertWalletCommandsFitWalletSchema(t, wallet.calls) + if wallet.calls[0].CustomizeOriginDesc != walletOriginSendDesc { + t.Fatalf("send wallet desc = %q, want %q", wallet.calls[0].CustomizeOriginDesc, walletOriginSendDesc) + } + if wallet.calls[1].CustomizeOriginDesc != walletOriginClaimDesc { + t.Fatalf("claim wallet desc = %q, want %q", wallet.calls[1].CustomizeOriginDesc, walletOriginClaimDesc) + } + + claimRecords, err := service.ListClaimRecords(ctx, sender, sendResp.PacketNo) + if err != nil { + t.Fatalf("ListClaimRecords() error = %v", err) + } + if claimRecords.PacketNo != sendResp.PacketNo || + claimRecords.TotalCount != 5 || + claimRecords.ClaimedCount != 1 || + claimRecords.ClaimedAmount != claimResp.Amount { + t.Fatalf("ListClaimRecords() = %+v, claimResp = %+v", claimRecords, claimResp) + } + if len(claimRecords.Records) != 1 { + t.Fatalf("records len = %d, want 1", len(claimRecords.Records)) + } + record := claimRecords.Records[0] + if record.UserID != claimer.UserID || + record.Account != "1002" || + record.UserNickname != "claimer" || + record.UserAvatar != "claimer.png" || + record.Amount != claimResp.Amount || + record.ClaimTime == "" { + t.Fatalf("claim record = %+v, claimResp = %+v", record, claimResp) + } +} + +func TestDelayedPacketRejectsEarlyClaim(t *testing.T) { + service, _, _ := newVoiceRoomRedPacketTestService(t) + ctx := context.Background() + sender := AuthUser{UserID: 1001, SysOrigin: "LIKEI"} + claimer := AuthUser{UserID: 1002, SysOrigin: "LIKEI"} + _, _ = service.Heartbeat(ctx, sender, PresenceRequest{RoomID: 9001}) + _, _ = service.Heartbeat(ctx, claimer, PresenceRequest{RoomID: 9001}) + + sendResp, err := service.Send(ctx, sender, SendRequest{ + RequestID: "req-delayed-1", + RoomID: 9001, + PacketMode: packetModeDelayed, + TotalAmount: 15000, + TotalCount: 5, + }) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + if _, err := service.Claim(ctx, claimer, ClaimRequest{PacketNo: sendResp.PacketNo}); err == nil { + t.Fatalf("Claim() error = nil, want not started") + } +} + +func TestDelayedPacketSendsRegionIMBroadcastOnly(t *testing.T) { + service, _, _ := newVoiceRoomRedPacketTestService(t) + fakeRegionIM := &fakeRegionIMGateway{} + service.regionIM = fakeRegionIM + ctx := context.Background() + sender := AuthUser{UserID: 1001, SysOrigin: "LIKEI"} + if _, err := service.Heartbeat(ctx, sender, PresenceRequest{RoomID: 9001}); err != nil { + t.Fatalf("Heartbeat() error = %v", err) + } + sendResp, err := service.Send(ctx, sender, SendRequest{ + RequestID: "req-delayed-im-1", + RoomID: 9001, + PacketMode: packetModeDelayed, + TotalAmount: 15000, + TotalCount: 5, + }) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + if len(fakeRegionIM.sent) != 1 { + t.Fatalf("sent messages = %+v, want one region message", fakeRegionIM.sent) + } + if fakeRegionIM.sent[0].sysOrigin != "LIKEI" || fakeRegionIM.sent[0].regionCode != "AR" { + t.Fatalf("sent target = %+v, want LIKEI/AR", fakeRegionIM.sent[0]) + } + body, ok := fakeRegionIM.sent[0].body.(map[string]any) + if !ok { + t.Fatalf("sent body type = %T", fakeRegionIM.sent[0].body) + } + if body["type"] != "ROOM_RED_PACKET" { + t.Fatalf("sent type = %v, want ROOM_RED_PACKET", body["type"]) + } + data, ok := body["data"].(map[string]any) + if !ok { + t.Fatalf("sent data type = %T", body["data"]) + } + if data["roomId"] != "9001" || data["packetId"] != sendResp.PacketNo || data["regionCode"] != "AR" { + t.Fatalf("sent data = %+v", data) + } +} + +func TestImmediatePacketSendsRoomIMMessage(t *testing.T) { + service, _, _ := newVoiceRoomRedPacketTestService(t) + fakeIM := &fakeIMGateway{} + service.im = fakeIM + service.roomProfiles = fakeRoomProfileGateway{ + profiles: map[int64]integration.RoomProfile{ + 9001: { + ID: integration.Int64Value(9001), + RoomAccount: "900100", + }, + }, + } + ctx := context.Background() + sender := AuthUser{UserID: 1001, SysOrigin: "LIKEI"} + if _, err := service.Heartbeat(ctx, sender, PresenceRequest{RoomID: 9001}); err != nil { + t.Fatalf("Heartbeat() error = %v", err) + } + sendResp, err := service.Send(ctx, sender, SendRequest{ + RequestID: "req-immediate-im-1", + RoomID: 9001, + PacketMode: packetModeImmediate, + TotalAmount: 15000, + TotalCount: 5, + }) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + if len(fakeIM.sent) != 1 { + t.Fatalf("sent messages = %+v, want one room message", fakeIM.sent) + } + if fakeIM.sent[0].groupID != "900100" { + t.Fatalf("sent group = %s, want 900100", fakeIM.sent[0].groupID) + } + body, ok := fakeIM.sent[0].body.(map[string]any) + if !ok { + t.Fatalf("sent body type = %T", fakeIM.sent[0].body) + } + if body["type"] != "ROOM_RED_PACKET" { + t.Fatalf("sent type = %v, want ROOM_RED_PACKET", body["type"]) + } + data, ok := body["data"].(map[string]any) + if !ok { + t.Fatalf("sent data type = %T", body["data"]) + } + if data["roomId"] != "9001" || data["roomAccount"] != "900100" || + data["packetId"] != sendResp.PacketNo || data["packetMode"] != packetModeImmediate { + t.Fatalf("sent data = %+v", data) + } +} + +func TestProcessExpiredRefundsUnclaimedSlices(t *testing.T) { + service, wallet, db := newVoiceRoomRedPacketTestService(t) + ctx := context.Background() + sender := AuthUser{UserID: 1001, SysOrigin: "LIKEI"} + claimer := AuthUser{UserID: 1002, SysOrigin: "LIKEI"} + _, _ = service.Heartbeat(ctx, sender, PresenceRequest{RoomID: 9001}) + _, _ = service.Heartbeat(ctx, claimer, PresenceRequest{RoomID: 9001}) + + sendResp, err := service.Send(ctx, sender, SendRequest{ + RequestID: "req-refund-1", + RoomID: 9001, + PacketMode: packetModeImmediate, + TotalAmount: 15000, + TotalCount: 5, + }) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + claimResp, err := service.Claim(ctx, claimer, ClaimRequest{PacketNo: sendResp.PacketNo}) + if err != nil { + t.Fatalf("Claim() error = %v", err) + } + if err := db.Model(&model.VoiceRoomRedPacket{}). + Where("packet_no = ?", sendResp.PacketNo). + Update("expire_time", time.Now().Add(-time.Minute)).Error; err != nil { + t.Fatalf("force expire: %v", err) + } + result, err := service.ProcessExpired(ctx, 10) + if err != nil { + t.Fatalf("ProcessExpired() error = %v", err) + } + if result.Success != 1 || result.Failed != 0 { + t.Fatalf("ProcessExpired() = %+v", result) + } + wantSenderBalance := int64(100000 - 15000 + (15000 - claimResp.Amount)) + if wallet.balance[1001] != wantSenderBalance { + t.Fatalf("sender balance = %d, want %d", wallet.balance[1001], wantSenderBalance) + } + var packet model.VoiceRoomRedPacket + if err := db.Where("packet_no = ?", sendResp.PacketNo).First(&packet).Error; err != nil { + t.Fatalf("load packet: %v", err) + } + if packet.Status != packetStatusRefunded || packet.RefundAmount != 15000-claimResp.Amount { + t.Fatalf("packet after refund = %+v", packet) + } + assertWalletCommandsFitWalletSchema(t, wallet.calls) + if wallet.calls[len(wallet.calls)-1].CustomizeOriginDesc != walletOriginRefundDesc { + t.Fatalf("refund wallet desc = %q, want %q", wallet.calls[len(wallet.calls)-1].CustomizeOriginDesc, walletOriginRefundDesc) + } +} + +func TestWalletEventIDFitsJavaWalletSchema(t *testing.T) { + packetNo := buildPacketNo(time.Date(2026, 5, 8, 10, 18, 53, 0, time.UTC), 2052649421790744577) + cases := map[string]string{ + "send": walletEventID(walletOriginSend, "LIKEI", packetNo, 0), + "claim": walletEventID(walletOriginClaim, "LIKEI", packetNo, 2052643780674195456), + "refund": walletEventID(walletOriginRefund, "LIKEI", packetNo, 0), + } + for name, eventID := range cases { + if len(eventID) > 50 { + t.Fatalf("%s wallet event ID length = %d, want <= 50: %s", name, len(eventID), eventID) + } + } +} + +func newVoiceRoomRedPacketTestService(t *testing.T) (*Service, *fakeWalletGateway, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate( + &model.UserBaseInfo{}, + &model.VoiceRoomRedPacketConfig{}, + &model.VoiceRoomRegionCountry{}, + &model.VoiceRoomRegionIMGroup{}, + &model.RoomLiveBroadcastVIP{}, + &model.VoiceRoomRedPacket{}, + &model.VoiceRoomRedPacketSlice{}, + &model.VoiceRoomRedPacketClaim{}, + &model.VoiceRoomRedPacketDailyCounter{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + now := time.Now() + if err := db.Create(&[]model.UserBaseInfo{ + {ID: 1001, Account: "1001", UserNickname: "sender", UserAvatar: "sender.png", OriginSys: "LIKEI", CountryCode: "SA", CountryName: "Saudi Arabia", CreateTime: now}, + {ID: 1002, Account: "1002", UserNickname: "claimer", UserAvatar: "claimer.png", OriginSys: "LIKEI", CountryCode: "SA", CountryName: "Saudi Arabia", CreateTime: now}, + }).Error; err != nil { + t.Fatalf("seed users: %v", err) + } + if err := db.Create(&model.VoiceRoomRegionCountry{ + ID: 1, + SysOrigin: "LIKEI", + RegionCode: "AR", + CountryCode: "SA", + Enabled: true, + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed region: %v", err) + } + mr := miniredis.RunT(t) + redisClient := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = redisClient.Close() }) + wallet := &fakeWalletGateway{balance: map[int64]int64{1001: 100000, 1002: 100000}, events: map[string]struct{}{}} + service := NewService(config.Config{ + VoiceRoomRedPacket: config.VoiceRoomRedPacketConfig{ + PresenceTTLSeconds: 60, + RefundScanIntervalSeconds: 10, + RefundBatchSize: 100, + NotifyStreamKey: "test:voice_room:red_packet:notify", + ExpireZSetKey: "test:voice_room:red_packet:expire", + }, + }, db, redisClient, wallet, nil) + if _, err := service.SaveAdminConfig(context.Background(), SaveConfigRequest{ + SysOrigin: "LIKEI", + Enabled: true, + DailySendLimit: 10, + DelaySeconds: 300, + RefundAfterSeconds: 7200, + Timezone: "Asia/Riyadh", + AmountOptions: []int64{15000, 30000, 70000, 100000}, + CountOptions: []int{5, 10, 20, 50}, + }); err != nil { + t.Fatalf("SaveAdminConfig() error = %v", err) + } + return service, wallet, db +} + +type fakeRoomProfileGateway struct { + profiles map[int64]integration.RoomProfile +} + +func (g fakeRoomProfileGateway) MapRoomProfiles(_ context.Context, roomIDs []int64) (map[int64]integration.RoomProfile, error) { + result := make(map[int64]integration.RoomProfile, len(roomIDs)) + for _, roomID := range roomIDs { + if profile, ok := g.profiles[roomID]; ok { + result[roomID] = profile + } + } + return result, nil +} + +type fakeIMCreateRecord struct { + groupID string + groupName string +} + +type fakeIMSendRecord struct { + groupID string + body any +} + +type fakeIMGateway struct { + created []fakeIMCreateRecord + sent []fakeIMSendRecord +} + +func (g *fakeIMGateway) CreateAVChatRoom(_ context.Context, groupID string, groupName string) (string, error) { + g.created = append(g.created, fakeIMCreateRecord{groupID: groupID, groupName: groupName}) + return groupID, nil +} + +func (g *fakeIMGateway) SendCustomGroupMessage(_ context.Context, groupID string, body any) error { + g.sent = append(g.sent, fakeIMSendRecord{groupID: groupID, body: body}) + return nil +} + +type fakeRegionIMSendRecord struct { + sysOrigin string + regionCode string + body any +} + +type fakeRegionIMGateway struct { + sent []fakeRegionIMSendRecord +} + +func (g *fakeRegionIMGateway) SendRegionCustomMessage(_ context.Context, sysOrigin string, regionCode string, body any) (string, error) { + g.sent = append(g.sent, fakeRegionIMSendRecord{sysOrigin: sysOrigin, regionCode: regionCode, body: body}) + return "@region-" + strings.ToLower(regionCode), nil +} + +type fakeWalletGateway struct { + balance map[int64]int64 + events map[string]struct{} + calls []integration.GoldReceiptCommand +} + +func (g *fakeWalletGateway) ExistsGoldEvent(_ context.Context, eventID string) (bool, error) { + _, ok := g.events[eventID] + return ok, nil +} + +func (g *fakeWalletGateway) ChangeGoldBalance(_ context.Context, cmd integration.GoldReceiptCommand) error { + if _, exists := g.events[cmd.EventID]; exists { + return nil + } + g.calls = append(g.calls, cmd) + switch cmd.ReceiptType { + case walletReceiptExpenditure: + g.balance[cmd.UserID] -= cmd.Amount.DollarAmount + case walletReceiptIncome: + g.balance[cmd.UserID] += cmd.Amount.DollarAmount + } + g.events[cmd.EventID] = struct{}{} + return nil +} + +func (g *fakeWalletGateway) MapGoldBalance(_ context.Context, userIDs []int64) (map[int64]int64, error) { + result := make(map[int64]int64, len(userIDs)) + for _, userID := range userIDs { + result[userID] = g.balance[userID] + } + return result, nil +} + +func assertWalletCommandsFitWalletSchema(t *testing.T, commands []integration.GoldReceiptCommand) { + t.Helper() + if len(commands) == 0 { + t.Fatalf("wallet commands are empty") + } + for _, command := range commands { + if len(command.EventID) > 50 { + t.Fatalf("wallet event ID length = %d, want <= 50: %+v", len(command.EventID), command) + } + if len(command.CustomizeOrigin) > 50 { + t.Fatalf("wallet event type length = %d, want <= 50: %+v", len(command.CustomizeOrigin), command) + } + if len(command.CustomizeOriginDesc) > 150 { + t.Fatalf("wallet event desc length = %d, want <= 150: %+v", len(command.CustomizeOriginDesc), command) + } + if command.OpUserType != "APP" && command.OpUserType != "BACK" { + t.Fatalf("wallet op user type = %q, want APP or BACK: %+v", command.OpUserType, command) + } + } +} diff --git a/internal/service/voiceroomredpacket/types.go b/internal/service/voiceroomredpacket/types.go new file mode 100644 index 0000000..32b684e --- /dev/null +++ b/internal/service/voiceroomredpacket/types.go @@ -0,0 +1,517 @@ +package voiceroomredpacket + +import ( + "context" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/service/tencentim" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +const ( + packetModeImmediate = "IMMEDIATE" + packetModeDelayed = "DELAYED" + + amountModeRandom = "RANDOM" + + packetStatusInit = "INIT" + packetStatusActive = "ACTIVE" + packetStatusFinished = "FINISHED" + packetStatusExpired = "EXPIRED" + packetStatusRefunded = "REFUNDED" + packetStatusRefundFailed = "REFUND_FAILED" + packetStatusFailed = "FAILED" + + refundStatusNone = "NONE" + refundStatusPending = "PENDING" + refundStatusSuccess = "SUCCESS" + refundStatusFailed = "FAILED" + + sliceStatusUnclaimed = "UNCLAIMED" + sliceStatusClaiming = "CLAIMING" + sliceStatusClaimed = "CLAIMED" + sliceStatusReleased = "RELEASED" + + walletStatusPending = "PENDING" + walletStatusSuccess = "SUCCESS" + walletStatusFailed = "FAILED" + + walletReceiptIncome = "INCOME" + walletReceiptExpenditure = "EXPENDITURE" + + walletOriginSend = "VOICE_ROOM_RED_PACKET_SEND" + walletOriginClaim = "VOICE_ROOM_RED_PACKET_CLAIM" + walletOriginRefund = "VOICE_ROOM_RED_PACKET_REFUND" + + walletOriginSendDesc = "Send Voice Room Luck Bag" + walletOriginClaimDesc = "Receive Voice Room Luck Bag" + walletOriginRefundDesc = "Voice Room Luck Bag Refund" + + defaultSysOrigin = "LIKEI" + defaultTimezone = "Asia/Riyadh" + defaultDailySendLimit = 10 + defaultDelaySeconds = 300 + defaultRefundAfterSeconds = 7200 + defaultPresenceTTLSeconds = 60 + defaultRefundBatchSize = 100 + defaultRefundInterval = 10 * time.Second + defaultNotifyStreamKey = "voice_room:red_packet:notify" + defaultExpireZSetKey = "voice_room:red_packet:expire_zset" +) + +type AppError = common.AppError +type AuthUser = common.AuthUser + +var NewAppError = common.NewAppError + +type voiceRoomRedPacketDB interface { + WithContext(ctx context.Context) *gorm.DB +} + +type walletGateway interface { + ExistsGoldEvent(ctx context.Context, eventID string) (bool, error) + ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error + MapGoldBalance(ctx context.Context, userIDs []int64) (map[int64]int64, error) +} + +type imGateway interface { + SendCustomGroupMessage(ctx context.Context, groupID string, body any) error +} + +type regionIMGateway interface { + SendRegionCustomMessage(ctx context.Context, sysOrigin string, regionCode string, body any) (string, error) +} + +type roomProfileGateway interface { + MapRoomProfiles(ctx context.Context, roomIDs []int64) (map[int64]integration.RoomProfile, error) +} + +// Service 承接语音房红包配置、发送、领取、在线态和退款补偿。 +type Service struct { + cfg config.Config + db voiceRoomRedPacketDB + redis redis.Cmdable + wallet walletGateway + im imGateway + regionIM regionIMGateway + roomProfiles roomProfileGateway + notifier *redisNotifier +} + +func NewService(cfg config.Config, db voiceRoomRedPacketDB, cache redis.Cmdable, wallet walletGateway, regionIM regionIMGateway, roomProfiles ...roomProfileGateway) *Service { + service := &Service{ + cfg: cfg, + db: db, + redis: cache, + wallet: wallet, + im: tencentim.NewClient(cfg.TencentIM), + regionIM: regionIM, + notifier: newRedisNotifier(cache, notifyStreamKey(cfg)), + } + if len(roomProfiles) > 0 { + service.roomProfiles = roomProfiles[0] + } + return service +} + +type ConfigResponse struct { + Configured bool `json:"configured"` + ID int64 `json:"id,string"` + SysOrigin string `json:"sysOrigin"` + Enabled bool `json:"enabled"` + DailySendLimit int `json:"dailySendLimit"` + TodaySendCount int `json:"todaySendCount"` + DelaySeconds int `json:"delaySeconds"` + RefundAfterSeconds int `json:"refundAfterSeconds"` + Timezone string `json:"timezone"` + AmountOptions []int64 `json:"amountOptions"` + CountOptions []int `json:"countOptions"` + UpdateTime string `json:"updateTime,omitempty"` +} + +type SaveConfigRequest struct { + SysOrigin string `json:"sysOrigin"` + Enabled bool `json:"enabled"` + DailySendLimit int `json:"dailySendLimit"` + DelaySeconds int `json:"delaySeconds"` + RefundAfterSeconds int `json:"refundAfterSeconds"` + Timezone string `json:"timezone"` + AmountOptions []int64 `json:"amountOptions"` + CountOptions []int `json:"countOptions"` +} + +type flexibleInt64 int64 + +func (v *flexibleInt64) UnmarshalJSON(data []byte) error { + raw := strings.TrimSpace(string(data)) + switch raw { + case "", "null", `""`: + *v = 0 + return nil + } + if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) { + unquoted, err := strconv.Unquote(raw) + if err != nil { + return err + } + raw = strings.TrimSpace(unquoted) + } + parsed, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return fmt.Errorf("invalid int64 value: %w", err) + } + *v = flexibleInt64(parsed) + return nil +} + +func (v flexibleInt64) Int64() int64 { + return int64(v) +} + +type PresenceRequest struct { + RoomID flexibleInt64 `json:"roomId"` +} + +type PresenceResponse struct { + UserID int64 `json:"userId"` + SysOrigin string `json:"sysOrigin"` + RoomID int64 `json:"roomId"` + RegionCode string `json:"regionCode"` + TTLSeconds int `json:"ttlSeconds"` +} + +type SendRequest struct { + RequestID string `json:"requestId"` + RoomID flexibleInt64 `json:"roomId"` + PacketMode string `json:"packetMode"` + TotalAmount int64 `json:"totalAmount"` + TotalCount int `json:"totalCount"` +} + +type SendResponse struct { + PacketNo string `json:"packetNo"` + RoomID int64 `json:"roomId"` + PacketMode string `json:"packetMode"` + TotalAmount int64 `json:"totalAmount"` + TotalCount int `json:"totalCount"` + RemainAmount int64 `json:"remainAmount"` + RemainCount int `json:"remainCount"` + ClaimStartTime string `json:"claimStartTime"` + ExpireTime string `json:"expireTime"` + Status string `json:"status"` + BalanceAfter int64 `json:"balanceAfter"` +} + +type ClaimRequest struct { + PacketNo string `json:"packetNo"` +} + +type ClaimResponse struct { + PacketNo string `json:"packetNo"` + Amount int64 `json:"amount"` + RemainCount int `json:"remainCount"` + TotalCount int `json:"totalCount"` + ClaimTime string `json:"claimTime"` + Status string `json:"status"` +} + +type DetailResponse struct { + Packet PacketPayload `json:"packet"` + Claims []ClaimPayload `json:"claims"` + Grabbed bool `json:"grabbed"` + MyAmount int64 `json:"myAmount"` + Claimable bool `json:"claimable"` + NowMillis int64 `json:"nowMillis"` +} + +type PacketPayload struct { + PacketNo string `json:"packetNo"` + SysOrigin string `json:"sysOrigin"` + RoomID int64 `json:"roomId"` + SenderUserID int64 `json:"senderUserId"` + SenderRegionCode string `json:"senderRegionCode"` + PacketMode string `json:"packetMode"` + TotalAmount int64 `json:"totalAmount"` + TotalCount int `json:"totalCount"` + RemainAmount int64 `json:"remainAmount"` + RemainCount int `json:"remainCount"` + RefundAmount int64 `json:"refundAmount"` + Status string `json:"status"` + RefundStatus string `json:"refundStatus"` + ClaimStartTime string `json:"claimStartTime"` + ExpireTime string `json:"expireTime"` + CreateTime string `json:"createTime"` + UpdateTime string `json:"updateTime"` +} + +type ClaimPayload struct { + ID int64 `json:"id,string"` + UserID int64 `json:"userId,string"` + UserRegionCode string `json:"userRegionCode"` + Amount int64 `json:"amount"` + WalletStatus string `json:"walletStatus"` + ClaimTime string `json:"claimTime"` +} + +type ClaimRecordListResponse struct { + PacketNo string `json:"packetNo"` + TotalCount int `json:"totalCount"` + ClaimedCount int `json:"claimedCount"` + ClaimedAmount int64 `json:"claimedAmount"` + Records []ClaimRecordPayload `json:"records"` +} + +type ClaimRecordPayload struct { + ID int64 `json:"id,string"` + UserID int64 `json:"userId,string"` + Account string `json:"account"` + UserNickname string `json:"userNickname"` + UserAvatar string `json:"userAvatar"` + CountryCode string `json:"countryCode"` + CountryName string `json:"countryName"` + UserRegionCode string `json:"userRegionCode"` + Amount int64 `json:"amount"` + WalletStatus string `json:"walletStatus"` + ClaimTime string `json:"claimTime"` +} + +type SentRecordPageResponse struct { + Records []PacketPayload `json:"records"` + Total int64 `json:"total"` +} + +type RefundResult struct { + Scanned int `json:"scanned"` + Success int `json:"success"` + Failed int `json:"failed"` +} + +type configSnapshot struct { + ID int64 + SysOrigin string + Enabled bool + DailySendLimit int + DelaySeconds int + RefundAfterSeconds int + Timezone string + AmountOptions []int64 + CountOptions []int + Configured bool + UpdateTime time.Time +} + +type notificationMessage struct { + Type string `json:"type"` + Event string `json:"event"` + PacketNo string `json:"packetNo"` + RoomID int64 `json:"roomId"` + PacketMode string `json:"packetMode"` + SenderUserID int64 `json:"senderUserId"` + TotalAmount int64 `json:"totalAmount"` + TotalCount int `json:"totalCount"` + ClaimStartTime int64 `json:"claimStartTime"` + ExpireTime int64 `json:"expireTime"` +} + +func normalizeSysOrigin(sysOrigin string) string { + sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin)) + if sysOrigin == "" { + return defaultSysOrigin + } + return sysOrigin +} + +func normalizePacketMode(mode string) string { + mode = strings.ToUpper(strings.TrimSpace(mode)) + if mode == "" { + return packetModeImmediate + } + return mode +} + +func normalizeTimezone(timezone string) string { + timezone = strings.TrimSpace(timezone) + if timezone == "" { + return defaultTimezone + } + if _, err := time.LoadLocation(timezone); err != nil { + return defaultTimezone + } + return timezone +} + +func normalizePage(cursor, limit int) (int, int) { + if cursor <= 0 { + cursor = 1 + } + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + return cursor, limit +} + +func parseInt64CSV(raw string, fallback []int64) []int64 { + parts := strings.Split(strings.TrimSpace(raw), ",") + result := make([]int64, 0, len(parts)) + seen := map[int64]struct{}{} + for _, part := range parts { + value, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64) + if err != nil || value <= 0 { + continue + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + if len(result) == 0 { + return append([]int64(nil), fallback...) + } + return result +} + +func parseIntCSV(raw string, fallback []int) []int { + parts := strings.Split(strings.TrimSpace(raw), ",") + result := make([]int, 0, len(parts)) + seen := map[int]struct{}{} + for _, part := range parts { + value, err := strconv.Atoi(strings.TrimSpace(part)) + if err != nil || value <= 0 { + continue + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + if len(result) == 0 { + return append([]int(nil), fallback...) + } + return result +} + +func joinInt64CSV(values []int64) string { + parts := make([]string, 0, len(values)) + for _, value := range values { + if value > 0 { + parts = append(parts, strconv.FormatInt(value, 10)) + } + } + return strings.Join(parts, ",") +} + +func joinIntCSV(values []int) string { + parts := make([]string, 0, len(values)) + for _, value := range values { + if value > 0 { + parts = append(parts, strconv.Itoa(value)) + } + } + return strings.Join(parts, ",") +} + +func containsInt64(values []int64, target int64) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func containsInt(values []int, target int) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func defaultAmountOptions() []int64 { + return []int64{15000, 30000, 70000, 100000} +} + +func defaultCountOptions() []int { + return []int{5, 10, 20, 50} +} + +func formatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(time.RFC3339) +} + +func trimErrorMessage(message string) string { + message = strings.TrimSpace(message) + if len(message) > 1024 { + return message[:1024] + } + return message +} + +func walletEventID(kind, _ string, packetNo string, claimID int64) string { + // Java wallet stores event_id in varchar(50); sysOrigin is sent as a separate field. + switch kind { + case walletOriginClaim: + if claimID > 0 { + return fmt.Sprintf("VRPC:%d", claimID) + } + return fmt.Sprintf("VRPC:%s", packetNo) + case walletOriginRefund: + return fmt.Sprintf("VRPR:%s", packetNo) + default: + return fmt.Sprintf("VRPS:%s", packetNo) + } +} + +func mapWalletError(code string, err error) *AppError { + if err == nil { + return nil + } + message := strings.ToLower(err.Error()) + if strings.Contains(message, "insufficient_balance") || + strings.Contains(message, "balance not made") || + strings.Contains(message, `"errorcode":5000`) { + return NewAppError(http.StatusNotAcceptable, "voice_room_red_packet_insufficient_balance", "insufficient balance") + } + return NewAppError(http.StatusBadGateway, code, err.Error()) +} + +func packetPayload(row model.VoiceRoomRedPacket) PacketPayload { + return PacketPayload{ + PacketNo: row.PacketNo, + SysOrigin: row.SysOrigin, + RoomID: row.RoomID, + SenderUserID: row.SenderUserID, + SenderRegionCode: row.SenderRegionCode, + PacketMode: row.PacketMode, + TotalAmount: row.TotalAmount, + TotalCount: row.TotalCount, + RemainAmount: row.RemainAmount, + RemainCount: row.RemainCount, + RefundAmount: row.RefundAmount, + Status: row.Status, + RefundStatus: row.RefundStatus, + ClaimStartTime: formatTime(row.ClaimStartTime), + ExpireTime: formatTime(row.ExpireTime), + CreateTime: formatTime(row.CreateTime), + UpdateTime: formatTime(row.UpdateTime), + } +} diff --git a/internal/service/voiceroomredpacket/types_test.go b/internal/service/voiceroomredpacket/types_test.go new file mode 100644 index 0000000..59469d1 --- /dev/null +++ b/internal/service/voiceroomredpacket/types_test.go @@ -0,0 +1,32 @@ +package voiceroomredpacket + +import ( + "encoding/json" + "testing" +) + +func TestRoomIDRequestsAcceptStringOrNumber(t *testing.T) { + var presence PresenceRequest + if err := json.Unmarshal([]byte(`{"roomId":"9001"}`), &presence); err != nil { + t.Fatalf("unmarshal presence string roomId: %v", err) + } + if presence.RoomID.Int64() != 9001 { + t.Fatalf("presence string roomId = %d, want 9001", presence.RoomID.Int64()) + } + + var leave PresenceRequest + if err := json.Unmarshal([]byte(`{"roomId":9002}`), &leave); err != nil { + t.Fatalf("unmarshal presence numeric roomId: %v", err) + } + if leave.RoomID.Int64() != 9002 { + t.Fatalf("presence numeric roomId = %d, want 9002", leave.RoomID.Int64()) + } + + var send SendRequest + if err := json.Unmarshal([]byte(`{"requestId":"r1","roomId":"9003","packetMode":"DELAYED","totalAmount":15000,"totalCount":5}`), &send); err != nil { + t.Fatalf("unmarshal send string roomId: %v", err) + } + if send.RoomID.Int64() != 9003 { + t.Fatalf("send string roomId = %d, want 9003", send.RoomID.Int64()) + } +} diff --git a/internal/service/weekstar/event.go b/internal/service/weekstar/event.go index 189b216..e8f4671 100644 --- a/internal/service/weekstar/event.go +++ b/internal/service/weekstar/event.go @@ -3,11 +3,13 @@ package weekstar import ( "context" "encoding/json" + "log" "strconv" "strings" "time" "chatapp3-golang/internal/model" + "chatapp3-golang/internal/service/taskcenter" "chatapp3-golang/internal/utils" ) @@ -19,6 +21,7 @@ func (s *WeekStarService) ProcessGiftEvent(ctx context.Context, event weekStarGi return nil } eventTime := resolveMillisTime(int64(event.CreateTime), time.Now()).In(s.location) + s.reportGiftConsumeTaskEvent(ctx, sysOrigin, event, eventTime) configRow, giftRows, _, err := s.loadActiveConfigBundle(ctx, sysOrigin, eventTime) if err != nil { return err @@ -91,6 +94,32 @@ func (s *WeekStarService) ProcessGiftEvent(ctx context.Context, event weekStarGi }).Error } +func (s *WeekStarService) reportGiftConsumeTaskEvent(ctx context.Context, sysOrigin string, event weekStarGiftEvent, eventTime time.Time) { + if s.taskReporter == nil { + return + } + trackID := strings.TrimSpace(event.TrackID.String()) + sendUserID := int64(event.SendUserID) + if trackID == "" || sendUserID <= 0 { + return + } + scoreGold := calculateGiftScore(event) + if scoreGold <= 0 { + return + } + if err := s.taskReporter.ReportSimpleEventAt(ctx, sysOrigin, "GIFT_CONSUME:"+trackID, taskcenter.EventTypeGiftConsumeGold, sendUserID, scoreGold, eventTime, map[string]any{ + "trackId": trackID, + "giftId": int64(event.GiftConfig.ID), + "giftName": strings.TrimSpace(event.GiftConfig.GiftName), + "quantity": int64(event.Quantity), + "acceptUserSize": countDistinctAcceptUsers(event.Accepts), + "actualAmount": int64(event.GiftValue.ActualAmount), + "giftCandy": int64(event.GiftConfig.GiftCandy), + }); err != nil { + log.Printf("report gift consume task event failed: %v", err) + } +} + // ProcessGiftPayload 解析送礼消息原文并复用周榜统计逻辑,方便本地压测和非 RocketMQ 调用。 func (s *WeekStarService) ProcessGiftPayload(ctx context.Context, payload string) error { payload = strings.TrimSpace(payload) diff --git a/internal/service/weekstar/types.go b/internal/service/weekstar/types.go index 87e52a7..e6f3979 100644 --- a/internal/service/weekstar/types.go +++ b/internal/service/weekstar/types.go @@ -319,6 +319,10 @@ type weekStarPorts struct { Redis redis.Cmdable } +type taskEventReporter interface { + ReportSimpleEventAt(ctx context.Context, sysOrigin, eventID, eventType string, userID, deltaValue int64, occurredAt time.Time, payload map[string]any) error +} + // WeekStarService 负责周榜配置、实时积分、H5 聚合页和后台补发任务投递。 type WeekStarService struct { cfg config.Config @@ -326,6 +330,7 @@ type WeekStarService struct { java weekStarGateway location *time.Location rocketConsumer rmq.PushConsumer + taskReporter taskEventReporter } // NewWeekStarService 创建指定礼物周榜服务,并初始化统一使用的活动时区。 @@ -341,3 +346,7 @@ func NewWeekStarService(cfg config.Config, db weekStarDB, cache redis.Cmdable, j location: location, } } + +func (s *WeekStarService) SetTaskEventReporter(reporter taskEventReporter) { + s.taskReporter = reporter +} diff --git a/migrations/022_task_center.sql b/migrations/022_task_center.sql new file mode 100644 index 0000000..7787c3d --- /dev/null +++ b/migrations/022_task_center.sql @@ -0,0 +1,117 @@ +CREATE TABLE IF NOT EXISTS `task_center_config` ( + `id` bigint NOT NULL COMMENT '主键ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用任务中心', + `timezone` varchar(64) NOT NULL DEFAULT 'Asia/Riyadh' COMMENT '任务周期时区', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_task_center_config_sys_origin` (`sys_origin`), + KEY `idx_task_center_config_enabled` (`enabled`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务中心系统配置'; + +CREATE TABLE IF NOT EXISTS `task_center_task_config` ( + `id` bigint NOT NULL COMMENT '主键ID', + `config_id` bigint NOT NULL COMMENT '任务中心配置ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `task_code` varchar(64) NOT NULL COMMENT '任务编码', + `task_category` varchar(32) NOT NULL COMMENT '任务分类:DAILY/NEWBIE', + `enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用', + `task_name` varchar(255) NOT NULL DEFAULT '' COMMENT '任务名称', + `task_desc` varchar(255) NOT NULL DEFAULT '' COMMENT '任务说明', + `condition_type` varchar(64) NOT NULL DEFAULT '' COMMENT '完成条件类型', + `target_value` bigint NOT NULL DEFAULT '0' COMMENT '目标值', + `target_unit` varchar(32) NOT NULL DEFAULT '' COMMENT '目标单位', + `reward_gold` bigint NOT NULL DEFAULT '0' COMMENT '奖励金币', + `jump_type` varchar(64) NOT NULL DEFAULT 'NONE' COMMENT '跳转类型', + `jump_page` varchar(512) NOT NULL DEFAULT '' COMMENT '跳转参数', + `task_icon` varchar(512) NOT NULL DEFAULT '' COMMENT '任务图标', + `cover` 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_task_code` (`sys_origin`, `task_code`), + KEY `idx_task_center_task_config_id` (`config_id`), + KEY `idx_task_center_task_sys_category` (`sys_origin`, `task_category`), + KEY `idx_task_center_task_enabled` (`enabled`), + KEY `idx_task_center_task_condition` (`condition_type`), + KEY `idx_task_center_task_sort` (`sort_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务中心任务配置'; + +CREATE TABLE IF NOT EXISTS `task_center_user_progress` ( + `id` bigint NOT NULL COMMENT '主键ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `user_id` bigint NOT NULL COMMENT '用户ID', + `task_id` bigint NOT NULL COMMENT '任务ID', + `task_code` varchar(64) NOT NULL COMMENT '任务编码', + `task_category` varchar(32) NOT NULL COMMENT '任务分类', + `cycle_key` varchar(32) NOT NULL COMMENT '任务周期', + `condition_type` varchar(64) NOT NULL DEFAULT '' COMMENT '完成条件类型', + `completed_value` bigint NOT NULL DEFAULT '0' COMMENT '已完成值', + `target_value` bigint NOT NULL DEFAULT '0' COMMENT '目标值', + `status` varchar(32) NOT NULL DEFAULT 'IN_PROGRESS' COMMENT '任务状态', + `reward_collected` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已领取', + `claimed_at` datetime(3) DEFAULT NULL COMMENT '领取时间', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_task_center_user_progress` (`sys_origin`, `user_id`, `task_id`, `cycle_key`), + KEY `idx_task_center_progress_user` (`sys_origin`, `user_id`), + KEY `idx_task_center_progress_task` (`task_id`), + KEY `idx_task_center_progress_task_code` (`task_code`), + KEY `idx_task_center_progress_cycle` (`cycle_key`), + KEY `idx_task_center_progress_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务中心用户进度'; + +CREATE TABLE IF NOT EXISTS `task_center_event` ( + `id` bigint NOT NULL COMMENT '主键ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `event_id` varchar(128) NOT NULL COMMENT '业务事件ID', + `event_type` varchar(64) NOT NULL COMMENT '事件类型', + `user_id` bigint NOT NULL COMMENT '用户ID', + `task_id` bigint NOT NULL COMMENT '命中任务ID', + `task_code` varchar(64) NOT NULL COMMENT '命中任务编码', + `task_category` varchar(32) NOT NULL COMMENT '任务分类', + `cycle_key` varchar(32) NOT NULL COMMENT '任务周期', + `delta_value` bigint NOT NULL DEFAULT '0' COMMENT '本次增量', + `completed_value` bigint NOT NULL DEFAULT '0' COMMENT '事件后完成值', + `raw_json` text COMMENT '原始事件内容', + `occurred_at` datetime(3) DEFAULT NULL COMMENT '业务发生时间', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_task_center_event_task` (`event_id`, `task_id`), + KEY `idx_task_center_event_sys` (`sys_origin`), + KEY `idx_task_center_event_id` (`event_id`), + KEY `idx_task_center_event_type` (`event_type`), + KEY `idx_task_center_event_user` (`user_id`), + KEY `idx_task_center_event_task_code` (`task_code`), + KEY `idx_task_center_event_occurred` (`occurred_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务中心事件流水'; + +CREATE TABLE IF NOT EXISTS `task_center_claim_record` ( + `id` bigint NOT NULL COMMENT '主键ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `user_id` bigint NOT NULL COMMENT '用户ID', + `task_id` bigint NOT NULL COMMENT '任务ID', + `task_code` varchar(64) NOT NULL COMMENT '任务编码', + `task_name` varchar(255) NOT NULL DEFAULT '' COMMENT '任务名称快照', + `task_category` varchar(32) NOT NULL COMMENT '任务分类', + `cycle_key` varchar(32) NOT NULL COMMENT '任务周期', + `reward_gold` bigint NOT NULL DEFAULT '0' COMMENT '奖励金币', + `wallet_event_id` varchar(160) NOT NULL COMMENT '钱包事件ID', + `status` varchar(32) NOT NULL COMMENT '领取状态', + `failure_reason` varchar(512) NOT NULL DEFAULT '' COMMENT '失败原因', + `claimed_at` datetime(3) DEFAULT NULL COMMENT '领取成功时间', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_task_center_claim_wallet_event` (`wallet_event_id`), + KEY `idx_task_center_claim_sys` (`sys_origin`), + KEY `idx_task_center_claim_user` (`user_id`), + KEY `idx_task_center_claim_task` (`task_id`), + KEY `idx_task_center_claim_task_code` (`task_code`), + KEY `idx_task_center_claim_cycle` (`cycle_key`), + KEY `idx_task_center_claim_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务中心领取记录'; diff --git a/migrations/023_voice_room_red_packet.sql b/migrations/023_voice_room_red_packet.sql new file mode 100644 index 0000000..5416ed8 --- /dev/null +++ b/migrations/023_voice_room_red_packet.sql @@ -0,0 +1,107 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS voice_room_red_packet_config ( + id BIGINT NOT NULL COMMENT '主键ID' PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源', + enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用语音房红包', + daily_send_limit INT NOT NULL DEFAULT 10 COMMENT '用户每日发红包次数上限', + delay_seconds INT NOT NULL DEFAULT 300 COMMENT '延迟红包可领取等待秒数', + refund_after_seconds INT NOT NULL DEFAULT 7200 COMMENT '红包领取开始后多少秒过期退款', + timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Riyadh' COMMENT '业务时区,用于每日限额统计', + amount_options VARCHAR(255) NOT NULL DEFAULT '15000,30000,70000,100000' COMMENT '可选红包总金额列表,逗号分隔', + count_options VARCHAR(255) NOT NULL DEFAULT '5,10,20,50' COMMENT '可选红包份数列表,逗号分隔', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_voice_room_red_packet_config (sys_origin) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房红包配置表'; + +CREATE TABLE IF NOT EXISTS voice_room_region_country ( + id BIGINT NOT NULL COMMENT '主键ID' PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源', + region_code VARCHAR(32) NOT NULL COMMENT '区域编码', + country_code VARCHAR(32) NOT NULL COMMENT '国家编码', + enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用该国家区域映射', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_voice_room_region_country (sys_origin, country_code), + KEY idx_voice_room_region_country_region (sys_origin, region_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房国家区域映射表'; + +CREATE TABLE IF NOT EXISTS voice_room_red_packet ( + id BIGINT NOT NULL COMMENT '主键ID' PRIMARY KEY, + packet_no VARCHAR(64) NOT NULL COMMENT '红包业务编号', + request_id VARCHAR(128) NOT NULL COMMENT '客户端幂等请求ID', + sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源', + room_id BIGINT NOT NULL COMMENT '语音房ID', + sender_user_id BIGINT NOT NULL COMMENT '发红包用户ID', + sender_region_code VARCHAR(32) NOT NULL COMMENT '发红包用户区域编码', + packet_mode VARCHAR(16) NOT NULL COMMENT '红包模式:IMMEDIATE立即,DELAYED延迟', + amount_mode VARCHAR(16) NOT NULL DEFAULT 'RANDOM' COMMENT '金额分配模式:RANDOM随机', + total_amount BIGINT NOT NULL COMMENT '红包总金额,单位金币', + total_count INT NOT NULL COMMENT '红包总份数', + remain_amount BIGINT NOT NULL COMMENT '剩余未领取金额,单位金币', + remain_count INT NOT NULL COMMENT '剩余未领取份数', + claim_start_time DATETIME NOT NULL COMMENT '可开始领取时间', + expire_time DATETIME NOT NULL COMMENT '过期退款时间', + status VARCHAR(32) NOT NULL COMMENT '红包状态:INIT/ACTIVE/FINISHED/EXPIRED/REFUNDED/REFUND_FAILED/FAILED', + send_wallet_event_id VARCHAR(128) NOT NULL COMMENT '发红包扣款钱包事件ID', + refund_wallet_event_id VARCHAR(128) DEFAULT NULL COMMENT '退款钱包事件ID', + refund_amount BIGINT NOT NULL DEFAULT 0 COMMENT '退款金额,单位金币', + refund_status VARCHAR(32) NOT NULL DEFAULT 'NONE' COMMENT '退款状态:NONE/PENDING/SUCCESS/FAILED', + error_message VARCHAR(1024) DEFAULT NULL COMMENT '异常信息', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_voice_room_red_packet_no (packet_no), + UNIQUE KEY uk_voice_room_red_packet_request (request_id), + UNIQUE KEY uk_voice_room_red_packet_send_event (send_wallet_event_id), + KEY idx_voice_room_red_packet_room (sys_origin, room_id, create_time), + KEY idx_voice_room_red_packet_sender_day (sys_origin, sender_user_id, create_time), + KEY idx_voice_room_red_packet_expire (status, expire_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房红包主表'; + +CREATE TABLE IF NOT EXISTS voice_room_red_packet_slice ( + id BIGINT NOT NULL COMMENT '主键ID' PRIMARY KEY, + packet_id BIGINT NOT NULL COMMENT '红包主表ID', + packet_no VARCHAR(64) NOT NULL COMMENT '红包业务编号', + sort_no INT NOT NULL COMMENT '红包份额序号', + amount BIGINT NOT NULL COMMENT '份额金额,单位金币', + status VARCHAR(32) NOT NULL DEFAULT 'UNCLAIMED' COMMENT '份额状态:UNCLAIMED/CLAIMING/CLAIMED/RELEASED', + claim_user_id BIGINT DEFAULT NULL COMMENT '领取用户ID', + claim_record_id BIGINT DEFAULT NULL COMMENT '领取记录ID', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_voice_room_red_packet_slice (packet_id, sort_no), + KEY idx_voice_room_red_packet_slice_claim (packet_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房红包份额表'; + +CREATE TABLE IF NOT EXISTS voice_room_red_packet_claim ( + id BIGINT NOT NULL COMMENT '主键ID' PRIMARY KEY, + packet_id BIGINT NOT NULL COMMENT '红包主表ID', + packet_no VARCHAR(64) NOT NULL COMMENT '红包业务编号', + slice_id BIGINT NOT NULL COMMENT '红包份额ID', + sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源', + room_id BIGINT NOT NULL COMMENT '语音房ID', + user_id BIGINT NOT NULL COMMENT '领取用户ID', + user_region_code VARCHAR(32) NOT NULL COMMENT '领取用户区域编码', + amount BIGINT NOT NULL COMMENT '领取金额,单位金币', + wallet_event_id VARCHAR(128) NOT NULL COMMENT '领取入账钱包事件ID', + wallet_status VARCHAR(32) NOT NULL COMMENT '钱包入账状态:PENDING/SUCCESS/FAILED', + error_message VARCHAR(1024) DEFAULT NULL COMMENT '异常信息', + claim_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '领取时间', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_voice_room_red_packet_claim_user (packet_id, user_id), + UNIQUE KEY uk_voice_room_red_packet_claim_event (wallet_event_id), + KEY idx_voice_room_red_packet_claim_packet (packet_id, claim_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房红包领取记录表'; + +CREATE TABLE IF NOT EXISTS voice_room_red_packet_daily_counter ( + id BIGINT NOT NULL COMMENT '主键ID' PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源', + user_id BIGINT NOT NULL COMMENT '用户ID', + day_key VARCHAR(16) NOT NULL COMMENT '统计日期,格式yyyyMMdd', + send_count INT NOT NULL DEFAULT 0 COMMENT '当日已发送红包次数', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_voice_room_red_packet_daily (sys_origin, user_id, day_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房红包每日发送计数表'; diff --git a/migrations/024_app_popup.sql b/migrations/024_app_popup.sql new file mode 100644 index 0000000..e9fdb16 --- /dev/null +++ b/migrations/024_app_popup.sql @@ -0,0 +1,48 @@ +CREATE TABLE IF NOT EXISTS app_popup_config ( + id BIGINT NOT NULL PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源系统', + popup_key VARCHAR(64) NOT NULL COMMENT '弹窗业务key,例如 invite/game', + name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '后台展示名称', + scene VARCHAR(32) NOT NULL DEFAULT 'APP_ENTER' COMMENT '弹窗场景', + enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用', + limit_days INT NOT NULL DEFAULT 0 COMMENT '返回给App的展示间隔建议值,0=每次都弹', + priority INT NOT NULL DEFAULT 0 COMMENT '优先级,越大越靠前', + version INT NOT NULL DEFAULT 1 COMMENT '配置版本', + start_time DATETIME(3) DEFAULT NULL COMMENT '开始时间', + end_time DATETIME(3) DEFAULT NULL COMMENT '结束时间', + platforms_json JSON DEFAULT NULL COMMENT '平台白名单,例如 ["ios","android"]', + min_app_version VARCHAR(32) NOT NULL DEFAULT '' COMMENT '最低App版本', + max_app_version VARCHAR(32) NOT NULL DEFAULT '' COMMENT '最高App版本', + user_scope_type VARCHAR(32) NOT NULL DEFAULT 'ALL' COMMENT '人群规则:ALL/WHITELIST/BLACKLIST', + user_scope_json JSON DEFAULT NULL COMMENT '人群配置,一期可为空', + content_json JSON DEFAULT NULL COMMENT '弹窗内容:标题、图片、按钮、多语言', + jump_type VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'H5/APP_ROUTE/GAME/NOOP', + jump_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '跳转地址', + remark VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注', + create_time DATETIME(3) DEFAULT NULL, + update_time DATETIME(3) DEFAULT NULL, + UNIQUE KEY uk_app_popup_config_key (sys_origin, scene, popup_key), + KEY idx_app_popup_config_scene (sys_origin, scene, enabled, priority) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='APP弹窗配置'; + +INSERT INTO app_popup_config +(id, sys_origin, popup_key, name, scene, enabled, limit_days, priority, version, + content_json, jump_type, jump_url, create_time, update_time) +VALUES +(1001, 'LIKEI', 'invite', '邀请活动弹窗', 'APP_ENTER', 1, 7, 100, 1, + JSON_OBJECT('title', 'Invite friends', 'image', 'https://cdn.example.com/popup/invite.png'), + 'H5', '/h5/app-invite/landing.html', NOW(3), NOW(3)), +(1002, 'LIKEI', 'game', '游戏引导弹窗', 'APP_ENTER', 1, 7, 90, 1, + JSON_OBJECT('title', 'Play games', 'image', 'https://cdn.example.com/popup/game.png'), + 'APP_ROUTE', '/game-center', NOW(3), NOW(3)) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + enabled = VALUES(enabled), + limit_days = VALUES(limit_days), + priority = VALUES(priority), + version = VALUES(version), + content_json = VALUES(content_json), + jump_type = VALUES(jump_type), + jump_url = VALUES(jump_url), + update_time = NOW(3); + diff --git a/migrations/025_voice_room_region_im_group.sql b/migrations/025_voice_room_region_im_group.sql new file mode 100644 index 0000000..f137afe --- /dev/null +++ b/migrations/025_voice_room_region_im_group.sql @@ -0,0 +1,16 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS voice_room_region_im_group ( + id BIGINT NOT NULL COMMENT '主键ID' PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源', + region_code VARCHAR(32) NOT NULL COMMENT '区域编码', + region_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '区域名称', + group_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '腾讯 IM 群组ID', + group_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '腾讯 IM 群组名称', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '群组状态:PENDING/ACTIVE/FAILED', + last_error VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '最近一次创建或同步失败原因', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_voice_room_region_im_group (sys_origin, region_code), + KEY idx_voice_room_region_im_group_group (group_id, region_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房区域通用 IM 群组表'; diff --git a/migrations/026_task_center_removed_conditions.sql b/migrations/026_task_center_removed_conditions.sql new file mode 100644 index 0000000..83995f8 --- /dev/null +++ b/migrations/026_task_center_removed_conditions.sql @@ -0,0 +1,13 @@ +-- Keep existing task-center data aligned with the supported task types. +-- These condition types were removed from product scope and are no longer +-- reported by backend services. +UPDATE task_center_task_config +SET + enabled = 0, + update_time = NOW(3) +WHERE condition_type IN ( + 'POST_MOOD_COUNT', + 'INTERACTIVE_GAME_COUNT', + 'CP_BIND_COUNT' +) +AND enabled <> 0; diff --git a/migrations/027_voice_room_red_packet_comments.sql b/migrations/027_voice_room_red_packet_comments.sql new file mode 100644 index 0000000..f266598 --- /dev/null +++ b/migrations/027_voice_room_red_packet_comments.sql @@ -0,0 +1,105 @@ +SET NAMES utf8mb4; + +ALTER TABLE voice_room_red_packet_config + MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID', + MODIFY COLUMN sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源', + MODIFY COLUMN enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用语音房红包', + MODIFY COLUMN daily_send_limit INT NOT NULL DEFAULT 10 COMMENT '用户每日发红包次数上限', + MODIFY COLUMN delay_seconds INT NOT NULL DEFAULT 300 COMMENT '延迟红包可领取等待秒数', + MODIFY COLUMN refund_after_seconds INT NOT NULL DEFAULT 7200 COMMENT '红包领取开始后多少秒过期退款', + MODIFY COLUMN timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Riyadh' COMMENT '业务时区,用于每日限额统计', + MODIFY COLUMN amount_options VARCHAR(255) NOT NULL DEFAULT '15000,30000,70000,100000' COMMENT '可选红包总金额列表,逗号分隔', + MODIFY COLUMN count_options VARCHAR(255) NOT NULL DEFAULT '5,10,20,50' COMMENT '可选红包份数列表,逗号分隔', + MODIFY COLUMN create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + COMMENT = '语音房红包配置表'; + +ALTER TABLE voice_room_region_country + MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID', + MODIFY COLUMN sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源', + MODIFY COLUMN region_code VARCHAR(32) NOT NULL COMMENT '区域编码', + MODIFY COLUMN country_code VARCHAR(32) NOT NULL COMMENT '国家编码', + MODIFY COLUMN enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用该国家区域映射', + MODIFY COLUMN create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + COMMENT = '语音房国家区域映射表'; + +ALTER TABLE voice_room_red_packet + MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID', + MODIFY COLUMN packet_no VARCHAR(64) NOT NULL COMMENT '红包业务编号', + MODIFY COLUMN request_id VARCHAR(128) NOT NULL COMMENT '客户端幂等请求ID', + MODIFY COLUMN sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源', + MODIFY COLUMN room_id BIGINT NOT NULL COMMENT '语音房ID', + MODIFY COLUMN sender_user_id BIGINT NOT NULL COMMENT '发红包用户ID', + MODIFY COLUMN sender_region_code VARCHAR(32) NOT NULL COMMENT '发红包用户区域编码', + MODIFY COLUMN packet_mode VARCHAR(16) NOT NULL COMMENT '红包模式:IMMEDIATE立即,DELAYED延迟', + MODIFY COLUMN amount_mode VARCHAR(16) NOT NULL DEFAULT 'RANDOM' COMMENT '金额分配模式:RANDOM随机', + MODIFY COLUMN total_amount BIGINT NOT NULL COMMENT '红包总金额,单位金币', + MODIFY COLUMN total_count INT NOT NULL COMMENT '红包总份数', + MODIFY COLUMN remain_amount BIGINT NOT NULL COMMENT '剩余未领取金额,单位金币', + MODIFY COLUMN remain_count INT NOT NULL COMMENT '剩余未领取份数', + MODIFY COLUMN claim_start_time DATETIME NOT NULL COMMENT '可开始领取时间', + MODIFY COLUMN expire_time DATETIME NOT NULL COMMENT '过期退款时间', + MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '红包状态:INIT/ACTIVE/FINISHED/EXPIRED/REFUNDED/REFUND_FAILED/FAILED', + MODIFY COLUMN send_wallet_event_id VARCHAR(128) NOT NULL COMMENT '发红包扣款钱包事件ID', + MODIFY COLUMN refund_wallet_event_id VARCHAR(128) DEFAULT NULL COMMENT '退款钱包事件ID', + MODIFY COLUMN refund_amount BIGINT NOT NULL DEFAULT 0 COMMENT '退款金额,单位金币', + MODIFY COLUMN refund_status VARCHAR(32) NOT NULL DEFAULT 'NONE' COMMENT '退款状态:NONE/PENDING/SUCCESS/FAILED', + MODIFY COLUMN error_message VARCHAR(1024) DEFAULT NULL COMMENT '异常信息', + MODIFY COLUMN create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + COMMENT = '语音房红包主表'; + +ALTER TABLE voice_room_red_packet_slice + MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID', + MODIFY COLUMN packet_id BIGINT NOT NULL COMMENT '红包主表ID', + MODIFY COLUMN packet_no VARCHAR(64) NOT NULL COMMENT '红包业务编号', + MODIFY COLUMN sort_no INT NOT NULL COMMENT '红包份额序号', + MODIFY COLUMN amount BIGINT NOT NULL COMMENT '份额金额,单位金币', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'UNCLAIMED' COMMENT '份额状态:UNCLAIMED/CLAIMING/CLAIMED/RELEASED', + MODIFY COLUMN claim_user_id BIGINT DEFAULT NULL COMMENT '领取用户ID', + MODIFY COLUMN claim_record_id BIGINT DEFAULT NULL COMMENT '领取记录ID', + MODIFY COLUMN create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + COMMENT = '语音房红包份额表'; + +ALTER TABLE voice_room_red_packet_claim + MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID', + MODIFY COLUMN packet_id BIGINT NOT NULL COMMENT '红包主表ID', + MODIFY COLUMN packet_no VARCHAR(64) NOT NULL COMMENT '红包业务编号', + MODIFY COLUMN slice_id BIGINT NOT NULL COMMENT '红包份额ID', + MODIFY COLUMN sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源', + MODIFY COLUMN room_id BIGINT NOT NULL COMMENT '语音房ID', + MODIFY COLUMN user_id BIGINT NOT NULL COMMENT '领取用户ID', + MODIFY COLUMN user_region_code VARCHAR(32) NOT NULL COMMENT '领取用户区域编码', + MODIFY COLUMN amount BIGINT NOT NULL COMMENT '领取金额,单位金币', + MODIFY COLUMN wallet_event_id VARCHAR(128) NOT NULL COMMENT '领取入账钱包事件ID', + MODIFY COLUMN wallet_status VARCHAR(32) NOT NULL COMMENT '钱包入账状态:PENDING/SUCCESS/FAILED', + MODIFY COLUMN error_message VARCHAR(1024) DEFAULT NULL COMMENT '异常信息', + MODIFY COLUMN claim_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '领取时间', + MODIFY COLUMN create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + COMMENT = '语音房红包领取记录表'; + +ALTER TABLE voice_room_red_packet_daily_counter + MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID', + MODIFY COLUMN sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源', + MODIFY COLUMN user_id BIGINT NOT NULL COMMENT '用户ID', + MODIFY COLUMN day_key VARCHAR(16) NOT NULL COMMENT '统计日期,格式yyyyMMdd', + MODIFY COLUMN send_count INT NOT NULL DEFAULT 0 COMMENT '当日已发送红包次数', + MODIFY COLUMN create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + COMMENT = '语音房红包每日发送计数表'; + +ALTER TABLE voice_room_region_im_group + MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID', + MODIFY COLUMN sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源', + MODIFY COLUMN region_code VARCHAR(32) NOT NULL COMMENT '区域编码', + MODIFY COLUMN region_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '区域名称', + MODIFY COLUMN group_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '腾讯 IM 群组ID', + MODIFY COLUMN group_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '腾讯 IM 群组名称', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '群组状态:PENDING/ACTIVE/FAILED', + MODIFY COLUMN last_error VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '最近一次创建或同步失败原因', + MODIFY COLUMN create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + COMMENT = '语音房区域通用 IM 群组表';