红包、任务需求
This commit is contained in:
parent
25aab0d6a2
commit
72b66e26b0
@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"chatapp3-golang/internal/bootstrap"
|
"chatapp3-golang/internal/bootstrap"
|
||||||
"chatapp3-golang/internal/router"
|
"chatapp3-golang/internal/router"
|
||||||
|
"chatapp3-golang/internal/service/apppopup"
|
||||||
"chatapp3-golang/internal/service/baishun"
|
"chatapp3-golang/internal/service/baishun"
|
||||||
"chatapp3-golang/internal/service/gameopen"
|
"chatapp3-golang/internal/service/gameopen"
|
||||||
"chatapp3-golang/internal/service/gameprovider"
|
"chatapp3-golang/internal/service/gameprovider"
|
||||||
@ -21,10 +22,13 @@ import (
|
|||||||
"chatapp3-golang/internal/service/luckygift"
|
"chatapp3-golang/internal/service/luckygift"
|
||||||
"chatapp3-golang/internal/service/rechargeagency"
|
"chatapp3-golang/internal/service/rechargeagency"
|
||||||
"chatapp3-golang/internal/service/rechargereward"
|
"chatapp3-golang/internal/service/rechargereward"
|
||||||
|
"chatapp3-golang/internal/service/regionimgroup"
|
||||||
"chatapp3-golang/internal/service/registerreward"
|
"chatapp3-golang/internal/service/registerreward"
|
||||||
"chatapp3-golang/internal/service/roomturnoverreward"
|
"chatapp3-golang/internal/service/roomturnoverreward"
|
||||||
"chatapp3-golang/internal/service/signinreward"
|
"chatapp3-golang/internal/service/signinreward"
|
||||||
|
"chatapp3-golang/internal/service/taskcenter"
|
||||||
"chatapp3-golang/internal/service/vip"
|
"chatapp3-golang/internal/service/vip"
|
||||||
|
"chatapp3-golang/internal/service/voiceroomredpacket"
|
||||||
"chatapp3-golang/internal/service/weekstar"
|
"chatapp3-golang/internal/service/weekstar"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -37,6 +41,7 @@ func main() {
|
|||||||
log.Fatalf("bootstrap api failed: %v", err)
|
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)
|
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)
|
baishunService := baishun.NewBaishunService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||||
lingxianService := lingxian.NewService(app.Config, app.Repository.DB, app.Repository.Redis)
|
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)
|
registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||||
rechargeRewardService := rechargereward.NewService(app.Config, app.Repository.DB, &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)
|
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)
|
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)
|
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||||
roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
hostCenterService := hostcenter.NewService(app.Config, &app.Gateways)
|
hostCenterService := hostcenter.NewService(app.Config, &app.Gateways)
|
||||||
|
|
||||||
|
baishunService.SetTaskEventReporter(taskCenterService)
|
||||||
|
gameOpenService.SetTaskEventReporter(taskCenterService)
|
||||||
|
weekStarService.SetTaskEventReporter(taskCenterService)
|
||||||
|
|
||||||
workerCtx, workerCancel := context.WithCancel(context.Background())
|
workerCtx, workerCancel := context.WithCancel(context.Background())
|
||||||
defer workerCancel()
|
defer workerCancel()
|
||||||
if err := registerRewardService.Start(workerCtx); err != nil {
|
if err := registerRewardService.Start(workerCtx); err != nil {
|
||||||
@ -59,12 +71,14 @@ func main() {
|
|||||||
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
|
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
|
||||||
log.Fatalf("start week star message consumer failed: %v", err)
|
log.Fatalf("start week star message consumer failed: %v", err)
|
||||||
}
|
}
|
||||||
|
voiceRoomRedPacketService.StartRefundWorker(workerCtx)
|
||||||
|
|
||||||
gameProviders := gameprovider.NewRegistry(
|
gameProviders := gameprovider.NewRegistry(
|
||||||
baishun.NewAppProvider(baishunService),
|
baishun.NewAppProvider(baishunService),
|
||||||
lingxian.NewAppProvider(lingxianService),
|
lingxian.NewAppProvider(lingxianService),
|
||||||
)
|
)
|
||||||
engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{
|
engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{
|
||||||
|
AppPopup: appPopupService,
|
||||||
Invite: inviteService,
|
Invite: inviteService,
|
||||||
Baishun: baishunService,
|
Baishun: baishunService,
|
||||||
Lingxian: lingxianService,
|
Lingxian: lingxianService,
|
||||||
@ -74,10 +88,13 @@ func main() {
|
|||||||
HostCenter: hostCenterService,
|
HostCenter: hostCenterService,
|
||||||
RechargeAgency: rechargeAgencyService,
|
RechargeAgency: rechargeAgencyService,
|
||||||
RechargeReward: rechargeRewardService,
|
RechargeReward: rechargeRewardService,
|
||||||
|
RegionIMGroup: regionIMGroupService,
|
||||||
RegisterReward: registerRewardService,
|
RegisterReward: registerRewardService,
|
||||||
RoomTurnoverReward: roomTurnoverRewardService,
|
RoomTurnoverReward: roomTurnoverRewardService,
|
||||||
SignInReward: signInRewardService,
|
SignInReward: signInRewardService,
|
||||||
|
TaskCenter: taskCenterService,
|
||||||
VIP: vipService,
|
VIP: vipService,
|
||||||
|
VoiceRoomRedPacket: voiceRoomRedPacketService,
|
||||||
WeekStar: weekStarService,
|
WeekStar: weekStarService,
|
||||||
})
|
})
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
|
|||||||
@ -2,7 +2,9 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"chatapp3-golang/internal/bootstrap"
|
"chatapp3-golang/internal/bootstrap"
|
||||||
|
"chatapp3-golang/internal/service/regionimgroup"
|
||||||
"chatapp3-golang/internal/service/registerreward"
|
"chatapp3-golang/internal/service/registerreward"
|
||||||
|
"chatapp3-golang/internal/service/voiceroomredpacket"
|
||||||
"chatapp3-golang/internal/service/weekstar"
|
"chatapp3-golang/internal/service/weekstar"
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
@ -22,6 +24,8 @@ func main() {
|
|||||||
|
|
||||||
registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
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)
|
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)
|
workerCtx, workerCancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer workerCancel()
|
defer workerCancel()
|
||||||
@ -32,6 +36,7 @@ func main() {
|
|||||||
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
|
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
|
||||||
log.Fatalf("start week star message consumer failed: %v", err)
|
log.Fatalf("start week star message consumer failed: %v", err)
|
||||||
}
|
}
|
||||||
|
voiceRoomRedPacketService.StartRefundWorker(workerCtx)
|
||||||
|
|
||||||
log.Printf("chatapp3-golang consumer started")
|
log.Printf("chatapp3-golang consumer started")
|
||||||
<-workerCtx.Done()
|
<-workerCtx.Done()
|
||||||
|
|||||||
@ -16,8 +16,11 @@ type Config struct {
|
|||||||
Worker WorkerConfig
|
Worker WorkerConfig
|
||||||
Invite InviteConfig
|
Invite InviteConfig
|
||||||
RechargeReward RechargeRewardConfig
|
RechargeReward RechargeRewardConfig
|
||||||
|
TaskCenter TaskCenterConfig
|
||||||
WeekStar WeekStarConfig
|
WeekStar WeekStarConfig
|
||||||
RoomTurnoverReward RoomTurnoverRewardConfig
|
RoomTurnoverReward RoomTurnoverRewardConfig
|
||||||
|
VoiceRoomRedPacket VoiceRoomRedPacketConfig
|
||||||
|
TencentIM TencentIMConfig
|
||||||
Baishun BaishunConfig
|
Baishun BaishunConfig
|
||||||
Lingxian LingxianConfig
|
Lingxian LingxianConfig
|
||||||
GameOpen GameOpenConfig
|
GameOpen GameOpenConfig
|
||||||
@ -80,6 +83,13 @@ type RechargeRewardConfig struct {
|
|||||||
Timezone string
|
Timezone string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TaskCenterConfig 保存任务中心模块配置。
|
||||||
|
type TaskCenterConfig struct {
|
||||||
|
DefaultSysOrigin string
|
||||||
|
Timezone string
|
||||||
|
InternalEventTTL time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
// WeekStarConfig 保存周榜模块配置。
|
// WeekStarConfig 保存周榜模块配置。
|
||||||
type WeekStarConfig struct {
|
type WeekStarConfig struct {
|
||||||
DefaultSysOrigin string
|
DefaultSysOrigin string
|
||||||
@ -108,6 +118,27 @@ type RoomTurnoverRewardConfig struct {
|
|||||||
CycleDuration time.Duration
|
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 保存百顺模块配置。
|
// BaishunConfig 保存百顺模块配置。
|
||||||
type BaishunConfig struct {
|
type BaishunConfig struct {
|
||||||
PlatformBaseURL string
|
PlatformBaseURL string
|
||||||
@ -156,6 +187,12 @@ type LuckyGiftProviderConfig struct {
|
|||||||
func Load() Config {
|
func Load() Config {
|
||||||
javaAppBaseURL := getEnvAny([]string{"CHATAPP_JAVA_APP_BASE_URL", "INVITE_JAVA_APP_BASE_URL"}, "http://127.0.0.1:2400")
|
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")
|
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{
|
return Config{
|
||||||
HTTP: HTTPConfig{
|
HTTP: HTTPConfig{
|
||||||
@ -205,16 +242,30 @@ func Load() Config {
|
|||||||
"Asia/Riyadh",
|
"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{
|
WeekStar: WeekStarConfig{
|
||||||
DefaultSysOrigin: strings.ToUpper(getEnvAny([]string{"CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"}, "LIKEI")),
|
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"),
|
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"),
|
RetryStreamKey: getEnvAny([]string{"CHATAPP_WEEK_STAR_RETRY_STREAM_KEY", "WEEK_STAR_RETRY_STREAM_KEY"}, "week-star:reward-retry"),
|
||||||
RocketMQ: WeekStarRocketMQConfig{
|
RocketMQ: WeekStarRocketMQConfig{
|
||||||
Enabled: getEnvBoolAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_ENABLED", "WEEK_STAR_ROCKETMQ_ENABLED"}, false),
|
Enabled: getEnvBoolAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_ENABLED", "WEEK_STAR_ROCKETMQ_ENABLED"}, weekStarRocketMQEnabledDefault),
|
||||||
Endpoint: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_ENDPOINT", "WEEK_STAR_ROCKETMQ_ENDPOINT"}, ""),
|
Endpoint: weekStarRocketMQEndpoint,
|
||||||
Namespace: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_NAMESPACE", "WEEK_STAR_ROCKETMQ_NAMESPACE"}, ""),
|
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"}, ""),
|
AccessKey: weekStarRocketMQAccessKey,
|
||||||
AccessSecret: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_SECRET", "WEEK_STAR_ROCKETMQ_ACCESS_SECRET"}, ""),
|
AccessSecret: weekStarRocketMQAccessSecret,
|
||||||
SecurityToken: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_SECURITY_TOKEN", "WEEK_STAR_ROCKETMQ_SECURITY_TOKEN"}, ""),
|
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"),
|
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"),
|
Topic: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_TOPIC", "WEEK_STAR_ROCKETMQ_TOPIC"}, "RC_DEFAULT_APP_ORDINARY"),
|
||||||
@ -235,6 +286,62 @@ func Load() Config {
|
|||||||
10,
|
10,
|
||||||
)) * time.Second,
|
)) * 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{
|
Baishun: BaishunConfig{
|
||||||
PlatformBaseURL: getEnvAny([]string{"CHATAPP_BAISHUN_PLATFORM_BASE_URL", "BAISHUN_PLATFORM_BASE_URL"}, ""),
|
PlatformBaseURL: getEnvAny([]string{"CHATAPP_BAISHUN_PLATFORM_BASE_URL", "BAISHUN_PLATFORM_BASE_URL"}, ""),
|
||||||
GameListURL: getEnvAny([]string{"CHATAPP_BAISHUN_GAME_LIST_URL", "BAISHUN_GAME_LIST_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) {
|
func TestLoadLingxianConfigSupportsLikeiHkysSignKey(t *testing.T) {
|
||||||
t.Setenv("LIKEI_GAME_HKYS_SIGN_KEY", "likei-hkys-key")
|
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) {
|
func TestLoadLuckyGiftConfigsSupportsLegacyDefaultEnv(t *testing.T) {
|
||||||
oldJSON, hadJSON := os.LookupEnv("LUCKY_GIFT_CONFIGS_JSON")
|
oldJSON, hadJSON := os.LookupEnv("LUCKY_GIFT_CONFIGS_JSON")
|
||||||
oldList, hadList := os.LookupEnv("LUCKY_GIFT_CONFIGS")
|
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/config"
|
||||||
"chatapp3-golang/internal/repo"
|
"chatapp3-golang/internal/repo"
|
||||||
|
"chatapp3-golang/internal/service/apppopup"
|
||||||
"chatapp3-golang/internal/service/baishun"
|
"chatapp3-golang/internal/service/baishun"
|
||||||
"chatapp3-golang/internal/service/gameopen"
|
"chatapp3-golang/internal/service/gameopen"
|
||||||
"chatapp3-golang/internal/service/gameprovider"
|
"chatapp3-golang/internal/service/gameprovider"
|
||||||
@ -15,10 +16,13 @@ import (
|
|||||||
"chatapp3-golang/internal/service/luckygift"
|
"chatapp3-golang/internal/service/luckygift"
|
||||||
"chatapp3-golang/internal/service/rechargeagency"
|
"chatapp3-golang/internal/service/rechargeagency"
|
||||||
"chatapp3-golang/internal/service/rechargereward"
|
"chatapp3-golang/internal/service/rechargereward"
|
||||||
|
"chatapp3-golang/internal/service/regionimgroup"
|
||||||
"chatapp3-golang/internal/service/registerreward"
|
"chatapp3-golang/internal/service/registerreward"
|
||||||
"chatapp3-golang/internal/service/roomturnoverreward"
|
"chatapp3-golang/internal/service/roomturnoverreward"
|
||||||
"chatapp3-golang/internal/service/signinreward"
|
"chatapp3-golang/internal/service/signinreward"
|
||||||
|
"chatapp3-golang/internal/service/taskcenter"
|
||||||
"chatapp3-golang/internal/service/vip"
|
"chatapp3-golang/internal/service/vip"
|
||||||
|
"chatapp3-golang/internal/service/voiceroomredpacket"
|
||||||
"chatapp3-golang/internal/service/weekstar"
|
"chatapp3-golang/internal/service/weekstar"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@ -26,6 +30,7 @@ import (
|
|||||||
|
|
||||||
// Services 聚合所有业务模块服务,供路由层统一装配。
|
// Services 聚合所有业务模块服务,供路由层统一装配。
|
||||||
type Services struct {
|
type Services struct {
|
||||||
|
AppPopup *apppopup.Service
|
||||||
Invite *invite.InviteService
|
Invite *invite.InviteService
|
||||||
Baishun *baishun.BaishunService
|
Baishun *baishun.BaishunService
|
||||||
Lingxian *lingxian.Service
|
Lingxian *lingxian.Service
|
||||||
@ -35,10 +40,13 @@ type Services struct {
|
|||||||
HostCenter *hostcenter.Service
|
HostCenter *hostcenter.Service
|
||||||
RechargeAgency *rechargeagency.Service
|
RechargeAgency *rechargeagency.Service
|
||||||
RechargeReward *rechargereward.Service
|
RechargeReward *rechargereward.Service
|
||||||
|
RegionIMGroup *regionimgroup.Service
|
||||||
RegisterReward *registerreward.RegisterRewardService
|
RegisterReward *registerreward.RegisterRewardService
|
||||||
RoomTurnoverReward *roomturnoverreward.Service
|
RoomTurnoverReward *roomturnoverreward.Service
|
||||||
SignInReward *signinreward.SignInRewardService
|
SignInReward *signinreward.SignInRewardService
|
||||||
|
TaskCenter *taskcenter.Service
|
||||||
VIP *vip.Service
|
VIP *vip.Service
|
||||||
|
VoiceRoomRedPacket *voiceroomredpacket.Service
|
||||||
WeekStar *weekstar.WeekStarService
|
WeekStar *weekstar.WeekStarService
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,12 +61,16 @@ func NewRouter(
|
|||||||
engine.Use(gin.Logger(), gin.Recovery(), corsMiddleware())
|
engine.Use(gin.Logger(), gin.Recovery(), corsMiddleware())
|
||||||
|
|
||||||
registerHealthRoutes(engine, cfg, repository)
|
registerHealthRoutes(engine, cfg, repository)
|
||||||
|
registerAppPopupRoutes(engine, javaClient, services.AppPopup)
|
||||||
registerInviteRoutes(engine, javaClient, services.Invite)
|
registerInviteRoutes(engine, javaClient, services.Invite)
|
||||||
registerRechargeAgencyRoutes(engine, javaClient, services.RechargeAgency)
|
registerRechargeAgencyRoutes(engine, javaClient, services.RechargeAgency)
|
||||||
registerRechargeRewardRoutes(engine, javaClient, services.RechargeReward)
|
registerRechargeRewardRoutes(engine, javaClient, services.RechargeReward)
|
||||||
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
|
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
|
||||||
registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
|
registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
|
||||||
|
registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter)
|
||||||
registerVipRoutes(engine, javaClient, services.VIP)
|
registerVipRoutes(engine, javaClient, services.VIP)
|
||||||
|
registerRegionIMGroupRoutes(engine, cfg, javaClient, services.RegionIMGroup)
|
||||||
|
registerVoiceRoomRedPacketRoutes(engine, javaClient, services.VoiceRoomRedPacket)
|
||||||
registerWeekStarRoutes(engine, javaClient, services.WeekStar)
|
registerWeekStarRoutes(engine, javaClient, services.WeekStar)
|
||||||
registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward)
|
registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward)
|
||||||
registerHostCenterRoutes(engine, services.HostCenter)
|
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 (
|
import (
|
||||||
"chatapp3-golang/internal/integration"
|
"chatapp3-golang/internal/integration"
|
||||||
"chatapp3-golang/internal/model"
|
"chatapp3-golang/internal/model"
|
||||||
|
"chatapp3-golang/internal/service/taskcenter"
|
||||||
"chatapp3-golang/internal/utils"
|
"chatapp3-golang/internal/utils"
|
||||||
"context"
|
"context"
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
@ -10,6 +11,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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.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.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
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -255,6 +258,27 @@ func (s *BaishunService) HandleReport(ctx context.Context, payload map[string]an
|
|||||||
return resp
|
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 处理百顺查询余额回调。
|
// HandleBalanceInfo 处理百顺查询余额回调。
|
||||||
func (s *BaishunService) HandleBalanceInfo(ctx context.Context, req BaishunBalanceInfoRequest, rawJSON string) baishunBalanceInfoResponse {
|
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 {
|
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
|
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 {
|
type baishunPorts struct {
|
||||||
DB baishunDB
|
DB baishunDB
|
||||||
Redis redis.Cmdable
|
Redis redis.Cmdable
|
||||||
@ -57,10 +61,11 @@ type baishunPorts struct {
|
|||||||
|
|
||||||
// BaishunService 负责百顺目录同步、游戏启动、房间状态以及回调处理。
|
// BaishunService 负责百顺目录同步、游戏启动、房间状态以及回调处理。
|
||||||
type BaishunService struct {
|
type BaishunService struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
repo baishunPorts
|
repo baishunPorts
|
||||||
java baishunGateway
|
java baishunGateway
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
taskReporter taskEventReporter
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBaishunService 创建百顺接入服务实例。
|
// 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 是房间游戏列表中的单个游戏项。
|
// RoomGameListItem 是房间游戏列表中的单个游戏项。
|
||||||
type RoomGameListItem struct {
|
type RoomGameListItem struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
|||||||
@ -3,9 +3,11 @@ package gameopen
|
|||||||
import (
|
import (
|
||||||
"chatapp3-golang/internal/integration"
|
"chatapp3-golang/internal/integration"
|
||||||
"chatapp3-golang/internal/model"
|
"chatapp3-golang/internal/model"
|
||||||
|
"chatapp3-golang/internal/service/taskcenter"
|
||||||
"chatapp3-golang/internal/utils"
|
"chatapp3-golang/internal/utils"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@ -134,6 +136,7 @@ func (s *GameOpenService) HandleUpdateCoin(ctx context.Context, req UpdateCoinRe
|
|||||||
|
|
||||||
resp := successResponse(map[string]any{"coin": balance})
|
resp := successResponse(map[string]any{"coin": balance})
|
||||||
s.saveCallbackLog(ctx, "update-coin", rawJSON, resp, credential.SysOrigin, req, &userID, gameOpenLogStatusSuccess)
|
s.saveCallbackLog(ctx, "update-coin", rawJSON, resp, credential.SysOrigin, req, &userID, gameOpenLogStatusSuccess)
|
||||||
|
s.reportGameConsumeTaskEvent(ctx, credential.SysOrigin, userID, req)
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -204,6 +207,30 @@ func (s *GameOpenService) readUserBalance(ctx context.Context, userID int64) (in
|
|||||||
return balances[userID], nil
|
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 {
|
type resolvedGameOpenUser struct {
|
||||||
userID int64
|
userID int64
|
||||||
profile integration.UserProfile
|
profile integration.UserProfile
|
||||||
|
|||||||
@ -37,15 +37,20 @@ type gameOpenGateway interface {
|
|||||||
ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error
|
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 {
|
type gameOpenPorts struct {
|
||||||
DB gameOpenDB
|
DB gameOpenDB
|
||||||
}
|
}
|
||||||
|
|
||||||
// GameOpenService 负责统一三方游戏回调接口。
|
// GameOpenService 负责统一三方游戏回调接口。
|
||||||
type GameOpenService struct {
|
type GameOpenService struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
repo gameOpenPorts
|
repo gameOpenPorts
|
||||||
java gameOpenGateway
|
java gameOpenGateway
|
||||||
|
taskReporter taskEventReporter
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGameOpenService 创建统一三方游戏回调服务。
|
// NewGameOpenService 创建统一三方游戏回调服务。
|
||||||
@ -60,6 +65,10 @@ func NewGameOpenService(cfg config.Config, db gameOpenDB, javaGateway gameOpenGa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *GameOpenService) SetTaskEventReporter(reporter taskEventReporter) {
|
||||||
|
s.taskReporter = reporter
|
||||||
|
}
|
||||||
|
|
||||||
// QueryUserRequest 是统一查询用户信息入参。
|
// QueryUserRequest 是统一查询用户信息入参。
|
||||||
type QueryUserRequest struct {
|
type QueryUserRequest struct {
|
||||||
GameID string `json:"gameId"`
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"chatapp3-golang/internal/model"
|
"chatapp3-golang/internal/model"
|
||||||
|
"chatapp3-golang/internal/service/taskcenter"
|
||||||
"chatapp3-golang/internal/utils"
|
"chatapp3-golang/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -19,6 +21,7 @@ func (s *WeekStarService) ProcessGiftEvent(ctx context.Context, event weekStarGi
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
eventTime := resolveMillisTime(int64(event.CreateTime), time.Now()).In(s.location)
|
eventTime := resolveMillisTime(int64(event.CreateTime), time.Now()).In(s.location)
|
||||||
|
s.reportGiftConsumeTaskEvent(ctx, sysOrigin, event, eventTime)
|
||||||
configRow, giftRows, _, err := s.loadActiveConfigBundle(ctx, sysOrigin, eventTime)
|
configRow, giftRows, _, err := s.loadActiveConfigBundle(ctx, sysOrigin, eventTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -91,6 +94,32 @@ func (s *WeekStarService) ProcessGiftEvent(ctx context.Context, event weekStarGi
|
|||||||
}).Error
|
}).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 调用。
|
// ProcessGiftPayload 解析送礼消息原文并复用周榜统计逻辑,方便本地压测和非 RocketMQ 调用。
|
||||||
func (s *WeekStarService) ProcessGiftPayload(ctx context.Context, payload string) error {
|
func (s *WeekStarService) ProcessGiftPayload(ctx context.Context, payload string) error {
|
||||||
payload = strings.TrimSpace(payload)
|
payload = strings.TrimSpace(payload)
|
||||||
|
|||||||
@ -319,6 +319,10 @@ type weekStarPorts struct {
|
|||||||
Redis redis.Cmdable
|
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 聚合页和后台补发任务投递。
|
// WeekStarService 负责周榜配置、实时积分、H5 聚合页和后台补发任务投递。
|
||||||
type WeekStarService struct {
|
type WeekStarService struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
@ -326,6 +330,7 @@ type WeekStarService struct {
|
|||||||
java weekStarGateway
|
java weekStarGateway
|
||||||
location *time.Location
|
location *time.Location
|
||||||
rocketConsumer rmq.PushConsumer
|
rocketConsumer rmq.PushConsumer
|
||||||
|
taskReporter taskEventReporter
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWeekStarService 创建指定礼物周榜服务,并初始化统一使用的活动时区。
|
// NewWeekStarService 创建指定礼物周榜服务,并初始化统一使用的活动时区。
|
||||||
@ -341,3 +346,7 @@ func NewWeekStarService(cfg config.Config, db weekStarDB, cache redis.Cmdable, j
|
|||||||
location: location,
|
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