红包、任务需求

This commit is contained in:
hy001 2026-05-08 18:55:47 +08:00
parent 25aab0d6a2
commit 72b66e26b0
53 changed files with 8333 additions and 11 deletions

View File

@ -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{

View File

@ -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()

View File

@ -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"}, ""),

View File

@ -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")

View File

@ -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" }

View File

@ -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" }

View File

@ -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" }

View File

@ -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"
}

View File

@ -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})
})
}

View File

@ -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
}

View File

@ -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)
})
}

View File

@ -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)

View File

@ -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)
})
}

View File

@ -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})
})
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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 {

View File

@ -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"`

View File

@ -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

View File

@ -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"`

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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())
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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()
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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()
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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),
}
}

View File

@ -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())
}
}

View File

@ -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)

View File

@ -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
}

View File

@ -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='任务中心领取记录';

View File

@ -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='语音房红包每日发送计数表';

View File

@ -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);

View File

@ -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 群组表';

View File

@ -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;

View File

@ -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 群组表';