红包、任务需求
This commit is contained in:
parent
25aab0d6a2
commit
72b66e26b0
@ -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{
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"}, ""),
|
||||
|
||||
@ -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")
|
||||
|
||||
32
internal/model/app_popup_models.go
Normal file
32
internal/model/app_popup_models.go
Normal 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" }
|
||||
19
internal/model/region_im_group_models.go
Normal file
19
internal/model/region_im_group_models.go
Normal 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" }
|
||||
103
internal/model/taskcenter_models.go
Normal file
103
internal/model/taskcenter_models.go
Normal 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" }
|
||||
127
internal/model/voice_room_red_packet_models.go
Normal file
127
internal/model/voice_room_red_packet_models.go
Normal 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"
|
||||
}
|
||||
77
internal/router/app_popup_routes.go
Normal file
77
internal/router/app_popup_routes.go
Normal 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})
|
||||
})
|
||||
}
|
||||
183
internal/router/app_popup_routes_test.go
Normal file
183
internal/router/app_popup_routes_test.go
Normal 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
|
||||
}
|
||||
106
internal/router/region_im_group_routes.go
Normal file
106
internal/router/region_im_group_routes.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@ -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)
|
||||
|
||||
150
internal/router/task_center_routes.go
Normal file
150
internal/router/task_center_routes.go
Normal 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)
|
||||
})
|
||||
}
|
||||
164
internal/router/voice_room_red_packet_routes.go
Normal file
164
internal/router/voice_room_red_packet_routes.go
Normal 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})
|
||||
})
|
||||
}
|
||||
237
internal/service/apppopup/admin.go
Normal file
237
internal/service/apppopup/admin.go
Normal 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)
|
||||
}
|
||||
299
internal/service/apppopup/query.go
Normal file
299
internal/service/apppopup/query.go
Normal 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
|
||||
}
|
||||
351
internal/service/apppopup/query_test.go
Normal file
351
internal/service/apppopup/query_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
353
internal/service/apppopup/types.go
Normal file
353
internal/service/apppopup/types.go
Normal 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)
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"`
|
||||
|
||||
255
internal/service/regionimgroup/broadcast.go
Normal file
255
internal/service/regionimgroup/broadcast.go
Normal 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
|
||||
}
|
||||
}
|
||||
43
internal/service/regionimgroup/region.go
Normal file
43
internal/service/regionimgroup/region.go
Normal 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
|
||||
}
|
||||
295
internal/service/regionimgroup/service.go
Normal file
295
internal/service/regionimgroup/service.go
Normal 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())
|
||||
}
|
||||
193
internal/service/regionimgroup/service_test.go
Normal file
193
internal/service/regionimgroup/service_test.go
Normal 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
|
||||
}
|
||||
140
internal/service/regionimgroup/types.go
Normal file
140
internal/service/regionimgroup/types.go
Normal 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
|
||||
}
|
||||
438
internal/service/taskcenter/admin.go
Normal file
438
internal/service/taskcenter/admin.go
Normal 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
|
||||
}
|
||||
322
internal/service/taskcenter/app.go
Normal file
322
internal/service/taskcenter/app.go
Normal 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()
|
||||
}
|
||||
201
internal/service/taskcenter/event.go
Normal file
201
internal/service/taskcenter/event.go
Normal 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
|
||||
}
|
||||
234
internal/service/taskcenter/taskcenter_test.go
Normal file
234
internal/service/taskcenter/taskcenter_test.go
Normal 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
|
||||
}
|
||||
344
internal/service/taskcenter/types.go
Normal file
344
internal/service/taskcenter/types.go
Normal 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)
|
||||
}
|
||||
234
internal/service/tencentim/client.go
Normal file
234
internal/service/tencentim/client.go
Normal 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()
|
||||
}
|
||||
36
internal/service/tencentim/client_test.go
Normal file
36
internal/service/tencentim/client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
59
internal/service/voiceroomredpacket/amount.go
Normal file
59
internal/service/voiceroomredpacket/amount.go
Normal 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
|
||||
}
|
||||
294
internal/service/voiceroomredpacket/claim.go
Normal file
294
internal/service/voiceroomredpacket/claim.go
Normal 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
|
||||
}
|
||||
194
internal/service/voiceroomredpacket/config.go
Normal file
194
internal/service/voiceroomredpacket/config.go
Normal 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
|
||||
}
|
||||
222
internal/service/voiceroomredpacket/notify.go
Normal file
222
internal/service/voiceroomredpacket/notify.go
Normal 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)
|
||||
}
|
||||
}
|
||||
150
internal/service/voiceroomredpacket/presence.go
Normal file
150
internal/service/voiceroomredpacket/presence.go
Normal 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
|
||||
}
|
||||
230
internal/service/voiceroomredpacket/records.go
Normal file
230
internal/service/voiceroomredpacket/records.go
Normal 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
|
||||
}
|
||||
192
internal/service/voiceroomredpacket/refund.go
Normal file
192
internal/service/voiceroomredpacket/refund.go
Normal 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
|
||||
}
|
||||
329
internal/service/voiceroomredpacket/send.go
Normal file
329
internal/service/voiceroomredpacket/send.go
Normal 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
|
||||
}
|
||||
466
internal/service/voiceroomredpacket/service_test.go
Normal file
466
internal/service/voiceroomredpacket/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
517
internal/service/voiceroomredpacket/types.go
Normal file
517
internal/service/voiceroomredpacket/types.go
Normal 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),
|
||||
}
|
||||
}
|
||||
32
internal/service/voiceroomredpacket/types_test.go
Normal file
32
internal/service/voiceroomredpacket/types_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
117
migrations/022_task_center.sql
Normal file
117
migrations/022_task_center.sql
Normal 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='任务中心领取记录';
|
||||
107
migrations/023_voice_room_red_packet.sql
Normal file
107
migrations/023_voice_room_red_packet.sql
Normal 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='语音房红包每日发送计数表';
|
||||
48
migrations/024_app_popup.sql
Normal file
48
migrations/024_app_popup.sql
Normal 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);
|
||||
|
||||
16
migrations/025_voice_room_region_im_group.sql
Normal file
16
migrations/025_voice_room_region_im_group.sql
Normal 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 群组表';
|
||||
13
migrations/026_task_center_removed_conditions.sql
Normal file
13
migrations/026_task_center_removed_conditions.sql
Normal 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;
|
||||
105
migrations/027_voice_room_red_packet_comments.sql
Normal file
105
migrations/027_voice_room_red_packet_comments.sql
Normal 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 群组表';
|
||||
Loading…
x
Reference in New Issue
Block a user