Compare commits

...

107 Commits

Author SHA1 Message Date
hy001
523cfe961f optimize archive retry claim SQL 2026-07-09 23:02:09 +08:00
hy001
90ab87393e fix task center archive claim SQL 2026-07-08 20:03:52 +08:00
hy001
714c1c5c68 feat: allow manager center VIP4 and VIP5 gifts 2026-07-07 19:58:23 +08:00
hy001
4bb8613570 经理中心 2026-06-24 15:39:42 +08:00
hy001
53c6f361f9 经理中心配置 2026-06-22 17:34:52 +08:00
hy001
e370c3ca32 Hide game lists for iOS clients 2026-06-16 14:09:15 +08:00
hy001
995268fd70 经理中心 2026-06-12 19:37:04 +08:00
hy001
e086a518bc 修复后台 2026-06-10 19:26:15 +08:00
hy001
4219117721 增加热游游戏 2026-06-10 15:58:52 +08:00
hy001
a71dd0fbae test文件 2026-06-09 16:26:03 +08:00
hy001
81b1ff88bf 游戏列表 2026-06-09 16:18:19 +08:00
hy001
4e3c9d01aa 隐藏土耳其地区的游戏 2026-06-09 15:42:52 +08:00
hy001
cbe388b071 feat(wheel): split gift and decoration reward values 2026-06-03 21:02:14 +08:00
hy001
8147b5981f Hide provider game lists for test user 2026-06-02 20:49:58 +08:00
hy001
81cdba4096 Merge branch 'test' 2026-06-01 19:46:35 +08:00
hy001
ecb2fcba4e 自动结算 2026-06-01 19:46:21 +08:00
hy001
a9ee549e37 Hardcode empty game list for test user 2026-06-01 18:31:19 +08:00
hy001
c7c40fce77 Hardcode empty game list for test user 2026-06-01 18:30:52 +08:00
hy001
b53313f62d 增加首冲 2026-05-28 15:07:59 +08:00
hy001
e738fbc44a 修复充值奖励 2026-05-26 20:30:54 +08:00
hy001
c09433bf29 修复奖励 2026-05-26 20:06:23 +08:00
hy001
675f45bf44 修复充值奖励 2026-05-26 18:45:10 +08:00
hy001
f1b781e3be 增加范围以及首充版本识别 2026-05-25 23:20:32 +08:00
hy001
ee5b4f5eaf 1 2026-05-25 22:55:28 +08:00
hy001
72c0d1d633 修复区域飘屏 2026-05-25 22:11:02 +08:00
hy001
5b92fef113 模拟飘屏修复 2026-05-25 21:48:29 +08:00
hy001
9aecdf34a3 持续模拟 2026-05-25 21:19:09 +08:00
hy001
4cf4eba5ff 模拟飘屏 2026-05-25 21:02:28 +08:00
hy001
adcc722284 火箭结算不正确问题 2026-05-23 18:38:09 +08:00
hy001
3ec7cc6a62 修复vip类型 2026-05-23 14:54:28 +08:00
hy001
3639cad484 资源组vip 2026-05-23 12:04:14 +08:00
hy001
0a11372d96 修复最后一份红包死锁 2026-05-23 11:10:52 +08:00
hy001
e4911e7732 通用CP接口 2026-05-22 18:00:12 +08:00
hy001
747d4700df CP 2026-05-22 16:05:12 +08:00
hy001
8e8878a038 通用im 中奖消息 2026-05-22 14:22:27 +08:00
hy001
a02e22ac74 首冲奖励 2026-05-22 10:10:04 +08:00
hy001
74063d0908 充值奖励结算 2026-05-21 11:14:55 +08:00
hy001
810dc0345b fix: trim rtc reward streams 2026-05-21 10:50:18 +08:00
hy001
4926d5dfd2 修复 背包礼物不增加 火箭能量 2026-05-20 14:17:45 +08:00
hy001
c4d4f03ddf 转盘抽奖修改 2026-05-20 13:53:08 +08:00
hy001
78bddf406d 表格修改 2026-05-19 21:59:42 +08:00
hy001
165863c279 修改抽奖概率 2026-05-19 21:02:11 +08:00
hy001
886bc8998b 完善抽奖池 2026-05-19 19:50:50 +08:00
hy001
6195544950 手动领取房间奖励 2026-05-19 15:17:47 +08:00
hy001
8c53a64416 Add props store admin go routes 2026-05-18 19:22:25 +08:00
hy001
c0d58525fe feat: discount lucky gift rocket energy 2026-05-17 00:47:27 +08:00
hy001
6d90ddeee0 fix: restrict manager vip gifts 2026-05-17 00:34:56 +08:00
hy001
dbd073b97f 中奖消息加用户名称 2026-05-16 06:18:31 +08:00
hy001
43c44692ec 修改火箭 2026-05-16 06:13:15 +08:00
hy001
106033f6a9 修改头像 2026-05-16 04:09:08 +08:00
hy001
31811d5cf4 feat: grant badges from manager props send 2026-05-16 03:18:02 +08:00
hy001
c1a0049b1d fix: split badges by display type 2026-05-16 02:32:16 +08:00
hy001
8ace95f78f 徽章 2026-05-16 02:26:20 +08:00
hy001
0d8e6f599b feat: add VIP badge card resources 2026-05-16 02:21:43 +08:00
hy001
6a0ae4793b fix: add badge update time for honor wall sorting 2026-05-16 01:40:34 +08:00
hy001
463d7374af 修复火箭内容 2026-05-15 23:44:56 +08:00
hy001
ffd3eeddfd 修复发奖 2026-05-15 23:03:56 +08:00
hy001
2c5c247f12 feat: add rocket level contents api 2026-05-15 21:51:31 +08:00
hy001
39744caf2e Merge branch 'test' 2026-05-15 19:42:30 +08:00
hy001
bbf50367e2 新增声网报错 2026-05-15 19:42:02 +08:00
hy001
1abc344d05 fix: show rocket gold rewards in asset records 2026-05-15 16:50:04 +08:00
hy001
fc214f9991 feat: add daily task claim record filters 2026-05-15 15:57:10 +08:00
hy001
de605d9ccd Add voice room rocket SQL comments 2026-05-15 14:14:04 +08:00
hy001
019ffa9a4a 火箭份数配置 2026-05-15 12:19:42 +08:00
hy001
41c1d326ee 徽章 2026-05-15 12:07:26 +08:00
hy001
31433b0a1b 砸蛋,转盘 2026-05-15 09:58:01 +08:00
hy001
51b8b52c57 feat: complete voice room rocket launch rewards 2026-05-14 20:02:43 +08:00
hy001
dd9020a542 Add voice room rocket feature 2026-05-14 13:46:53 +08:00
hy001
2bba927458 灵仙游戏的key 2026-05-12 15:33:32 +08:00
hy001
171ba88a2e 经理中心以及币安充值 2026-05-12 14:08:46 +08:00
hy001
47a7725b33 fix: normalize lingxian launch token 2026-05-12 11:19:40 +08:00
hy001
ab8f711169 Merge branch 'main' of gitea.haiyihy.com:hy/chatapp3-golang 2026-05-11 18:47:18 +08:00
hy001
946c3789b1 经理中心 2026-05-11 18:47:16 +08:00
ZuoZuo
df7446fb8c Stop writing task center event rows 2026-05-10 02:43:07 +08:00
ZuoZuo
d39840a6ed Add task center MQ consumer and COS archive outbox 2026-05-10 02:05:01 +08:00
hy001
dd2f4ea6c5 修复区域缓存 2026-05-09 18:01:08 +08:00
hy001
fbe9e922f8 修复麦位任务 2026-05-09 16:02:55 +08:00
hy001
c6ba03c407 修复后台保存无效 2026-05-09 14:12:07 +08:00
hy001
c4549b16af 增加默认值 2026-05-09 00:16:01 +08:00
hy001
72b66e26b0 红包、任务需求 2026-05-08 18:55:47 +08:00
hy001
25aab0d6a2 fix(recharge-reward): show actual claim records only 2026-05-07 17:47:18 +08:00
hy001
d0007b3319 feat(recharge-reward): add reached reward records page 2026-05-07 17:30:15 +08:00
ZuoZuo
ba8ed01b99 fix: tolerate numeric recharge timestamps 2026-05-01 16:02:35 +08:00
hy001
157307a474 修复周星 2026-04-30 22:18:25 +08:00
hy001
dc6f15369c 修复周星 2026-04-30 20:53:40 +08:00
hy001
d76a11f62f 修复rocket报错 2026-04-30 20:33:44 +08:00
hy001
3c8358b6ca 修复周星 2026-04-30 17:52:47 +08:00
hy001
a2ef52c8d9 币商充值 2026-04-30 13:29:22 +08:00
hy001
f8a8d4d65f vip fixed 2026-04-29 20:14:53 +08:00
hy001
bf07c84dd0 增加VIP资源返回 2026-04-29 19:49:18 +08:00
hy001
65b0558e03 vip 2026-04-29 19:29:12 +08:00
hy001
c16c6a7bd9 vip 2026-04-29 18:45:52 +08:00
hy001
a9ab2b8589 灵仙游戏和百顺的兼容 2026-04-28 18:05:13 +08:00
hy001
24525bb4f8 修改排行榜 2026-04-28 12:23:44 +08:00
hy001
4fc2ddebf1 主播中心/邀请活动 2026-04-27 23:27:37 +08:00
hy001
7c1ecb804e fix invite calendar month reset 2026-04-27 21:10:27 +08:00
hy001
362069e2d4 fix invite rule id precision 2026-04-27 20:56:49 +08:00
hy001
b7c0225dc3 邀请 2026-04-27 19:12:00 +08:00
hy001
86c8e0c9ec 修复注册礼物不生效 2026-04-27 12:38:54 +08:00
hy001
c4f75b4846 z
增加邀请活动
2026-04-24 17:11:13 +08:00
hy001
42b3a4b595 增加狗台修复 2026-04-24 14:56:07 +08:00
hy001
fa564ef985 百顺游戏 2026-04-24 14:05:15 +08:00
hy001
8256fdf7e6 1 2026-04-24 13:04:06 +08:00
hy001
5e81478c8b 统一三方游戏重复订单返回10003 2026-04-23 18:00:34 +08:00
hy001
1d2bc6eafd 灵仙gateway 2026-04-23 16:03:13 +08:00
hy001
3e2719f682 修复幸运礼物 2026-04-23 12:24:32 +08:00
hy001
8f8ae40947 fix: preserve baishun provider response fields 2026-04-22 20:39:41 +08:00
333 changed files with 61769 additions and 503 deletions

View File

@ -29,6 +29,7 @@ internal/utils
### 1. `cmd/api`
- 只做启动和依赖装配。
- 不承担业务逻辑。
- 当前线上只部署 `golang` API 进程,所以这里会同时启动 `registerreward` 的 Redis Stream consumer 和 `weekstar` 的 RocketMQ consumer确保注册成功与周星送礼事件能被消费。
- 不启动任何 `weekstar` 结算 goroutine。
### 2. `cmd/consumer`
@ -36,6 +37,7 @@ internal/utils
- 当前承接:
- `registerreward` 的 Redis Stream 消费
- `weekstar` 的 RocketMQ 消费
- 如果后续单独部署 consumer`registerreward` 使用同一个 Redis consumer group重复启动不会重复发奖。
### 3. `internal/bootstrap`
- 统一完成配置加载、存储初始化、依赖探活、Java 网关装配。

View File

@ -1,16 +1,6 @@
package main
import (
"chatapp3-golang/internal/bootstrap"
"chatapp3-golang/internal/router"
"chatapp3-golang/internal/service/baishun"
"chatapp3-golang/internal/service/gameopen"
"chatapp3-golang/internal/service/gameprovider"
"chatapp3-golang/internal/service/invite"
"chatapp3-golang/internal/service/luckygift"
"chatapp3-golang/internal/service/registerreward"
"chatapp3-golang/internal/service/signinreward"
"chatapp3-golang/internal/service/weekstar"
"context"
"errors"
"log"
@ -19,6 +9,38 @@ import (
"syscall"
"time"
_ "time/tzdata"
"chatapp3-golang/internal/bootstrap"
"chatapp3-golang/internal/router"
"chatapp3-golang/internal/service/apppopup"
"chatapp3-golang/internal/service/baishun"
"chatapp3-golang/internal/service/binancerecharge"
"chatapp3-golang/internal/service/cprelationbroadcast"
"chatapp3-golang/internal/service/errorlog"
"chatapp3-golang/internal/service/firstrechargereward"
"chatapp3-golang/internal/service/gameopen"
"chatapp3-golang/internal/service/gameprovider"
"chatapp3-golang/internal/service/hostcenter"
"chatapp3-golang/internal/service/hotgame"
"chatapp3-golang/internal/service/invite"
"chatapp3-golang/internal/service/lingxian"
"chatapp3-golang/internal/service/luckygift"
"chatapp3-golang/internal/service/managercenter"
"chatapp3-golang/internal/service/propsstore"
"chatapp3-golang/internal/service/rechargeagency"
"chatapp3-golang/internal/service/rechargereward"
"chatapp3-golang/internal/service/regionimgroup"
"chatapp3-golang/internal/service/registerreward"
"chatapp3-golang/internal/service/roomturnoverreward"
"chatapp3-golang/internal/service/signinreward"
"chatapp3-golang/internal/service/smashegg"
"chatapp3-golang/internal/service/taskcenter"
"chatapp3-golang/internal/service/userbadge"
"chatapp3-golang/internal/service/vip"
"chatapp3-golang/internal/service/voiceroomredpacket"
"chatapp3-golang/internal/service/voiceroomrocket"
"chatapp3-golang/internal/service/weekstar"
"chatapp3-golang/internal/service/wheel"
)
func main() {
@ -30,25 +52,107 @@ func main() {
log.Fatalf("bootstrap api failed: %v", err)
}
appPopupService := apppopup.NewService(app.Config, app.Repository.DB, app.Repository.Redis)
errorLogService := errorlog.NewService(app.Repository.DB)
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)
binanceRechargeService := binancerecharge.NewService(app.Config, app.Repository.DB, &app.Gateways)
lingxianService := lingxian.NewService(app.Config, app.Repository.DB, app.Repository.Redis)
hotgameService := hotgame.NewService(app.Config, app.Repository.DB, app.Repository.Redis)
gameOpenService := gameopen.NewGameOpenService(app.Config, app.Repository.DB, &app.Gateways)
luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet)
rechargeAgencyService := rechargeagency.NewService(app.Config, app.Repository.DB, &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)
firstRechargeRewardService := firstrechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways)
signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
userBadgeService := userbadge.NewService(app.Repository.DB)
vipService := vip.NewService(app.Config, app.Repository.DB, &app.Gateways)
wheelService := wheel.NewService(app.Config, app.Repository.DB, &app.Gateways)
smashEggService := smashegg.NewService(app.Config, app.Repository.DB, &app.Gateways)
regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB, &app.Gateways)
baishunService.SetHighWinBroadcaster(regionIMGroupService)
luckyGiftService.SetHighWinBroadcaster(regionIMGroupService)
cpRelationBroadcastService := cprelationbroadcast.NewService(app.Config, app.Repository.DB, regionIMGroupService)
voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room)
voiceRoomRedPacketService.SetRegionResolver(&app.Gateways)
voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
voiceRoomRocketService.SetRegionBroadcaster(regionIMGroupService)
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways)
hostCenterService := hostcenter.NewService(app.Config, &app.Gateways)
managerCenterService := managercenter.NewService(app.Repository.DB, &app.Gateways)
propsStoreService := propsstore.NewService(app.Repository.DB, app.Repository.Redis)
baishunService.SetTaskEventReporter(taskCenterService)
gameOpenService.SetTaskEventReporter(taskCenterService)
weekStarService.SetTaskEventReporter(taskCenterService)
workerCtx, workerCancel := context.WithCancel(context.Background())
defer workerCancel()
if err := registerRewardService.Start(workerCtx); err != nil {
log.Fatalf("start register reward consumer failed: %v", err)
}
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start week star message consumer failed: %v", err)
}
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start task center message consumer failed: %v", err)
}
if err := firstRechargeRewardService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start first recharge reward message consumer failed: %v", err)
}
if err := cpRelationBroadcastService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start cp relation broadcast message consumer failed: %v", err)
}
if err := voiceRoomRocketService.Start(workerCtx); err != nil {
log.Fatalf("start voice room rocket workers failed: %v", err)
}
if err := taskCenterService.StartArchiveWorker(workerCtx); err != nil {
log.Fatalf("start task center archive worker failed: %v", err)
}
voiceRoomRedPacketService.StartRefundWorker(workerCtx)
gameProviders := gameprovider.NewRegistry(
baishun.NewAppProvider(baishunService),
lingxian.NewAppProvider(lingxianService),
hotgame.NewAppProvider(hotgameService),
)
gameVisibility := gameprovider.NewVisibilityPolicy(
&app.Gateways,
gameprovider.NewCachedIPCountryResolver(app.Repository.Redis, app.Config.HTTP.Timeout),
)
engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{
Invite: inviteService,
Baishun: baishunService,
GameOpen: gameOpenService,
GameProviders: gameProviders,
LuckyGift: luckyGiftService,
RegisterReward: registerRewardService,
SignInReward: signInRewardService,
WeekStar: weekStarService,
AppPopup: appPopupService,
ErrorLog: errorLogService,
Invite: inviteService,
Baishun: baishunService,
BinanceRecharge: binanceRechargeService,
FirstRechargeReward: firstRechargeRewardService,
Lingxian: lingxianService,
GameOpen: gameOpenService,
GameProviders: gameProviders,
GameVisibility: gameVisibility,
LuckyGift: luckyGiftService,
ManagerCenter: managerCenterService,
PropsStore: propsStoreService,
HostCenter: hostCenterService,
Hotgame: hotgameService,
RechargeAgency: rechargeAgencyService,
RechargeReward: rechargeRewardService,
RegionIMGroup: regionIMGroupService,
RegisterReward: registerRewardService,
RoomTurnoverReward: roomTurnoverRewardService,
SignInReward: signInRewardService,
SmashEgg: smashEggService,
TaskCenter: taskCenterService,
UserBadge: userBadgeService,
VIP: vipService,
Wheel: wheelService,
VoiceRoomRedPacket: voiceRoomRedPacketService,
VoiceRoomRocket: voiceRoomRocketService,
WeekStar: weekStarService,
})
server := &http.Server{
Addr: app.Config.HTTP.ListenAddr,
@ -76,6 +180,7 @@ func main() {
return
case <-shutdownSignal.Done():
log.Printf("shutdown signal received, draining http traffic")
workerCancel()
}
shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), app.Config.HTTP.ShutdownTimeout)

View File

@ -2,7 +2,13 @@ package main
import (
"chatapp3-golang/internal/bootstrap"
"chatapp3-golang/internal/service/cprelationbroadcast"
"chatapp3-golang/internal/service/firstrechargereward"
"chatapp3-golang/internal/service/regionimgroup"
"chatapp3-golang/internal/service/registerreward"
"chatapp3-golang/internal/service/taskcenter"
"chatapp3-golang/internal/service/voiceroomredpacket"
"chatapp3-golang/internal/service/voiceroomrocket"
"chatapp3-golang/internal/service/weekstar"
"context"
"log"
@ -22,6 +28,14 @@ func main() {
registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
firstRechargeRewardService := firstrechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways)
regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB, &app.Gateways)
cpRelationBroadcastService := cprelationbroadcast.NewService(app.Config, app.Repository.DB, regionIMGroupService)
voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room)
voiceRoomRedPacketService.SetRegionResolver(&app.Gateways)
voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
voiceRoomRocketService.SetRegionBroadcaster(regionIMGroupService)
workerCtx, workerCancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer workerCancel()
@ -32,6 +46,22 @@ func main() {
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start week star message consumer failed: %v", err)
}
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start task center message consumer failed: %v", err)
}
if err := firstRechargeRewardService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start first recharge reward message consumer failed: %v", err)
}
if err := cpRelationBroadcastService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start cp relation broadcast message consumer failed: %v", err)
}
if err := voiceRoomRocketService.Start(workerCtx); err != nil {
log.Fatalf("start voice room rocket workers failed: %v", err)
}
if err := taskCenterService.StartArchiveWorker(workerCtx); err != nil {
log.Fatalf("start task center archive worker failed: %v", err)
}
voiceRoomRedPacketService.StartRefundWorker(workerCtx)
log.Printf("chatapp3-golang consumer started")
<-workerCtx.Done()

5
go.mod
View File

@ -18,6 +18,7 @@ require (
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj v1.8.4 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dchest/siphash v1.2.3 // indirect
@ -32,6 +33,7 @@ require (
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/mock v1.6.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-querystring v1.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
@ -42,12 +44,15 @@ require (
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/mitchellh/mapstructure v1.4.3 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mozillazg/go-httpheader v0.2.1 // indirect
github.com/natefinch/lumberjack v2.0.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pierrec/lz4 v2.6.1+incompatible // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/tencentyun/cos-go-sdk-v5 v0.7.73 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/valyala/fastrand v1.1.0 // indirect

16
go.sum
View File

@ -49,6 +49,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
@ -94,6 +96,7 @@ github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@ -132,6 +135,8 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
@ -140,6 +145,7 @@ github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hf
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@ -179,11 +185,15 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs=
github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mozillazg/go-httpheader v0.2.1 h1:geV7TrjbL8KXSyvghnFm+NyTux/hxwueTSrwhe88TQQ=
github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60=
github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM=
github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
@ -201,6 +211,7 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@ -214,6 +225,11 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0=
github.com/tencentyun/cos-go-sdk-v5 v0.7.73 h1:uFfgp1A7cQaAGR6QP9DsIkoEQ67b8ewj5r1RV6XB540=
github.com/tencentyun/cos-go-sdk-v5 v0.7.73/go.mod h1:STbTNaNKq03u+gscPEGOahKzLcGSYOj6Dzc5zNay7Pg=
github.com/tencentyun/qcloud-cos-sts-sdk v0.0.0-20250515025012-e0eec8a5d123/go.mod h1:b18KQa4IxHbxeseW1GcZox53d7J0z39VNONTxvvlkXw=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=

View File

@ -6,4 +6,6 @@ type AuthUser struct {
SysOrigin string
Token string
Authorization string
RegionID string
RegionCode string
}

View File

@ -8,17 +8,34 @@ import (
"time"
)
const (
cpRelationBroadcastMQConsumerGroup = "cp-relation-broadcast"
cpRelationBroadcastMQTopic = "RC_DEFAULT_APP_ORDINARY"
cpRelationBroadcastMQTag = "cp_relation_broadcast"
)
// Config 是按运行层和业务模块分段后的应用配置。
type Config struct {
HTTP HTTPConfig
Store StoreConfig
Java JavaConfig
Worker WorkerConfig
Invite InviteConfig
WeekStar WeekStarConfig
Baishun BaishunConfig
GameOpen GameOpenConfig
LuckyGift LuckyGiftConfig
HTTP HTTPConfig
Store StoreConfig
Java JavaConfig
Worker WorkerConfig
Invite InviteConfig
RechargeReward RechargeRewardConfig
FirstRechargeReward FirstRechargeRewardConfig
TaskCenter TaskCenterConfig
WeekStar WeekStarConfig
RoomTurnoverReward RoomTurnoverRewardConfig
VoiceRoomRedPacket VoiceRoomRedPacketConfig
VoiceRoomRocket VoiceRoomRocketConfig
CPRelationBroadcast CPRelationBroadcastConfig
TencentIM TencentIMConfig
Baishun BaishunConfig
Lingxian LingxianConfig
Hotgame HotgameConfig
GameOpen GameOpenConfig
LuckyGift LuckyGiftConfig
BinanceRecharge BinanceRechargeConfig
}
// HTTPConfig 保存 HTTP 服务运行配置。
@ -46,6 +63,7 @@ type JavaConfig struct {
BridgeBaseURL string
ExternalBaseURL string
OtherBaseURL string
OrderBaseURL string
WalletBaseURL string
}
@ -67,6 +85,70 @@ type RegisterRewardWorkerConfig struct {
// InviteConfig 保存邀请活动相关配置。
type InviteConfig struct {
PublicBaseURL string
PublicH5Path string
}
// RechargeRewardConfig 保存充值奖励活动配置。
type RechargeRewardConfig struct {
DefaultSysOrigin string
Timezone string
StorageTimezone string
StartDate string
CoinSellerGoldPerUSD int64
}
// FirstRechargeRewardConfig 保存首冲奖励模块配置。
type FirstRechargeRewardConfig struct {
DefaultSysOrigin string
MQ FirstRechargeRewardMQConfig
}
// FirstRechargeRewardMQConfig 保存首冲奖励订阅通用充值成功 MQ 的消费配置。
type FirstRechargeRewardMQConfig struct {
Enabled bool
Endpoint string
Namespace string
AccessKey string
AccessSecret string
SecurityToken string
ConsumerGroup string
Topic string
Tag string
}
// TaskCenterConfig 保存任务中心模块配置。
type TaskCenterConfig struct {
DefaultSysOrigin string
Timezone string
InternalEventTTL time.Duration
MQ TaskCenterMQConfig
Archive TaskCenterArchiveConfig
}
// TaskCenterMQConfig 保存任务中心事件 MQ 消费配置。
type TaskCenterMQConfig struct {
Enabled bool
Endpoint string
Namespace string
AccessKey string
AccessSecret string
SecurityToken string
ConsumerGroup string
Topic string
Tag string
}
// TaskCenterArchiveConfig 保存任务中心原始事件 COS 归档配置。
type TaskCenterArchiveConfig struct {
Enabled bool
Bucket string
Region string
SecretID string
SecretKey string
Prefix string
BatchSize int
FlushSeconds int
Workers int
}
// WeekStarConfig 保存周榜模块配置。
@ -90,9 +172,88 @@ type WeekStarRocketMQConfig struct {
Tag string
}
// RoomTurnoverRewardConfig 保存房间流水奖励模块配置。
type RoomTurnoverRewardConfig struct {
DefaultSysOrigin string
Timezone string
CycleDuration time.Duration
}
// VoiceRoomRedPacketConfig 保存语音房红包 Go 侧运行配置。
type VoiceRoomRedPacketConfig struct {
DefaultSysOrigin string
Timezone string
PresenceTTLSeconds int
RefundScanIntervalSeconds int
RefundBatchSize int
NotifyStreamKey string
ExpireZSetKey string
}
// VoiceRoomRocketConfig 保存语音房火箭 Go 侧运行配置。
type VoiceRoomRocketConfig struct {
DefaultSysOrigin string
Timezone string
RewardRetryStreamKey string
InRoomRewardDueZSetKey string
RoomLockTTLSeconds int
BroadcastStreamKey string
BroadcastStreamMaxLen int64
InRoomRewardScanIntervalSeconds int
InRoomRewardBatchSize int
InRoomRewardUserLimit int
RewardGrantScanIntervalSeconds int
RewardGrantBatchSize int
MQ VoiceRoomRocketMQConfig
}
// VoiceRoomRocketMQConfig 保存语音房火箭送礼 MQ 消费配置。
type VoiceRoomRocketMQConfig struct {
Enabled bool
Endpoint string
Namespace string
AccessKey string
AccessSecret string
SecurityToken string
ConsumerGroup string
Topic string
Tag string
}
// CPRelationBroadcastConfig 保存 CP/兄弟/姐妹关系区域飘屏配置。
type CPRelationBroadcastConfig struct {
DefaultSysOrigin string
MQ CPRelationBroadcastMQConfig
}
// CPRelationBroadcastMQConfig 保存 CP 关系成功 MQ 消费配置。
type CPRelationBroadcastMQConfig struct {
Enabled bool
Endpoint string
Namespace string
AccessKey string
AccessSecret string
SecurityToken string
ConsumerGroup string
Topic string
Tag string
}
// TencentIMConfig 保存腾讯 IM REST API 配置。
type TencentIMConfig struct {
AppID int64
Key string
Identifier string
BaseEndpoint string
UserSigExpireSeconds int64
RequestTimeoutSeconds int
}
// BaishunConfig 保存百顺模块配置。
type BaishunConfig struct {
PlatformBaseURL string
GameListURL string
LuckyGiftURL string
AppID int64
AppName string
AppChannel string
@ -102,6 +263,18 @@ type BaishunConfig struct {
SSTokenTTLSeconds int
}
// LingxianConfig 保存灵仙模块配置。
type LingxianConfig struct {
GameListURL string
AppKey string
}
// HotgameConfig 保存热游模块的环境兜底配置。
type HotgameConfig struct {
AppKey string
CallbackBaseURL string
}
// GameOpenConfig 保存统一三方游戏回调配置。
type GameOpenConfig struct {
PublicBaseURL string
@ -113,10 +286,16 @@ type GameOpenConfig struct {
// LuckyGiftConfig 保存幸运礼物模块配置。
type LuckyGiftConfig struct {
Enabled bool
Timeout time.Duration
RewardStreamKey string
Providers []LuckyGiftProviderConfig
Enabled bool
Timeout time.Duration
RewardStreamKey string
RewardStreamMaxLen int64
Providers []LuckyGiftProviderConfig
}
// BinanceRechargeConfig 保存币安自动充值验证的运行配置。
type BinanceRechargeConfig struct {
BaseURL string
}
type LuckyGiftProviderConfig struct {
@ -130,6 +309,27 @@ type LuckyGiftProviderConfig struct {
func Load() Config {
javaAppBaseURL := getEnvAny([]string{"CHATAPP_JAVA_APP_BASE_URL", "INVITE_JAVA_APP_BASE_URL"}, "http://127.0.0.1:2400")
consoleBaseURL := getEnvAny([]string{"CHATAPP_JAVA_CONSOLE_BASE_URL", "JAVA_CONSOLE_BASE_URL"}, "http://127.0.0.1:2700/console")
weekStarRocketMQEndpoint := getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_ENDPOINT", "WEEK_STAR_ROCKETMQ_ENDPOINT"}, "")
weekStarRocketMQAccessKey := getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_KEY", "WEEK_STAR_ROCKETMQ_ACCESS_KEY"}, "")
weekStarRocketMQAccessSecret := getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_SECRET", "WEEK_STAR_ROCKETMQ_ACCESS_SECRET"}, "")
weekStarRocketMQEnabledDefault := strings.TrimSpace(weekStarRocketMQEndpoint) != "" &&
strings.TrimSpace(weekStarRocketMQAccessKey) != "" &&
strings.TrimSpace(weekStarRocketMQAccessSecret) != ""
taskCenterMQEndpoint := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ENDPOINT", "TASK_CENTER_EVENT_MQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT"}, "")
taskCenterMQAccessKey := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ACCESS_KEY", "TASK_CENTER_EVENT_MQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY"}, "")
taskCenterMQAccessSecret := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ACCESS_SECRET", "TASK_CENTER_EVENT_MQ_SECRET_KEY", "LIKEI_ROCKETMQ_SECRET_KEY"}, "")
firstRechargeRewardMQEndpoint := getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_ENDPOINT", "FIRST_RECHARGE_REWARD_MQ_ENDPOINT", "CHATAPP_RECHARGE_SUCCESS_MQ_ENDPOINT", "RECHARGE_SUCCESS_MQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT"}, "")
firstRechargeRewardMQAccessKey := getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_ACCESS_KEY", "FIRST_RECHARGE_REWARD_MQ_ACCESS_KEY", "CHATAPP_RECHARGE_SUCCESS_MQ_ACCESS_KEY", "RECHARGE_SUCCESS_MQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY"}, "")
firstRechargeRewardMQAccessSecret := getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_ACCESS_SECRET", "FIRST_RECHARGE_REWARD_MQ_SECRET_KEY", "CHATAPP_RECHARGE_SUCCESS_MQ_ACCESS_SECRET", "RECHARGE_SUCCESS_MQ_SECRET_KEY", "LIKEI_ROCKETMQ_SECRET_KEY"}, "")
voiceRoomRocketMQEndpoint := getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT"}, "")
voiceRoomRocketMQAccessKey := getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY"}, "")
voiceRoomRocketMQAccessSecret := getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_ACCESS_SECRET", "LIKEI_ROCKETMQ_SECRET_KEY"}, "")
cpRelationBroadcastMQEndpoint := getEnvAny([]string{"LIKEI_ROCKETMQ_ENDPOINT", "CHATAPP_TASK_CENTER_MQ_ENDPOINT", "TASK_CENTER_EVENT_MQ_ENDPOINT"}, "")
cpRelationBroadcastMQAccessKey := getEnvAny([]string{"LIKEI_ROCKETMQ_ACCESS_KEY", "CHATAPP_TASK_CENTER_MQ_ACCESS_KEY", "TASK_CENTER_EVENT_MQ_ACCESS_KEY"}, "")
cpRelationBroadcastMQAccessSecret := getEnvAny([]string{"LIKEI_ROCKETMQ_SECRET_KEY", "CHATAPP_TASK_CENTER_MQ_ACCESS_SECRET", "TASK_CENTER_EVENT_MQ_SECRET_KEY"}, "")
cpRelationBroadcastMQEnabled := strings.TrimSpace(cpRelationBroadcastMQEndpoint) != "" &&
strings.TrimSpace(cpRelationBroadcastMQAccessKey) != "" &&
strings.TrimSpace(cpRelationBroadcastMQAccessSecret) != ""
return Config{
HTTP: HTTPConfig{
@ -152,6 +352,7 @@ func Load() Config {
BridgeBaseURL: getEnvAny([]string{"CHATAPP_JAVA_BRIDGE_BASE_URL", "INVITE_JAVA_BRIDGE_BASE_URL"}, consoleBaseURL),
ExternalBaseURL: getEnvAny([]string{"CHATAPP_JAVA_EXTERNAL_BASE_URL", "INVITE_JAVA_EXTERNAL_BASE_URL"}, "http://127.0.0.1:3000"),
OtherBaseURL: getEnvAny([]string{"CHATAPP_JAVA_OTHER_BASE_URL", "GAME_JAVA_OTHER_BASE_URL", "INVITE_JAVA_APP_BASE_URL"}, javaAppBaseURL),
OrderBaseURL: getEnvAny([]string{"CHATAPP_JAVA_ORDER_BASE_URL", "GAME_JAVA_ORDER_BASE_URL", "LIKEI_GATEWAY_ROUTE_ORDER_URI"}, "http://127.0.0.1:2600"),
WalletBaseURL: getEnvAny([]string{"CHATAPP_JAVA_WALLET_BASE_URL", "GAME_JAVA_WALLET_BASE_URL"}, "http://127.0.0.1:2300"),
},
Worker: WorkerConfig{
@ -166,25 +367,251 @@ func Load() Config {
},
Invite: InviteConfig{
PublicBaseURL: getEnvAny([]string{"CHATAPP_INVITE_PUBLIC_BASE_URL", "INVITE_PUBLIC_BASE_URL"}, "http://localhost:2900"),
PublicH5Path: getEnvAny([]string{"CHATAPP_INVITE_PUBLIC_H5_PATH", "INVITE_PUBLIC_H5_PATH"}, "/h5/app-invite/landing.html"),
},
RechargeReward: RechargeRewardConfig{
DefaultSysOrigin: strings.ToUpper(getEnvAny(
[]string{"CHATAPP_RECHARGE_REWARD_DEFAULT_SYS_ORIGIN", "RECHARGE_REWARD_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"},
"LIKEI",
)),
Timezone: getEnvAny(
[]string{"CHATAPP_RECHARGE_REWARD_TIMEZONE", "RECHARGE_REWARD_TIMEZONE", "CHATAPP_WEEK_STAR_TIMEZONE", "WEEK_STAR_TIMEZONE"},
"Asia/Riyadh",
),
StorageTimezone: getEnvAny(
[]string{"CHATAPP_RECHARGE_REWARD_STORAGE_TIMEZONE", "RECHARGE_REWARD_STORAGE_TIMEZONE"},
"Asia/Shanghai",
),
StartDate: getEnvAny(
[]string{"CHATAPP_RECHARGE_REWARD_START_DATE", "RECHARGE_REWARD_START_DATE"},
"2026-05-21",
),
CoinSellerGoldPerUSD: int64(getEnvIntAny(
[]string{"CHATAPP_RECHARGE_REWARD_COIN_SELLER_GOLD_PER_USD", "RECHARGE_REWARD_COIN_SELLER_GOLD_PER_USD"},
100000,
)),
},
FirstRechargeReward: FirstRechargeRewardConfig{
DefaultSysOrigin: strings.ToUpper(getEnvAny(
[]string{"CHATAPP_FIRST_RECHARGE_REWARD_DEFAULT_SYS_ORIGIN", "FIRST_RECHARGE_REWARD_DEFAULT_SYS_ORIGIN", "CHATAPP_RECHARGE_REWARD_DEFAULT_SYS_ORIGIN", "RECHARGE_REWARD_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"},
"LIKEI",
)),
MQ: FirstRechargeRewardMQConfig{
Enabled: getEnvBoolAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_ENABLED", "FIRST_RECHARGE_REWARD_MQ_ENABLED", "CHATAPP_RECHARGE_SUCCESS_MQ_ENABLED", "RECHARGE_SUCCESS_MQ_ENABLED"}, false),
Endpoint: firstRechargeRewardMQEndpoint,
Namespace: getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_NAMESPACE", "FIRST_RECHARGE_REWARD_MQ_NAMESPACE", "CHATAPP_RECHARGE_SUCCESS_MQ_NAMESPACE", "RECHARGE_SUCCESS_MQ_NAMESPACE"}, ""),
AccessKey: firstRechargeRewardMQAccessKey,
AccessSecret: firstRechargeRewardMQAccessSecret,
SecurityToken: getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_SECURITY_TOKEN", "FIRST_RECHARGE_REWARD_MQ_SECURITY_TOKEN", "CHATAPP_RECHARGE_SUCCESS_MQ_SECURITY_TOKEN", "RECHARGE_SUCCESS_MQ_SECURITY_TOKEN"}, ""),
ConsumerGroup: getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_CONSUMER_GROUP", "FIRST_RECHARGE_REWARD_MQ_CONSUMER_GROUP"}, "first-recharge-reward"),
Topic: getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_TOPIC", "FIRST_RECHARGE_REWARD_MQ_TOPIC", "CHATAPP_RECHARGE_SUCCESS_MQ_TOPIC", "RECHARGE_SUCCESS_MQ_TOPIC"}, "RC_DEFAULT_APP_ORDINARY"),
Tag: getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_TAG", "FIRST_RECHARGE_REWARD_MQ_TAG", "CHATAPP_RECHARGE_SUCCESS_MQ_TAG", "RECHARGE_SUCCESS_MQ_TAG"}, "recharge_success_v1"),
},
},
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,
MQ: TaskCenterMQConfig{
Enabled: getEnvBoolAny([]string{"CHATAPP_TASK_CENTER_MQ_ENABLED", "TASK_CENTER_EVENT_MQ_ENABLED"}, false),
Endpoint: taskCenterMQEndpoint,
Namespace: getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_NAMESPACE", "TASK_CENTER_EVENT_MQ_NAMESPACE"}, ""),
AccessKey: taskCenterMQAccessKey,
AccessSecret: taskCenterMQAccessSecret,
SecurityToken: getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_SECURITY_TOKEN", "TASK_CENTER_EVENT_MQ_SECURITY_TOKEN"}, ""),
ConsumerGroup: getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_CONSUMER_GROUP", "TASK_CENTER_EVENT_MQ_CONSUMER_GROUP"}, "task-center-progress"),
Topic: getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_TOPIC", "TASK_CENTER_EVENT_MQ_TOPIC"}, "RC_DEFAULT_APP_ORDINARY"),
Tag: getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_TAG", "TASK_CENTER_EVENT_MQ_TAG"}, "task_center_event"),
},
Archive: TaskCenterArchiveConfig{
Enabled: getEnvBoolAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_ENABLED", "TASK_CENTER_ARCHIVE_ENABLED"}, false),
Bucket: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_BUCKET", "TASK_CENTER_ARCHIVE_COS_BUCKET", "LIKEI_TENCENT_COS_BUCKET"}, ""),
Region: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_REGION", "TASK_CENTER_ARCHIVE_COS_REGION", "LIKEI_TENCENT_COS_REGION"}, ""),
SecretID: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_SECRET_ID", "TASK_CENTER_ARCHIVE_COS_SECRET_ID", "LIKEI_TENCENT_COS_SECRET_ID"}, ""),
SecretKey: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_SECRET_KEY", "TASK_CENTER_ARCHIVE_COS_SECRET_KEY", "LIKEI_TENCENT_COS_SECRET_KEY"}, ""),
Prefix: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_PREFIX", "TASK_CENTER_ARCHIVE_COS_PREFIX"}, "task-center-events/v1"),
BatchSize: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_BATCH_SIZE", "TASK_CENTER_ARCHIVE_BATCH_SIZE"}, 500),
FlushSeconds: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_FLUSH_SECONDS", "TASK_CENTER_ARCHIVE_FLUSH_SECONDS"}, 60),
Workers: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_WORKERS", "TASK_CENTER_ARCHIVE_WORKERS"}, 2),
},
},
WeekStar: WeekStarConfig{
DefaultSysOrigin: strings.ToUpper(getEnvAny([]string{"CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"}, "LIKEI")),
Timezone: getEnvAny([]string{"CHATAPP_WEEK_STAR_TIMEZONE", "WEEK_STAR_TIMEZONE"}, "Asia/Riyadh"),
RetryStreamKey: getEnvAny([]string{"CHATAPP_WEEK_STAR_RETRY_STREAM_KEY", "WEEK_STAR_RETRY_STREAM_KEY"}, "week-star:reward-retry"),
RocketMQ: WeekStarRocketMQConfig{
Enabled: getEnvBoolAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_ENABLED", "WEEK_STAR_ROCKETMQ_ENABLED"}, false),
Endpoint: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_ENDPOINT", "WEEK_STAR_ROCKETMQ_ENDPOINT"}, ""),
Enabled: getEnvBoolAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_ENABLED", "WEEK_STAR_ROCKETMQ_ENABLED"}, weekStarRocketMQEnabledDefault),
Endpoint: weekStarRocketMQEndpoint,
Namespace: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_NAMESPACE", "WEEK_STAR_ROCKETMQ_NAMESPACE"}, ""),
AccessKey: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_KEY", "WEEK_STAR_ROCKETMQ_ACCESS_KEY"}, ""),
AccessSecret: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_SECRET", "WEEK_STAR_ROCKETMQ_ACCESS_SECRET"}, ""),
AccessKey: weekStarRocketMQAccessKey,
AccessSecret: weekStarRocketMQAccessSecret,
SecurityToken: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_SECURITY_TOKEN", "WEEK_STAR_ROCKETMQ_SECURITY_TOKEN"}, ""),
ConsumerGroup: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_CONSUMER_GROUP", "WEEK_STAR_ROCKETMQ_CONSUMER_GROUP"}, "week-star-activity"),
Topic: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_TOPIC", "WEEK_STAR_ROCKETMQ_TOPIC"}, "GIVE_GIFTS"),
Topic: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_TOPIC", "WEEK_STAR_ROCKETMQ_TOPIC"}, "RC_DEFAULT_APP_ORDINARY"),
Tag: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_TAG", "WEEK_STAR_ROCKETMQ_TAG"}, "give_gift_v3"),
},
},
RoomTurnoverReward: RoomTurnoverRewardConfig{
DefaultSysOrigin: strings.ToUpper(getEnvAny(
[]string{"CHATAPP_ROOM_TURNOVER_REWARD_DEFAULT_SYS_ORIGIN", "ROOM_TURNOVER_REWARD_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"},
"LIKEI",
)),
Timezone: getEnvAny(
[]string{"CHATAPP_ROOM_TURNOVER_REWARD_TIMEZONE", "ROOM_TURNOVER_REWARD_TIMEZONE", "CHATAPP_WEEK_STAR_TIMEZONE", "WEEK_STAR_TIMEZONE"},
"Asia/Riyadh",
),
CycleDuration: time.Duration(getEnvIntAny(
[]string{"CHATAPP_ROOM_TURNOVER_REWARD_CYCLE_SECONDS", "ROOM_TURNOVER_REWARD_CYCLE_SECONDS"},
10,
)) * time.Second,
},
VoiceRoomRedPacket: VoiceRoomRedPacketConfig{
DefaultSysOrigin: strings.ToUpper(getEnvAny(
[]string{"CHATAPP_VOICE_ROOM_RED_PACKET_DEFAULT_SYS_ORIGIN", "VOICE_ROOM_RED_PACKET_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"},
"LIKEI",
)),
Timezone: getEnvAny(
[]string{"CHATAPP_VOICE_ROOM_RED_PACKET_TIMEZONE", "VOICE_ROOM_RED_PACKET_TIMEZONE", "CHATAPP_WEEK_STAR_TIMEZONE", "WEEK_STAR_TIMEZONE"},
"Asia/Riyadh",
),
PresenceTTLSeconds: getEnvIntAny(
[]string{"CHATAPP_VOICE_ROOM_RED_PACKET_PRESENCE_TTL_SECONDS", "VOICE_ROOM_RED_PACKET_PRESENCE_TTL_SECONDS"},
60,
),
RefundScanIntervalSeconds: getEnvIntAny(
[]string{"CHATAPP_VOICE_ROOM_RED_PACKET_REFUND_SCAN_INTERVAL_SECONDS", "VOICE_ROOM_RED_PACKET_REFUND_SCAN_INTERVAL_SECONDS"},
10,
),
RefundBatchSize: getEnvIntAny(
[]string{"CHATAPP_VOICE_ROOM_RED_PACKET_REFUND_BATCH_SIZE", "VOICE_ROOM_RED_PACKET_REFUND_BATCH_SIZE"},
100,
),
NotifyStreamKey: getEnvAny(
[]string{"CHATAPP_VOICE_ROOM_RED_PACKET_NOTIFY_STREAM_KEY", "VOICE_ROOM_RED_PACKET_NOTIFY_STREAM_KEY"},
"voice_room:red_packet:notify",
),
ExpireZSetKey: getEnvAny(
[]string{"CHATAPP_VOICE_ROOM_RED_PACKET_EXPIRE_ZSET_KEY", "VOICE_ROOM_RED_PACKET_EXPIRE_ZSET_KEY"},
"voice_room:red_packet:expire_zset",
),
},
VoiceRoomRocket: VoiceRoomRocketConfig{
DefaultSysOrigin: strings.ToUpper(getEnvAny(
[]string{"CHATAPP_VOICE_ROOM_ROCKET_DEFAULT_SYS_ORIGIN", "VOICE_ROOM_ROCKET_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"},
"LIKEI",
)),
Timezone: getEnvAny(
[]string{"CHATAPP_VOICE_ROOM_ROCKET_TIMEZONE", "VOICE_ROOM_ROCKET_TIMEZONE", "CHATAPP_WEEK_STAR_TIMEZONE", "WEEK_STAR_TIMEZONE"},
"Asia/Riyadh",
),
RewardRetryStreamKey: getEnvAny(
[]string{"CHATAPP_VOICE_ROOM_ROCKET_REWARD_RETRY_STREAM_KEY", "VOICE_ROOM_ROCKET_REWARD_RETRY_STREAM_KEY"},
"voice_room:rocket:reward_retry",
),
InRoomRewardDueZSetKey: getEnvAny(
[]string{"CHATAPP_VOICE_ROOM_ROCKET_IN_ROOM_DUE_ZSET_KEY", "VOICE_ROOM_ROCKET_IN_ROOM_DUE_ZSET_KEY"},
"voice_room:rocket:in_room_due",
),
RoomLockTTLSeconds: getEnvIntAny(
[]string{"CHATAPP_VOICE_ROOM_ROCKET_ROOM_LOCK_TTL_SECONDS", "VOICE_ROOM_ROCKET_ROOM_LOCK_TTL_SECONDS"},
10,
),
BroadcastStreamKey: getEnvAny(
[]string{"CHATAPP_VOICE_ROOM_ROCKET_BROADCAST_STREAM_KEY", "VOICE_ROOM_ROCKET_BROADCAST_STREAM_KEY"},
"voice_room:rocket:broadcast",
),
BroadcastStreamMaxLen: int64(getEnvIntAny(
[]string{"CHATAPP_VOICE_ROOM_ROCKET_BROADCAST_STREAM_MAX_LEN", "VOICE_ROOM_ROCKET_BROADCAST_STREAM_MAX_LEN"},
10000,
)),
InRoomRewardScanIntervalSeconds: getEnvIntAny(
[]string{"CHATAPP_VOICE_ROOM_ROCKET_IN_ROOM_SCAN_INTERVAL_SECONDS", "VOICE_ROOM_ROCKET_IN_ROOM_SCAN_INTERVAL_SECONDS"},
1,
),
InRoomRewardBatchSize: getEnvIntAny(
[]string{"CHATAPP_VOICE_ROOM_ROCKET_IN_ROOM_BATCH_SIZE", "VOICE_ROOM_ROCKET_IN_ROOM_BATCH_SIZE"},
100,
),
InRoomRewardUserLimit: getEnvIntAny(
[]string{"CHATAPP_VOICE_ROOM_ROCKET_IN_ROOM_USER_LIMIT", "VOICE_ROOM_ROCKET_IN_ROOM_USER_LIMIT"},
3,
),
RewardGrantScanIntervalSeconds: getEnvIntAny(
[]string{"CHATAPP_VOICE_ROOM_ROCKET_REWARD_GRANT_SCAN_INTERVAL_SECONDS", "VOICE_ROOM_ROCKET_REWARD_GRANT_SCAN_INTERVAL_SECONDS"},
1,
),
RewardGrantBatchSize: getEnvIntAny(
[]string{"CHATAPP_VOICE_ROOM_ROCKET_REWARD_GRANT_BATCH_SIZE", "VOICE_ROOM_ROCKET_REWARD_GRANT_BATCH_SIZE"},
100,
),
MQ: VoiceRoomRocketMQConfig{
Enabled: getEnvBoolAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_ENABLED", "VOICE_ROOM_ROCKET_MQ_ENABLED"}, false),
Endpoint: voiceRoomRocketMQEndpoint,
Namespace: getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_NAMESPACE", "VOICE_ROOM_ROCKET_MQ_NAMESPACE"}, ""),
AccessKey: voiceRoomRocketMQAccessKey,
AccessSecret: voiceRoomRocketMQAccessSecret,
SecurityToken: getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_SECURITY_TOKEN", "VOICE_ROOM_ROCKET_MQ_SECURITY_TOKEN"}, ""),
ConsumerGroup: getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_CONSUMER_GROUP", "VOICE_ROOM_ROCKET_MQ_CONSUMER_GROUP"}, "voice-room-rocket"),
Topic: getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_TOPIC", "VOICE_ROOM_ROCKET_MQ_TOPIC"}, "RC_DEFAULT_APP_ORDINARY"),
Tag: getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_TAG", "VOICE_ROOM_ROCKET_MQ_TAG"}, "give_gift_v3"),
},
},
CPRelationBroadcast: CPRelationBroadcastConfig{
DefaultSysOrigin: strings.ToUpper(getEnvAny(
[]string{"CHATAPP_CP_RELATION_BROADCAST_DEFAULT_SYS_ORIGIN", "CP_RELATION_BROADCAST_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"},
"LIKEI",
)),
MQ: CPRelationBroadcastMQConfig{
Enabled: cpRelationBroadcastMQEnabled,
Endpoint: cpRelationBroadcastMQEndpoint,
Namespace: "",
AccessKey: cpRelationBroadcastMQAccessKey,
AccessSecret: cpRelationBroadcastMQAccessSecret,
SecurityToken: "",
ConsumerGroup: cpRelationBroadcastMQConsumerGroup,
Topic: cpRelationBroadcastMQTopic,
Tag: cpRelationBroadcastMQTag,
},
},
TencentIM: TencentIMConfig{
AppID: getEnvInt64Any(
[]string{"CHATAPP_TENCENT_IM_APP_ID", "TENCENT_IM_APP_ID", "LIKEI_IM_APP_ID"},
0,
),
Key: getEnvAny(
[]string{"CHATAPP_TENCENT_IM_KEY", "TENCENT_IM_KEY", "LIKEI_IM_KEY"},
"",
),
Identifier: getEnvAny(
[]string{"CHATAPP_TENCENT_IM_IDENTIFIER", "TENCENT_IM_IDENTIFIER", "LIKEI_IM_IDENTIFIER"},
"administrator",
),
BaseEndpoint: getEnvAny(
[]string{"CHATAPP_TENCENT_IM_BASE_ENDPOINT", "TENCENT_IM_BASE_ENDPOINT", "LIKEI_IM_BASE_ENDPOINT"},
"https://console.tim.qq.com",
),
UserSigExpireSeconds: getEnvInt64Any(
[]string{"CHATAPP_TENCENT_IM_USERSIG_EXPIRE_SECONDS", "TENCENT_IM_USERSIG_EXPIRE_SECONDS"},
7200000,
),
RequestTimeoutSeconds: getEnvIntAny(
[]string{"CHATAPP_TENCENT_IM_REQUEST_TIMEOUT_SECONDS", "TENCENT_IM_REQUEST_TIMEOUT_SECONDS"},
8,
),
},
Baishun: BaishunConfig{
PlatformBaseURL: getEnvAny([]string{"CHATAPP_BAISHUN_PLATFORM_BASE_URL", "BAISHUN_PLATFORM_BASE_URL"}, ""),
GameListURL: getEnvAny([]string{"CHATAPP_BAISHUN_GAME_LIST_URL", "BAISHUN_GAME_LIST_URL"}, ""),
LuckyGiftURL: getEnvAny([]string{"CHATAPP_BAISHUN_LUCKY_GIFT_URL", "BAISHUN_LUCKY_GIFT_URL"}, ""),
AppID: getEnvInt64Any([]string{"CHATAPP_BAISHUN_APP_ID", "BAISHUN_APP_ID"}, 0),
AppName: getEnvAny([]string{"CHATAPP_BAISHUN_APP_NAME", "BAISHUN_APP_NAME"}, ""),
AppChannel: getEnvAny([]string{"CHATAPP_BAISHUN_APP_CHANNEL", "BAISHUN_APP_CHANNEL"}, ""),
@ -193,6 +620,14 @@ func Load() Config {
LaunchCodeTTLSeconds: getEnvIntAny([]string{"CHATAPP_BAISHUN_LAUNCH_CODE_TTL_SECONDS", "BAISHUN_LAUNCH_CODE_TTL_SECONDS"}, 300),
SSTokenTTLSeconds: getEnvIntAny([]string{"CHATAPP_BAISHUN_SS_TOKEN_TTL_SECONDS", "BAISHUN_SS_TOKEN_TTL_SECONDS"}, 86400),
},
Lingxian: LingxianConfig{
GameListURL: getEnvAny([]string{"CHATAPP_LINGXIAN_GAME_LIST_URL", "LINGXIAN_GAME_LIST_URL"}, "https://sg-test.leadercc.com/yumichat_games/test_game_list.json"),
AppKey: getEnvAny([]string{"CHATAPP_LINGXIAN_APP_KEY", "LINGXIAN_APP_KEY", "GAME_HKYS_SIGN_KEY", "LIKEI_GAME_HKYS_SIGN_KEY"}, ""),
},
Hotgame: HotgameConfig{
AppKey: getEnvAny([]string{"CHATAPP_HOTGAME_APP_KEY", "HOTGAME_APP_KEY", "CHATAPP_REYOU_APP_KEY", "REYOU_APP_KEY"}, ""),
CallbackBaseURL: getEnvAny([]string{"CHATAPP_HOTGAME_CALLBACK_BASE_URL", "HOTGAME_CALLBACK_BASE_URL", "CHATAPP_REYOU_CALLBACK_BASE_URL", "REYOU_CALLBACK_BASE_URL"}, ""),
},
GameOpen: GameOpenConfig{
PublicBaseURL: getEnvAny([]string{"CHATAPP_GAME_OPEN_PUBLIC_BASE_URL", "GAME_OPEN_PUBLIC_BASE_URL"}, ""),
AppKey: getEnvAny([]string{"CHATAPP_GAME_OPEN_APP_KEY", "GAME_OPEN_APP_KEY"}, "game-open-test-key"),
@ -201,10 +636,14 @@ func Load() Config {
TestPassword: getEnvAny([]string{"CHATAPP_GAME_OPEN_TEST_PASSWORD", "GAME_OPEN_TEST_PASSWORD"}, "1234567"),
},
LuckyGift: LuckyGiftConfig{
Enabled: getEnvBoolAny([]string{"CHATAPP_LUCKY_GIFT_ENABLED", "LUCKY_GIFT_ENABLED", "LUCKY_GIFT_GO_ENABLED"}, false),
Timeout: time.Duration(getEnvIntAny([]string{"CHATAPP_LUCKY_GIFT_TIMEOUT_MILLIS", "LUCKY_GIFT_TIMEOUT_MILLIS", "LUCKY_GIFT_GO_TIMEOUT_MILLIS"}, 8000)) * time.Millisecond,
RewardStreamKey: getEnvAny([]string{"CHATAPP_LUCKY_GIFT_REWARD_STREAM_KEY", "LUCKY_GIFT_REWARD_STREAM_KEY"}, "lucky-gift:reward"),
Providers: loadLuckyGiftConfigs(),
Enabled: getEnvBoolAny([]string{"CHATAPP_LUCKY_GIFT_ENABLED", "LUCKY_GIFT_ENABLED", "LUCKY_GIFT_GO_ENABLED"}, false),
Timeout: time.Duration(getEnvIntAny([]string{"CHATAPP_LUCKY_GIFT_TIMEOUT_MILLIS", "LUCKY_GIFT_TIMEOUT_MILLIS", "LUCKY_GIFT_GO_TIMEOUT_MILLIS"}, 8000)) * time.Millisecond,
RewardStreamKey: getEnvAny([]string{"CHATAPP_LUCKY_GIFT_REWARD_STREAM_KEY", "LUCKY_GIFT_REWARD_STREAM_KEY"}, "lucky-gift:reward"),
RewardStreamMaxLen: int64(getEnvIntAny([]string{"CHATAPP_LUCKY_GIFT_REWARD_STREAM_MAX_LEN", "LUCKY_GIFT_REWARD_STREAM_MAX_LEN"}, 100000)),
Providers: loadLuckyGiftConfigs(),
},
BinanceRecharge: BinanceRechargeConfig{
BaseURL: getEnvAny([]string{"CHATAPP_BINANCE_RECHARGE_BASE_URL", "BINANCE_RECHARGE_BASE_URL"}, "https://api.binance.com"),
},
}
}
@ -307,13 +746,13 @@ func normalizeLuckyGiftConfigs(
item.AppID = strings.TrimSpace(item.AppID)
item.AppChannel = strings.TrimSpace(item.AppChannel)
item.AppKey = strings.TrimSpace(item.AppKey)
if sharedBaishunAppID != "" {
if item.AppID == "" && sharedBaishunAppID != "" {
item.AppID = sharedBaishunAppID
}
if sharedBaishunAppChannel != "" {
if item.AppChannel == "" && sharedBaishunAppChannel != "" {
item.AppChannel = sharedBaishunAppChannel
}
if sharedBaishunAppKey != "" {
if item.AppKey == "" && sharedBaishunAppKey != "" {
item.AppKey = sharedBaishunAppKey
}
if item.URL == "" || item.AppID == "" || item.AppChannel == "" || item.AppKey == "" {

View File

@ -5,7 +5,7 @@ import (
"testing"
)
func TestNormalizeLuckyGiftConfigsUsesSharedBaishunAppKey(t *testing.T) {
func TestNormalizeLuckyGiftConfigsKeepsDedicatedAccountWhenSharedPresent(t *testing.T) {
configs := normalizeLuckyGiftConfigs([]LuckyGiftProviderConfig{
{
StandardID: 18816,
@ -18,30 +18,6 @@ func TestNormalizeLuckyGiftConfigsUsesSharedBaishunAppKey(t *testing.T) {
if len(configs) != 1 {
t.Fatalf("expected 1 config, got %d", len(configs))
}
if got := configs[0].AppID; got != "shared-baishun-app-id" {
t.Fatalf("expected baishun app id override, got %q", got)
}
if got := configs[0].AppChannel; got != "shared-baishun-app-channel" {
t.Fatalf("expected baishun app channel override, got %q", got)
}
if got := configs[0].AppKey; got != "shared-baishun-key" {
t.Fatalf("expected baishun key override, got %q", got)
}
}
func TestNormalizeLuckyGiftConfigsKeepsDedicatedKeyWhenSharedBlank(t *testing.T) {
configs := normalizeLuckyGiftConfigs([]LuckyGiftProviderConfig{
{
StandardID: 18816,
URL: "https://provider.example.com",
AppID: "lucky-app-id",
AppChannel: "lucky-app-channel",
AppKey: "legacy-lucky-key",
},
}, "", "", "")
if len(configs) != 1 {
t.Fatalf("expected 1 config, got %d", len(configs))
}
if got := configs[0].AppID; got != "lucky-app-id" {
t.Fatalf("expected dedicated lucky gift app id, got %q", got)
}
@ -53,6 +29,27 @@ func TestNormalizeLuckyGiftConfigsKeepsDedicatedKeyWhenSharedBlank(t *testing.T)
}
}
func TestNormalizeLuckyGiftConfigsFallsBackToSharedAccountWhenDedicatedBlank(t *testing.T) {
configs := normalizeLuckyGiftConfigs([]LuckyGiftProviderConfig{
{
StandardID: 18816,
URL: "https://provider.example.com",
},
}, "shared-baishun-app-id", "shared-baishun-app-channel", "shared-baishun-key")
if len(configs) != 1 {
t.Fatalf("expected 1 config, got %d", len(configs))
}
if got := configs[0].AppID; got != "shared-baishun-app-id" {
t.Fatalf("expected shared baishun app id fallback, got %q", got)
}
if got := configs[0].AppChannel; got != "shared-baishun-app-channel" {
t.Fatalf("expected shared baishun app channel fallback, got %q", got)
}
if got := configs[0].AppKey; got != "shared-baishun-key" {
t.Fatalf("expected shared baishun key fallback, got %q", got)
}
}
func TestGetEnvBoolAliasSupportsLegacyLuckyGiftKey(t *testing.T) {
t.Setenv("LUCKY_GIFT_GO_ENABLED", "true")
if got := getEnvBoolAny([]string{"LUCKY_GIFT_ENABLED", "LUCKY_GIFT_GO_ENABLED"}, false); !got {
@ -60,6 +57,125 @@ 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 TestLoadTaskCenterMQUsesSharedRocketMQCredentials(t *testing.T) {
t.Setenv("CHATAPP_TASK_CENTER_MQ_ENABLED", "true")
t.Setenv("LIKEI_ROCKETMQ_ENDPOINT", "rmq.example.com:8081")
t.Setenv("LIKEI_ROCKETMQ_ACCESS_KEY", "access-key")
t.Setenv("LIKEI_ROCKETMQ_SECRET_KEY", "access-secret")
cfg := Load()
if !cfg.TaskCenter.MQ.Enabled {
t.Fatalf("expected task center mq enabled")
}
if got := cfg.TaskCenter.MQ.Endpoint; got != "rmq.example.com:8081" {
t.Fatalf("task center mq endpoint = %q", got)
}
if got := cfg.TaskCenter.MQ.Topic; got != "RC_DEFAULT_APP_ORDINARY" {
t.Fatalf("task center mq topic = %q", got)
}
if got := cfg.TaskCenter.MQ.Tag; got != "task_center_event" {
t.Fatalf("task center mq tag = %q", got)
}
}
func TestLoadCPRelationBroadcastMQUsesSharedRocketMQCredentials(t *testing.T) {
t.Setenv("LIKEI_ROCKETMQ_ENDPOINT", "rmq.example.com:8081")
t.Setenv("LIKEI_ROCKETMQ_ACCESS_KEY", "access-key")
t.Setenv("LIKEI_ROCKETMQ_SECRET_KEY", "access-secret")
t.Setenv("CHATAPP_CP_RELATION_BROADCAST_MQ_ENDPOINT", "should-not-use:8081")
t.Setenv("CHATAPP_CP_RELATION_BROADCAST_MQ_TOPIC", "should-not-use")
t.Setenv("CHATAPP_CP_RELATION_BROADCAST_MQ_TAG", "should-not-use")
cfg := Load()
if !cfg.CPRelationBroadcast.MQ.Enabled {
t.Fatalf("expected cp relation broadcast mq enabled")
}
if got := cfg.CPRelationBroadcast.MQ.Endpoint; got != "rmq.example.com:8081" {
t.Fatalf("cp relation broadcast mq endpoint = %q", got)
}
if got := cfg.CPRelationBroadcast.MQ.Topic; got != "RC_DEFAULT_APP_ORDINARY" {
t.Fatalf("cp relation broadcast mq topic = %q", got)
}
if got := cfg.CPRelationBroadcast.MQ.Tag; got != "cp_relation_broadcast" {
t.Fatalf("cp relation broadcast mq tag = %q", got)
}
}
func TestLoadTaskCenterArchiveUsesSharedCOSCredentials(t *testing.T) {
t.Setenv("CHATAPP_TASK_CENTER_ARCHIVE_ENABLED", "true")
t.Setenv("LIKEI_TENCENT_COS_BUCKET", "bucket-123")
t.Setenv("LIKEI_TENCENT_COS_REGION", "ap-test")
t.Setenv("LIKEI_TENCENT_COS_SECRET_ID", "secret-id")
t.Setenv("LIKEI_TENCENT_COS_SECRET_KEY", "secret-key")
cfg := Load()
if !cfg.TaskCenter.Archive.Enabled {
t.Fatalf("expected task center archive enabled")
}
if got := cfg.TaskCenter.Archive.Bucket; got != "bucket-123" {
t.Fatalf("task center archive bucket = %q", got)
}
if got := cfg.TaskCenter.Archive.Prefix; got != "task-center-events/v1" {
t.Fatalf("task center archive prefix = %q", got)
}
}
func TestLoadLingxianConfigSupportsLikeiHkysSignKey(t *testing.T) {
t.Setenv("LIKEI_GAME_HKYS_SIGN_KEY", "likei-hkys-key")
cfg := Load()
if got := cfg.Lingxian.AppKey; got != "likei-hkys-key" {
t.Fatalf("expected legacy likei hkys sign key, got %q", got)
}
}
func unsetEnvForTest(t *testing.T, keys ...string) {
t.Helper()
values := make(map[string]string, len(keys))
present := make(map[string]bool, len(keys))
for _, key := range keys {
value, ok := os.LookupEnv(key)
values[key] = value
present[key] = ok
if err := os.Unsetenv(key); err != nil {
t.Fatalf("unset %s: %v", key, err)
}
}
t.Cleanup(func() {
for _, key := range keys {
if present[key] {
_ = os.Setenv(key, values[key])
} else {
_ = os.Unsetenv(key)
}
}
})
}
func TestLoadLuckyGiftConfigsSupportsLegacyDefaultEnv(t *testing.T) {
oldJSON, hadJSON := os.LookupEnv("LUCKY_GIFT_CONFIGS_JSON")
oldList, hadList := os.LookupEnv("LUCKY_GIFT_CONFIGS")

View File

@ -12,10 +12,14 @@ type Gateways struct {
Device *DeviceGateway
InviteCode *InviteCodeGateway
Notice *NoticeGateway
Order *OrderGateway
RewardDispatch *RewardDispatchGateway
RewardQuery *RewardQueryGateway
Profile *ProfileGateway
Room *RoomGateway
Team *TeamGateway
Wallet *WalletGateway
Freight *FreightGateway
}
// NewGateways 基于共享 HTTP 客户端创建所有 Java 网关。
@ -26,10 +30,14 @@ func NewGateways(cfg config.Config) Gateways {
Device: &DeviceGateway{client: client},
InviteCode: &InviteCodeGateway{client: client},
Notice: &NoticeGateway{client: client},
Order: &OrderGateway{client: client},
RewardDispatch: &RewardDispatchGateway{client: client},
RewardQuery: &RewardQueryGateway{client: client},
Profile: &ProfileGateway{client: client},
Room: &RoomGateway{client: client},
Team: &TeamGateway{client: client},
Wallet: &WalletGateway{client: client},
Freight: &FreightGateway{client: client},
}
}
@ -63,6 +71,16 @@ func (g *Gateways) SendOfficialNoticeCustomize(ctx context.Context, req Official
return g.Notice.SendOfficialNoticeCustomize(ctx, req)
}
// MapUserTotalRecharge 透传到订单充值统计网关。
func (g *Gateways) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]UserTotalRecharge, error) {
return g.Order.MapUserTotalRecharge(ctx, userIDs)
}
// GetThisMonthTotalPersonalRecharge 透传到订单充值统计网关。
func (g *Gateways) GetThisMonthTotalPersonalRecharge(ctx context.Context, userID int64) (DecimalString, error) {
return g.Order.GetThisMonthTotalPersonalRecharge(ctx, userID)
}
// GrantGold 透传到奖励发放网关。
func (g *Gateways) GrantGold(ctx context.Context, req GrantGoldRequest) error {
return g.RewardDispatch.GrantGold(ctx, req)
@ -73,6 +91,36 @@ func (g *Gateways) GrantProps(ctx context.Context, req GrantPropsRequest) error
return g.RewardDispatch.GrantProps(ctx, req)
}
// GivePropsBackpack 透传到道具背包发放接口。
func (g *Gateways) GivePropsBackpack(ctx context.Context, req GivePropsBackpackRequest) error {
return g.RewardDispatch.GivePropsBackpack(ctx, req)
}
// IncrGiftBackpack 透传到礼物背包发放接口。
func (g *Gateways) IncrGiftBackpack(ctx context.Context, userID int64, giftID int64, quantity int) error {
return g.RewardDispatch.IncrGiftBackpack(ctx, userID, giftID, quantity)
}
// GetNobleVIPAbility 透传到贵族 VIP 能力配置接口。
func (g *Gateways) GetNobleVIPAbility(ctx context.Context, sourceID int64) (PropsNobleVIPAbility, error) {
return g.RewardQuery.GetNobleVIPAbility(ctx, sourceID)
}
// GetUserMaxNobleVIPAbility 透传到用户当前最高贵族 VIP 能力配置接口。
func (g *Gateways) GetUserMaxNobleVIPAbility(ctx context.Context, userID int64) (PropsNobleVIPAbility, error) {
return g.RewardQuery.GetUserMaxNobleVIPAbility(ctx, userID)
}
// SwitchUseProps 透传到道具使用切换接口。
func (g *Gateways) SwitchUseProps(ctx context.Context, userID int64, propsID int64) error {
return g.RewardDispatch.SwitchUseProps(ctx, userID, propsID)
}
// ActivateTemporaryBadge 透传到临时徽章激活接口。
func (g *Gateways) ActivateTemporaryBadge(ctx context.Context, userID int64, badgeID int64, days int) error {
return g.RewardDispatch.ActivateTemporaryBadge(ctx, userID, badgeID, days)
}
// SendActivityReward 透传到奖励发放网关。
func (g *Gateways) SendActivityReward(ctx context.Context, req SendActivityRewardRequest) error {
return g.RewardDispatch.SendActivityReward(ctx, req)
@ -83,16 +131,91 @@ func (g *Gateways) GetRewardGroupDetail(ctx context.Context, groupID int64) (Rew
return g.RewardQuery.GetRewardGroupDetail(ctx, groupID)
}
// FindRewardGroupDetail 透传到奖励查询网关,按名称查找奖励组详情。
func (g *Gateways) FindRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (RewardGroupDetail, error) {
return g.RewardQuery.FindRewardGroupDetail(ctx, sysOrigin, name)
}
// GetUserProfile 透传到资料网关。
func (g *Gateways) GetUserProfile(ctx context.Context, userID int64) (UserProfile, error) {
return g.Profile.GetUserProfile(ctx, userID)
}
// GetUserRegion 透传到用户地区网关。
func (g *Gateways) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) {
return g.Profile.GetUserRegion(ctx, userID, authorization)
}
// GetUserLevel 透传到用户等级网关。
func (g *Gateways) GetUserLevel(ctx context.Context, sysOrigin string, userID int64) (UserLevel, error) {
return g.Profile.GetUserLevel(ctx, sysOrigin, userID)
}
// ResolveRegionCodeByCountryCode 透传到 Java 区域配置网关。
func (g *Gateways) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
return g.Profile.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode)
}
// GetUserProfileByAccount 透传到资料网关。
func (g *Gateways) GetUserProfileByAccount(ctx context.Context, sysOrigin, account string) (UserProfile, error) {
return g.Profile.GetUserProfileByAccount(ctx, sysOrigin, account)
}
// GetUserProfileByAccountOne 透传到资料网关。
func (g *Gateways) GetUserProfileByAccountOne(ctx context.Context, account string) (UserProfile, error) {
return g.Profile.GetUserProfileByAccountOne(ctx, account)
}
// GetUserLanguage 透传到资料网关。
func (g *Gateways) GetUserLanguage(ctx context.Context, userID int64) (string, error) {
return g.Profile.GetUserLanguage(ctx, userID)
}
// RemoveUserProfileCacheAll 透传到用户资料缓存清理接口。
func (g *Gateways) RemoveUserProfileCacheAll(ctx context.Context, userID int64) error {
return g.Profile.RemoveUserProfileCacheAll(ctx, userID)
}
// PunishAccount 透传到账号处罚接口。
func (g *Gateways) PunishAccount(ctx context.Context, req PunishAccountRequest) error {
return g.Profile.PunishAccount(ctx, req)
}
// UnblockAccount 透传到账号解封接口。
func (g *Gateways) UnblockAccount(ctx context.Context, req UnblockAccountRequest) error {
return g.Profile.UnblockAccount(ctx, req)
}
// ListCurrentWeekRoomContribution 透传到房间流水网关。
func (g *Gateways) ListCurrentWeekRoomContribution(ctx context.Context, limit int) ([]RoomContributionActivityCount, error) {
return g.Room.ListCurrentWeekRoomContribution(ctx, limit)
}
// GetCurrentWeekRoomContribution 透传到房间流水网关。
func (g *Gateways) GetCurrentWeekRoomContribution(ctx context.Context, roomID int64) (RoomContributionActivityCount, error) {
return g.Room.GetCurrentWeekRoomContribution(ctx, roomID)
}
// GetRoomProfileByUserID 透传到房间资料网关。
func (g *Gateways) GetRoomProfileByUserID(ctx context.Context, userID int64) (RoomProfile, error) {
return g.Room.GetRoomProfileByUserID(ctx, userID)
}
// MapRoomProfiles 透传到房间资料网关。
func (g *Gateways) MapRoomProfiles(ctx context.Context, roomIDs []int64) (map[int64]RoomProfile, error) {
return g.Room.MapRoomProfiles(ctx, roomIDs)
}
// MarkRoomTurnoverRewardCoinsSent 透传到房间流水奖励标记接口。
func (g *Gateways) MarkRoomTurnoverRewardCoinsSent(ctx context.Context, id string) (bool, error) {
return g.Room.MarkRoomTurnoverRewardCoinsSent(ctx, id)
}
// GetTeamPolicyRelease 透传到团队政策网关。
func (g *Gateways) GetTeamPolicyRelease(ctx context.Context, query TeamPolicyReleaseQuery) (TeamPolicyRelease, error) {
return g.Team.GetTeamPolicyRelease(ctx, query)
}
// MapGoldBalance 透传到钱包网关。
func (g *Gateways) MapGoldBalance(ctx context.Context, userIDs []int64) (map[int64]int64, error) {
return g.Wallet.MapGoldBalance(ctx, userIDs)
@ -108,6 +231,31 @@ func (g *Gateways) ChangeGoldBalance(ctx context.Context, cmd GoldReceiptCommand
return g.Wallet.ChangeGoldBalance(ctx, cmd)
}
// PageFreightSellers 透传到 H5 币商列表接口。
func (g *Gateways) PageFreightSellers(ctx context.Context, authorization string, sysOrigin string, cursor int, limit int) (FreightSellerPage, error) {
return g.Freight.PageFreightSellers(ctx, authorization, sysOrigin, cursor, limit)
}
func (g *Gateways) ExistsFreightBalance(ctx context.Context, userID int64) (bool, error) {
return g.Freight.ExistsFreightBalance(ctx, userID)
}
func (g *Gateways) ExistsFreightSeller(ctx context.Context, userID int64) (bool, error) {
return g.Freight.ExistsFreightSeller(ctx, userID)
}
func (g *Gateways) ChangeFreightBalance(ctx context.Context, req FreightBalanceChangeRequest) error {
return g.Freight.ChangeFreightBalance(ctx, req)
}
func (g *Gateways) AddFreightAgentReview(ctx context.Context, req FreightAgentReviewRequest) error {
return g.Freight.AddFreightAgentReview(ctx, req)
}
func (g *Gateways) IncreaseFreightRechargeRecord(ctx context.Context, userID int64, amount string) error {
return g.Order.IncreaseFreightRechargeRecord(ctx, userID, amount)
}
// AuthGateway 负责认证相关接口。
type AuthGateway struct {
client *Client
@ -158,6 +306,25 @@ func (g *NoticeGateway) SendOfficialNoticeCustomize(ctx context.Context, req Off
return g.client.SendOfficialNoticeCustomize(ctx, req)
}
// OrderGateway 负责订单充值统计接口。
type OrderGateway struct {
client *Client
}
// MapUserTotalRecharge 查询用户累计充值分组。
func (g *OrderGateway) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]UserTotalRecharge, error) {
return g.client.MapUserTotalRecharge(ctx, userIDs)
}
// GetThisMonthTotalPersonalRecharge 查询用户本月个人充值。
func (g *OrderGateway) GetThisMonthTotalPersonalRecharge(ctx context.Context, userID int64) (DecimalString, error) {
return g.client.GetThisMonthTotalPersonalRecharge(ctx, userID)
}
func (g *OrderGateway) IncreaseFreightRechargeRecord(ctx context.Context, userID int64, amount string) error {
return g.client.IncreaseFreightRechargeRecord(ctx, userID, amount)
}
// RewardDispatchGateway 负责活动奖励发放接口。
type RewardDispatchGateway struct {
client *Client
@ -173,6 +340,26 @@ func (g *RewardDispatchGateway) GrantProps(ctx context.Context, req GrantPropsRe
return g.client.GrantProps(ctx, req)
}
// GivePropsBackpack 发放单个道具到用户背包。
func (g *RewardDispatchGateway) GivePropsBackpack(ctx context.Context, req GivePropsBackpackRequest) error {
return g.client.GivePropsBackpack(ctx, req)
}
// IncrGiftBackpack 发放礼物到用户礼物背包。
func (g *RewardDispatchGateway) IncrGiftBackpack(ctx context.Context, userID int64, giftID int64, quantity int) error {
return g.client.IncrGiftBackpack(ctx, userID, giftID, quantity)
}
// SwitchUseProps 切换用户当前使用道具。
func (g *RewardDispatchGateway) SwitchUseProps(ctx context.Context, userID int64, propsID int64) error {
return g.client.SwitchUseProps(ctx, userID, propsID)
}
// ActivateTemporaryBadge 激活临时徽章并佩戴。
func (g *RewardDispatchGateway) ActivateTemporaryBadge(ctx context.Context, userID int64, badgeID int64, days int) error {
return g.client.ActivateTemporaryBadge(ctx, userID, badgeID, days)
}
// SendActivityReward 发放活动奖励组。
func (g *RewardDispatchGateway) SendActivityReward(ctx context.Context, req SendActivityRewardRequest) error {
return g.client.SendActivityReward(ctx, req)
@ -188,6 +375,21 @@ func (g *RewardQueryGateway) GetRewardGroupDetail(ctx context.Context, groupID i
return g.client.GetRewardGroupDetail(ctx, groupID)
}
// FindRewardGroupDetail 按名称查询奖励组详情。
func (g *RewardQueryGateway) FindRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (RewardGroupDetail, error) {
return g.client.FindRewardGroupDetail(ctx, sysOrigin, name)
}
// GetNobleVIPAbility 查询贵族 VIP 能力配置。
func (g *RewardQueryGateway) GetNobleVIPAbility(ctx context.Context, sourceID int64) (PropsNobleVIPAbility, error) {
return g.client.GetNobleVIPAbility(ctx, sourceID)
}
// GetUserMaxNobleVIPAbility 查询用户当前最高贵族 VIP 能力配置。
func (g *RewardQueryGateway) GetUserMaxNobleVIPAbility(ctx context.Context, userID int64) (PropsNobleVIPAbility, error) {
return g.client.GetUserMaxNobleVIPAbility(ctx, userID)
}
// ProfileGateway 负责用户资料相关接口。
type ProfileGateway struct {
client *Client
@ -198,11 +400,91 @@ func (g *ProfileGateway) GetUserProfile(ctx context.Context, userID int64) (User
return g.client.GetUserProfile(ctx, userID)
}
// GetUserRegion 查询用户当前地区。
func (g *ProfileGateway) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) {
return g.client.GetUserRegion(ctx, userID, authorization)
}
// GetUserLevel 查询用户财富/魅力等级。
func (g *ProfileGateway) GetUserLevel(ctx context.Context, sysOrigin string, userID int64) (UserLevel, error) {
return g.client.GetUserLevel(ctx, sysOrigin, userID)
}
// ResolveRegionCodeByCountryCode 根据 Java 区域配置把国家码解析成区域编码。
func (g *ProfileGateway) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
return g.client.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode)
}
// GetUserProfileByAccount 根据账号查询用户资料。
func (g *ProfileGateway) GetUserProfileByAccount(ctx context.Context, sysOrigin, account string) (UserProfile, error) {
return g.client.GetUserProfileByAccount(ctx, sysOrigin, account)
}
// GetUserProfileByAccountOne 根据账号查询单个用户资料。
func (g *ProfileGateway) GetUserProfileByAccountOne(ctx context.Context, account string) (UserProfile, error) {
return g.client.GetUserProfileByAccountOne(ctx, account)
}
// GetUserLanguage 查询用户语言。
func (g *ProfileGateway) GetUserLanguage(ctx context.Context, userID int64) (string, error) {
return g.client.GetUserLanguage(ctx, userID)
}
// RemoveUserProfileCacheAll 清理用户资料相关缓存。
func (g *ProfileGateway) RemoveUserProfileCacheAll(ctx context.Context, userID int64) error {
return g.client.RemoveUserProfileCacheAll(ctx, userID)
}
// PunishAccount 处罚用户账号。
func (g *ProfileGateway) PunishAccount(ctx context.Context, req PunishAccountRequest) error {
return g.client.PunishAccount(ctx, req)
}
// UnblockAccount 解封用户账号。
func (g *ProfileGateway) UnblockAccount(ctx context.Context, req UnblockAccountRequest) error {
return g.client.UnblockAccount(ctx, req)
}
// RoomGateway 负责房间资料和房间流水相关接口。
type RoomGateway struct {
client *Client
}
// ListCurrentWeekRoomContribution 查询当前周房间流水排行榜。
func (g *RoomGateway) ListCurrentWeekRoomContribution(ctx context.Context, limit int) ([]RoomContributionActivityCount, error) {
return g.client.ListCurrentWeekRoomContribution(ctx, limit)
}
// GetCurrentWeekRoomContribution 查询指定房间当前周流水。
func (g *RoomGateway) GetCurrentWeekRoomContribution(ctx context.Context, roomID int64) (RoomContributionActivityCount, error) {
return g.client.GetCurrentWeekRoomContribution(ctx, roomID)
}
// GetRoomProfileByUserID 查询用户当前房间资料。
func (g *RoomGateway) GetRoomProfileByUserID(ctx context.Context, userID int64) (RoomProfile, error) {
return g.client.GetRoomProfileByUserID(ctx, userID)
}
// MapRoomProfiles 批量查询房间资料。
func (g *RoomGateway) MapRoomProfiles(ctx context.Context, roomIDs []int64) (map[int64]RoomProfile, error) {
return g.client.MapRoomProfiles(ctx, roomIDs)
}
// MarkRoomTurnoverRewardCoinsSent 标记 Java 房间流水统计记录已经发奖。
func (g *RoomGateway) MarkRoomTurnoverRewardCoinsSent(ctx context.Context, id string) (bool, error) {
return g.client.MarkRoomTurnoverRewardCoinsSent(ctx, id)
}
// TeamGateway 负责团队政策相关接口。
type TeamGateway struct {
client *Client
}
// GetTeamPolicyRelease 查询已发布团队政策。
func (g *TeamGateway) GetTeamPolicyRelease(ctx context.Context, query TeamPolicyReleaseQuery) (TeamPolicyRelease, error) {
return g.client.GetTeamPolicyRelease(ctx, query)
}
// WalletGateway 负责钱包相关接口。
type WalletGateway struct {
client *Client
@ -222,3 +504,29 @@ func (g *WalletGateway) ExistsGoldEvent(ctx context.Context, eventID string) (bo
func (g *WalletGateway) ChangeGoldBalance(ctx context.Context, cmd GoldReceiptCommand) error {
return g.client.ChangeGoldBalance(ctx, cmd)
}
// FreightGateway 负责货运代理/币商相关接口。
type FreightGateway struct {
client *Client
}
// PageFreightSellers 查询 H5 可见币商列表。
func (g *FreightGateway) PageFreightSellers(ctx context.Context, authorization string, sysOrigin string, cursor int, limit int) (FreightSellerPage, error) {
return g.client.PageFreightSellers(ctx, authorization, sysOrigin, cursor, limit)
}
func (g *FreightGateway) ExistsFreightBalance(ctx context.Context, userID int64) (bool, error) {
return g.client.ExistsFreightBalance(ctx, userID)
}
func (g *FreightGateway) ExistsFreightSeller(ctx context.Context, userID int64) (bool, error) {
return g.client.ExistsFreightSeller(ctx, userID)
}
func (g *FreightGateway) ChangeFreightBalance(ctx context.Context, req FreightBalanceChangeRequest) error {
return g.client.ChangeFreightBalance(ctx, req)
}
func (g *FreightGateway) AddFreightAgentReview(ctx context.Context, req FreightAgentReviewRequest) error {
return g.client.AddFreightAgentReview(ctx, req)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,181 @@
package integration
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"chatapp3-golang/internal/config"
)
func TestUserTotalRechargeAcceptsNumericTimestamps(t *testing.T) {
var item UserTotalRecharge
raw := []byte(`{
"id":"2049097310624870401",
"userId":"2042274349343506434",
"type":"SHIPPING_AGENT",
"amount":1.60,
"createTime":1777377844000,
"updateTime":1777556451000
}`)
if err := json.Unmarshal(raw, &item); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if item.CreateTime != "1777377844000" {
t.Fatalf("CreateTime = %q, want numeric timestamp string", item.CreateTime)
}
if item.UpdateTime != "1777556451000" {
t.Fatalf("UpdateTime = %q, want numeric timestamp string", item.UpdateTime)
}
if item.Amount != "1.60" {
t.Fatalf("Amount = %q, want 1.60", item.Amount)
}
}
func TestResolveRegionCodeByCountryCodeUsesRegionConfig(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.Method != http.MethodPost || r.URL.Path != "/region-config/client/listByCondition" {
t.Fatalf("request = %s %s, want POST /region-config/client/listByCondition", r.Method, r.URL.Path)
}
var payload map[string]string
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
if payload["sysOrigin"] != "LIKEI" {
t.Fatalf("sysOrigin = %q, want LIKEI", payload["sysOrigin"])
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"body":[{"regionCode":"中东区","sysOrigin":"LIKEI","countryCodes":"JO, EG, KSA","del":false},{"regionCode":"US","sysOrigin":"LIKEI","countryCodes":"US","del":true}]}`))
}))
defer server.Close()
client := New(config.Config{
HTTP: config.HTTPConfig{Timeout: time.Second},
Java: config.JavaConfig{OtherBaseURL: server.URL},
})
got, err := client.ResolveRegionCodeByCountryCode(context.Background(), " likei ", " ksa ")
if err != nil {
t.Fatalf("ResolveRegionCodeByCountryCode() error = %v", err)
}
if got != "中东区" {
t.Fatalf("region = %q, want 中东区", got)
}
got, err = client.ResolveRegionCodeByCountryCode(context.Background(), "LIKEI", "eg")
if err != nil {
t.Fatalf("ResolveRegionCodeByCountryCode() cached error = %v", err)
}
if got != "中东区" {
t.Fatalf("cached region = %q, want 中东区", got)
}
if calls != 1 {
t.Fatalf("region config calls = %d, want cached 1", calls)
}
}
func TestGetRewardGroupDetailAcceptsDataWrapper(t *testing.T) {
groupID := int64(2056981527522242600)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/props-activity/reward/group/client/getByGroupId" {
t.Fatalf("request = %s %s, want GET /props-activity/reward/group/client/getByGroupId", r.Method, r.URL.Path)
}
if r.URL.Query().Get("id") != "2056981527522242600" {
t.Fatalf("id query = %q", r.URL.Query().Get("id"))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"id":"2056981527522242600","name":"累计充值10美金","rewardConfigList":[{"id":"1","type":"PROPS","detailType":"AVATAR_FRAME","name":"Frame","quantity":"1","sourceUrl":"https://example.com/frame.svga"}]}}`))
}))
defer server.Close()
client := New(config.Config{
HTTP: config.HTTPConfig{Timeout: time.Second},
Java: config.JavaConfig{OtherBaseURL: server.URL},
})
got, err := client.GetRewardGroupDetail(context.Background(), groupID)
if err != nil {
t.Fatalf("GetRewardGroupDetail() error = %v", err)
}
if int64(got.ID) != groupID || len(got.RewardConfigList) != 1 {
t.Fatalf("detail = %+v, want group with one reward item", got)
}
}
func TestGetRewardGroupDetailFallsBackToConsoleEndpoint(t *testing.T) {
groupID := int64(2056981527522242600)
var innerCalls, consoleCalls int
mux := http.NewServeMux()
mux.HandleFunc("/props-activity/reward/group/client/getByGroupId", func(w http.ResponseWriter, r *http.Request) {
innerCalls++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"body":{"id":"2056981527522242600","name":"累计充值10美金","rewardConfigList":[]}}`))
})
mux.HandleFunc("/console/props/activity/reward/group/2056981527522242600", func(w http.ResponseWriter, r *http.Request) {
consoleCalls++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"2056981527522242600","name":"累计充值10美金","rewardConfigList":[{"id":"1","type":"GIFT","name":"Gift","quantity":"1","cover":"https://example.com/gift.png"}]}`))
})
server := httptest.NewServer(mux)
defer server.Close()
client := New(config.Config{
HTTP: config.HTTPConfig{Timeout: time.Second},
Java: config.JavaConfig{
OtherBaseURL: server.URL,
ConsoleBaseURL: server.URL + "/console",
},
})
got, err := client.GetRewardGroupDetail(context.Background(), groupID)
if err != nil {
t.Fatalf("GetRewardGroupDetail() error = %v", err)
}
if innerCalls != 1 || consoleCalls != 1 {
t.Fatalf("calls inner=%d console=%d, want 1/1", innerCalls, consoleCalls)
}
if len(got.RewardConfigList) != 1 || got.RewardConfigList[0].Name != "Gift" {
t.Fatalf("detail = %+v, want fallback reward item", got)
}
}
func TestFindRewardGroupDetailHydratesPageRecord(t *testing.T) {
groupID := int64(2056980937278816399)
var pageCalls, detailCalls int
mux := http.NewServeMux()
mux.HandleFunc("/props-activity/reward/group/client/page", func(w http.ResponseWriter, r *http.Request) {
pageCalls++
if r.Method != http.MethodPost {
t.Fatalf("method = %s, want POST", r.Method)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"body":{"records":[{"id":"2056980937278816399","name":"累充活动$100","rewardConfigList":[]}]}}`))
})
mux.HandleFunc("/props-activity/reward/group/client/getByGroupId", func(w http.ResponseWriter, r *http.Request) {
detailCalls++
if r.URL.Query().Get("id") != "2056980937278816399" {
t.Fatalf("id query = %q", r.URL.Query().Get("id"))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"body":{"id":"2056980937278816399","name":"累充活动$100","rewardConfigList":[{"id":"1","type":"BADGE","name":"Badge","quantity":"30","cover":"https://example.com/badge.png"}]}}`))
})
server := httptest.NewServer(mux)
defer server.Close()
client := New(config.Config{
HTTP: config.HTTPConfig{Timeout: time.Second},
Java: config.JavaConfig{OtherBaseURL: server.URL},
})
got, err := client.FindRewardGroupDetail(context.Background(), "LIKEI", "累充活动$100")
if err != nil {
t.Fatalf("FindRewardGroupDetail() error = %v", err)
}
if pageCalls != 1 || detailCalls != 1 {
t.Fatalf("calls page=%d detail=%d, want 1/1", pageCalls, detailCalls)
}
if int64(got.ID) != groupID || len(got.RewardConfigList) != 1 {
t.Fatalf("detail = %+v, want hydrated reward group", got)
}
}

View File

@ -0,0 +1,23 @@
package model
import "time"
// ActivityIdempotency stores client-side idempotency keys for paid activity draw
// requests. The same activity/sysOrigin/user/key may only map to one request hash.
type ActivityIdempotency struct {
ID int64 `gorm:"column:id;primaryKey"`
Activity string `gorm:"column:activity;size:64;uniqueIndex:uk_activity_idempotency_key,priority:1"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_activity_idempotency_key,priority:2"`
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_activity_idempotency_key,priority:3"`
IdempotencyKey string `gorm:"column:idempotency_key;size:128;uniqueIndex:uk_activity_idempotency_key,priority:4"`
RequestHash string `gorm:"column:request_hash;size:64"`
BusinessNo string `gorm:"column:business_no;size:64;index:idx_activity_idempotency_business"`
Status string `gorm:"column:status;size:32;index:idx_activity_idempotency_status"`
ResponseJSON string `gorm:"column:response_json;type:longtext"`
ErrorMessage string `gorm:"column:error_message;size:1024"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the activity idempotency table name.
func (ActivityIdempotency) TableName() string { return "activity_idempotency" }

View File

@ -0,0 +1,28 @@
package model
import "time"
// AgoraErrorLog stores client-reported Agora room connection errors.
type AgoraErrorLog struct {
ID int64 `gorm:"column:id;primaryKey"` // 主键ID
SysOrigin string `gorm:"column:sys_origin;size:32"` // 系统来源
UserID int64 `gorm:"column:user_id"` // 上报用户ID
RoomID string `gorm:"column:room_id;size:64"` // 房间ID
AgoraUID string `gorm:"column:agora_uid;size:64"` // Agora UID
ErrorCodeType string `gorm:"column:error_code_type;size:128"` // Agora ErrorCodeType
ConnectionChangedReasonType string `gorm:"column:connection_changed_reason_type;size:128"` // Agora ConnectionChangedReasonType
BannedByServer bool `gorm:"column:banned_by_server"` // 是否 BannedByServer/kicking-rule
IsTimeout bool `gorm:"column:is_timeout"` // 是否超时
TokenRequestSuccess *bool `gorm:"column:token_request_success"` // token 请求是否成功
NetworkType string `gorm:"column:network_type;size:64"` // 网络类型
ReqClient string `gorm:"column:req_client;size:32"` // 请求客户端
ReqAppIntel string `gorm:"column:req_app_intel;size:255"` // App 版本信息请求头
ClientIP string `gorm:"column:client_ip;size:64"` // 客户端IP
UserAgent string `gorm:"column:user_agent;size:255"` // User-Agent
RawPayload string `gorm:"column:raw_payload;type:mediumtext"` // 原始上报内容
CreateTime time.Time `gorm:"column:create_time"` // 创建时间
UpdateTime time.Time `gorm:"column:update_time"` // 更新时间
}
// TableName returns the Agora error log table name.
func (AgoraErrorLog) TableName() string { return "agora_error_log" }

View File

@ -0,0 +1,32 @@
package model
import "time"
// AppPopupConfig 保存 APP 进场弹窗配置。
type AppPopupConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_app_popup_config_key,priority:1;index:idx_app_popup_config_scene,priority:1"`
PopupKey string `gorm:"column:popup_key;size:64;uniqueIndex:uk_app_popup_config_key,priority:3"`
Name string `gorm:"column:name;size:128"`
Scene string `gorm:"column:scene;size:32;uniqueIndex:uk_app_popup_config_key,priority:2;index:idx_app_popup_config_scene,priority:2"`
Enabled bool `gorm:"column:enabled;index:idx_app_popup_config_scene,priority:3"`
LimitDays int `gorm:"column:limit_days"`
Priority int `gorm:"column:priority;index:idx_app_popup_config_scene,priority:4"`
Version int `gorm:"column:version"`
StartTime *time.Time `gorm:"column:start_time"`
EndTime *time.Time `gorm:"column:end_time"`
PlatformsJSON string `gorm:"column:platforms_json;type:json"`
MinAppVersion string `gorm:"column:min_app_version;size:32"`
MaxAppVersion string `gorm:"column:max_app_version;size:32"`
UserScopeType string `gorm:"column:user_scope_type;size:32"`
UserScopeJSON string `gorm:"column:user_scope_json;type:json"`
ContentJSON string `gorm:"column:content_json;type:json"`
JumpType string `gorm:"column:jump_type;size:32"`
JumpURL string `gorm:"column:jump_url;size:512"`
Remark string `gorm:"column:remark;size:255"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName 返回 APP 进场弹窗配置表名。
func (AppPopupConfig) TableName() string { return "app_popup_config" }

View File

@ -0,0 +1,50 @@
package model
import "time"
// UserBadgeBackpack stores user-owned badge records from the Java badge system.
type UserBadgeBackpack struct {
ID int64 `gorm:"column:id;primaryKey"`
UserID int64 `gorm:"column:user_id;index:idx_user_badge_backpack_user"`
BadgeID int64 `gorm:"column:badge_id;index:idx_user_badge_backpack_badge"`
ExpireType string `gorm:"column:expire_type;size:32"`
ExpireTime time.Time `gorm:"column:expire_time"`
UseProps bool `gorm:"column:is_use_props"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the user badge backpack table name.
func (UserBadgeBackpack) TableName() string { return "user_badge_backpack" }
// SysBadgeConfig stores badge metadata from the Java badge system.
type SysBadgeConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
Type string `gorm:"column:type;size:64"`
DisplayType string `gorm:"column:display_type;size:16"`
BadgeLevel int `gorm:"column:badge_level"`
Milestone int64 `gorm:"column:milestone"`
BadgeName string `gorm:"column:badge_name;size:128"`
BadgeKey string `gorm:"column:badge_key;size:128"`
Del bool `gorm:"column:is_del"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the badge config table name.
func (SysBadgeConfig) TableName() string { return "sys_badge_config" }
// SysBadgePictureConfig stores per-platform badge images.
type SysBadgePictureConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
BadgeConfigID int64 `gorm:"column:badge_config_id;index:idx_badge_picture_config_badge"`
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_badge_picture_config_sys"`
SelectURL string `gorm:"column:select_url;size:512"`
NotSelectURL string `gorm:"column:not_select_url;size:512"`
AnimationURL string `gorm:"column:animation_url;size:512"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the badge picture config table name.
func (SysBadgePictureConfig) TableName() string { return "sys_badge_picture_config" }

View File

@ -28,25 +28,26 @@ func (SysGameListConfig) TableName() string { return "sys_game_list_config" }
// SysGameListVendorExt 保存某个厂商在系统游戏表上的扩展信息。
type SysGameListVendorExt struct {
ID int64 `gorm:"column:id;primaryKey"` // 主键
GameListConfigID int64 `gorm:"column:game_list_config_id;uniqueIndex:uk_game_vendor_ext_cfg_vendor,priority:1"` // 关联系统游戏配置 ID
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_game_vendor_ext_sys_vendor,priority:1"` // 系统标识
VendorType string `gorm:"column:vendor_type;size:32;uniqueIndex:uk_game_vendor_ext_cfg_vendor,priority:2;index:idx_game_vendor_ext_sys_vendor,priority:2"` // 厂商类型
VendorGameID string `gorm:"column:vendor_game_id;size:64"` // 厂商游戏 ID
LaunchMode string `gorm:"column:launch_mode;size:32"` // 启动模式
PackageVersion string `gorm:"column:package_version;size:32"` // 包版本号
PackageURL string `gorm:"column:package_url;size:1024"` // 包下载地址
PreviewURL string `gorm:"column:preview_url;size:1024"` // 预览地址
Orientation *int `gorm:"column:orientation"` // 屏幕方向
SafeHeight int `gorm:"column:safe_height"` // 安全区域高度
GSP string `gorm:"column:gsp;size:64"` // GSP 配置
BridgeSchemaVersion string `gorm:"column:bridge_schema_version;size:16"` // Bridge 协议版本
CurrencyType *int `gorm:"column:currency_type"` // 货币类型
CurrencyIcon string `gorm:"column:currency_icon;size:1024"` // 货币图标
ExtraJSON string `gorm:"column:extra_json;type:text"` // 额外扩展配置
Enabled bool `gorm:"column:enabled;index:idx_game_vendor_ext_sys_vendor,priority:3"` // 是否启用
CreateTime time.Time `gorm:"column:create_time"` // 创建时间
UpdateTime time.Time `gorm:"column:update_time"` // 更新时间
ID int64 `gorm:"column:id;primaryKey"` // 主键
GameListConfigID int64 `gorm:"column:game_list_config_id;uniqueIndex:uk_game_vendor_ext_cfg_vendor,priority:1"` // 关联系统游戏配置 ID
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_game_vendor_ext_sys_vendor,priority:1;index:idx_game_vendor_ext_sys_profile_vendor,priority:1"` // 系统标识
Profile string `gorm:"column:profile;size:32;default:PROD;index:idx_game_vendor_ext_sys_profile_vendor,priority:2"` // 配置档案PROD/TEST
VendorType string `gorm:"column:vendor_type;size:32;uniqueIndex:uk_game_vendor_ext_cfg_vendor,priority:2;index:idx_game_vendor_ext_sys_vendor,priority:2;index:idx_game_vendor_ext_sys_profile_vendor,priority:3"` // 厂商类型
VendorGameID string `gorm:"column:vendor_game_id;size:64;index:idx_game_vendor_ext_sys_profile_vendor,priority:4"` // 厂商游戏 ID
LaunchMode string `gorm:"column:launch_mode;size:32"` // 启动模式
PackageVersion string `gorm:"column:package_version;size:32"` // 包版本号
PackageURL string `gorm:"column:package_url;size:1024"` // 包下载地址
PreviewURL string `gorm:"column:preview_url;size:1024"` // 预览地址
Orientation *int `gorm:"column:orientation"` // 屏幕方向
SafeHeight int `gorm:"column:safe_height"` // 安全区域高度
GSP string `gorm:"column:gsp;size:64"` // GSP 配置
BridgeSchemaVersion string `gorm:"column:bridge_schema_version;size:16"` // Bridge 协议版本
CurrencyType *int `gorm:"column:currency_type"` // 货币类型
CurrencyIcon string `gorm:"column:currency_icon;size:1024"` // 货币图标
ExtraJSON string `gorm:"column:extra_json;type:text"` // 额外扩展配置
Enabled bool `gorm:"column:enabled;index:idx_game_vendor_ext_sys_vendor,priority:3;index:idx_game_vendor_ext_sys_profile_vendor,priority:5"` // 是否启用
CreateTime time.Time `gorm:"column:create_time"` // 创建时间
UpdateTime time.Time `gorm:"column:update_time"` // 更新时间
}
// TableName 返回游戏厂商扩展表名。
@ -54,18 +55,22 @@ func (SysGameListVendorExt) TableName() string { return "sys_game_list_vendor_ex
// BaishunProviderConfig 保存每个系统的百顺接入配置。
type BaishunProviderConfig struct {
ID int64 `gorm:"column:id;primaryKey"` // 主键
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_baishun_provider_sys_origin"` // 系统标识
PlatformBaseURL string `gorm:"column:platform_base_url;size:1024"` // 百顺平台基础地址
AppID int64 `gorm:"column:app_id;index:idx_baishun_provider_app,priority:1"` // 百顺应用 ID
AppName string `gorm:"column:app_name;size:128"` // 百顺应用名
AppChannel string `gorm:"column:app_channel;size:64;index:idx_baishun_provider_app,priority:2"` // 百顺渠道
AppKey string `gorm:"column:app_key;size:255"` // 百顺密钥
GSP int `gorm:"column:gsp"` // 默认 GSP
LaunchCodeTTLSeconds int `gorm:"column:launch_code_ttl_seconds"` // 启动码过期秒数
SSTokenTTLSeconds int `gorm:"column:ss_token_ttl_seconds"` // ss_token 过期秒数
CreateTime time.Time `gorm:"column:create_time"` // 创建时间
UpdateTime time.Time `gorm:"column:update_time"` // 更新时间
ID int64 `gorm:"column:id;primaryKey"` // 主键
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_baishun_provider_sys_origin_profile,priority:1;index:idx_baishun_provider_active,priority:1"` // 系统标识
Profile string `gorm:"column:profile;size:32;default:PROD;uniqueIndex:uk_baishun_provider_sys_origin_profile,priority:2"` // 配置档案PROD/TEST
Active bool `gorm:"column:active;index:idx_baishun_provider_active,priority:2"` // 是否当前启用
PlatformBaseURL string `gorm:"column:platform_base_url;size:1024"` // 百顺平台基础地址
GameListURL string `gorm:"column:game_list_url;size:1024"` // 百顺游戏列表完整地址
LuckyGiftURL string `gorm:"column:lucky_gift_url;size:1024"` // 幸运礼物完整地址
AppID int64 `gorm:"column:app_id;index:idx_baishun_provider_app,priority:1"` // 百顺应用 ID
AppName string `gorm:"column:app_name;size:128"` // 百顺应用名
AppChannel string `gorm:"column:app_channel;size:64;index:idx_baishun_provider_app,priority:2"` // 百顺渠道
AppKey string `gorm:"column:app_key;size:255"` // 百顺密钥
GSP int `gorm:"column:gsp"` // 默认 GSP
LaunchCodeTTLSeconds int `gorm:"column:launch_code_ttl_seconds"` // 启动码过期秒数
SSTokenTTLSeconds int `gorm:"column:ss_token_ttl_seconds"` // ss_token 过期秒数
CreateTime time.Time `gorm:"column:create_time"` // 创建时间
UpdateTime time.Time `gorm:"column:update_time;index:idx_baishun_provider_active,priority:3"` // 更新时间
}
// TableName 返回百顺配置表名。
@ -73,29 +78,69 @@ func (BaishunProviderConfig) TableName() string { return "baishun_provider_confi
// BaishunGameCatalog 是百顺平台同步到本地的游戏目录。
type BaishunGameCatalog struct {
ID int64 `gorm:"column:id;primaryKey"` // 主键
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_baishun_catalog_sys_vendor_game,priority:1;index:idx_baishun_catalog_internal_game,priority:1"` // 系统标识
InternalGameID string `gorm:"column:internal_game_id;size:64;index:idx_baishun_catalog_internal_game,priority:2"` // 内部游戏 ID
VendorGameID int `gorm:"column:vendor_game_id;uniqueIndex:uk_baishun_catalog_sys_vendor_game,priority:2"` // 百顺游戏 ID
Name string `gorm:"column:name;size:128"` // 游戏名称
Cover string `gorm:"column:cover;size:1024"` // 封面图
PreviewURL string `gorm:"column:preview_url;size:1024"` // 预览地址
DownloadURL string `gorm:"column:download_url;size:1024"` // 下载地址
PackageVersion string `gorm:"column:package_version;size:32"` // 包版本
GameModeJSON string `gorm:"column:game_mode_json;size:64"` // 游戏模式原始 JSON
Orientation *int `gorm:"column:orientation"` // 屏幕方向
SafeHeight int `gorm:"column:safe_height"` // 安全区域高度
VenueLevelJSON string `gorm:"column:venue_level_json;size:64"` // 场馆等级 JSON
GSP string `gorm:"column:gsp;size:64"` // GSP 配置
RawJSON string `gorm:"column:raw_json;type:longtext"` // 原始目录数据
Status string `gorm:"column:status;size:32;index:idx_baishun_catalog_internal_game,priority:3"` // 状态
CreateTime time.Time `gorm:"column:create_time"` // 创建时间
UpdateTime time.Time `gorm:"column:update_time"` // 更新时间
ID int64 `gorm:"column:id;primaryKey"` // 主键
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_baishun_catalog_sys_profile_vendor_game,priority:1;index:idx_baishun_catalog_profile_internal_game,priority:1"` // 系统标识
Profile string `gorm:"column:profile;size:32;default:PROD;uniqueIndex:uk_baishun_catalog_sys_profile_vendor_game,priority:2;index:idx_baishun_catalog_profile_internal_game,priority:2"` // 配置档案PROD/TEST
InternalGameID string `gorm:"column:internal_game_id;size:64;index:idx_baishun_catalog_profile_internal_game,priority:3"` // 内部游戏 ID
VendorGameID int `gorm:"column:vendor_game_id;uniqueIndex:uk_baishun_catalog_sys_profile_vendor_game,priority:3"` // 百顺游戏 ID
Name string `gorm:"column:name;size:128"` // 游戏名称
Cover string `gorm:"column:cover;size:1024"` // 封面图
PreviewURL string `gorm:"column:preview_url;size:1024"` // 预览地址
DownloadURL string `gorm:"column:download_url;size:1024"` // 下载地址
PackageVersion string `gorm:"column:package_version;size:32"` // 包版本
GameModeJSON string `gorm:"column:game_mode_json;size:64"` // 游戏模式原始 JSON
Orientation *int `gorm:"column:orientation"` // 屏幕方向
SafeHeight int `gorm:"column:safe_height"` // 安全区域高度
VenueLevelJSON string `gorm:"column:venue_level_json;size:64"` // 场馆等级 JSON
GSP string `gorm:"column:gsp;size:64"` // GSP 配置
RawJSON string `gorm:"column:raw_json;type:longtext"` // 原始目录数据
Status string `gorm:"column:status;size:32;index:idx_baishun_catalog_profile_internal_game,priority:4"` // 状态
CreateTime time.Time `gorm:"column:create_time"` // 创建时间
UpdateTime time.Time `gorm:"column:update_time"` // 更新时间
}
// TableName 返回百顺目录表名。
func (BaishunGameCatalog) TableName() string { return "baishun_game_catalog" }
// LingxianProviderConfig 保存每个系统的灵仙接入配置。
type LingxianProviderConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_lingxian_provider_sys_origin_profile,priority:1;index:idx_lingxian_provider_active,priority:1"`
Profile string `gorm:"column:profile;size:32;default:PROD;uniqueIndex:uk_lingxian_provider_sys_origin_profile,priority:2"`
Active bool `gorm:"column:active;index:idx_lingxian_provider_active,priority:2"`
GameListURL string `gorm:"column:game_list_url;size:1024"`
AppKey string `gorm:"column:app_key;size:255"`
TestUID string `gorm:"column:test_uid;size:64"`
TestToken string `gorm:"column:test_token;size:1024"`
TestRoomID string `gorm:"column:test_room_id;size:64"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time;index:idx_lingxian_provider_active,priority:3"`
}
// TableName 返回灵仙配置表名。
func (LingxianProviderConfig) TableName() string { return "lingxian_provider_config" }
// LingxianGameCatalog 是灵仙平台同步到本地的游戏目录。
type LingxianGameCatalog struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_lingxian_catalog_sys_profile_vendor_game,priority:1;index:idx_lingxian_catalog_profile_internal_game,priority:1"`
Profile string `gorm:"column:profile;size:32;default:PROD;uniqueIndex:uk_lingxian_catalog_sys_profile_vendor_game,priority:2;index:idx_lingxian_catalog_profile_internal_game,priority:2"`
InternalGameID string `gorm:"column:internal_game_id;size:64;index:idx_lingxian_catalog_profile_internal_game,priority:3"`
VendorGameID string `gorm:"column:vendor_game_id;size:64;uniqueIndex:uk_lingxian_catalog_sys_profile_vendor_game,priority:3"`
Name string `gorm:"column:name;size:128"`
Cover string `gorm:"column:cover;size:1024"`
PreviewURL string `gorm:"column:preview_url;size:1024"`
DownloadURL string `gorm:"column:download_url;size:1024"`
PackageVersion string `gorm:"column:package_version;size:32"`
RawJSON string `gorm:"column:raw_json;type:longtext"`
Status string `gorm:"column:status;size:32;index:idx_lingxian_catalog_profile_internal_game,priority:4"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName 返回灵仙目录表名。
func (LingxianGameCatalog) TableName() string { return "lingxian_game_catalog" }
// BaishunLaunchSession 保存一次房间内游戏启动会话。
type BaishunLaunchSession struct {
ID int64 `gorm:"column:id;primaryKey"` // 会话主键

View File

@ -0,0 +1,60 @@
package model
import "time"
// BinanceRechargeConfig stores Binance API credentials per app origin.
type BinanceRechargeConfig struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_binance_recharge_config_origin"`
APIKey string `gorm:"column:api_key;size:255"`
APISecret string `gorm:"column:api_secret;size:255"`
Enabled bool `gorm:"column:enabled"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (BinanceRechargeConfig) TableName() string {
return "binance_recharge_config"
}
// BinanceRechargeRate maps a verified USDT amount range to gold per USD.
type BinanceRechargeRate struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
ConfigID int64 `gorm:"column:config_id;index:idx_binance_recharge_rate_config"`
MinAmount string `gorm:"column:min_amount;type:decimal(20,8)"`
MaxAmount string `gorm:"column:max_amount;type:decimal(20,8)"`
GoldPerUSD string `gorm:"column:gold_per_usd;type:decimal(20,8)"`
SortOrder int `gorm:"column:sort_order"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (BinanceRechargeRate) TableName() string {
return "binance_recharge_rate"
}
// BinanceRechargeVerifyRecord is the local idempotency ledger for auto recharge verification.
type BinanceRechargeVerifyRecord struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_binance_recharge_verify_origin;uniqueIndex:uk_binance_recharge_verify_order,priority:1;uniqueIndex:uk_binance_recharge_verify_bill,priority:1"`
DealerUserID int64 `gorm:"column:dealer_user_id;index:idx_binance_recharge_verify_dealer"`
TargetUserID int64 `gorm:"column:target_user_id;index:idx_binance_recharge_verify_target"`
OrderNo string `gorm:"column:order_no;size:128;uniqueIndex:uk_binance_recharge_verify_order,priority:2"`
TransferType string `gorm:"column:transfer_type;size:32;uniqueIndex:uk_binance_recharge_verify_bill,priority:2"`
BillNo string `gorm:"column:bill_no;size:160;uniqueIndex:uk_binance_recharge_verify_bill,priority:3"`
AmountUSDT string `gorm:"column:amount_usdt;type:decimal(20,8)"`
GoldAmount int64 `gorm:"column:gold_amount"`
RateGoldPerUSD string `gorm:"column:rate_gold_per_usd;type:decimal(20,8)"`
BinanceTransactionID string `gorm:"column:binance_transaction_id;size:180"`
BinanceOrderType string `gorm:"column:binance_order_type;size:64"`
BinanceRawJSON string `gorm:"column:binance_raw_json;type:text"`
Status string `gorm:"column:status;size:24;index:idx_binance_recharge_verify_status"`
FailReason string `gorm:"column:fail_reason;size:500"`
Remark string `gorm:"column:remark;size:500"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (BinanceRechargeVerifyRecord) TableName() string {
return "binance_recharge_verify_record"
}

View File

@ -0,0 +1,68 @@
package model
import "time"
// FirstRechargeRewardConfig stores first recharge reward config per system.
type FirstRechargeRewardConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_first_recharge_reward_sys_origin"`
Enabled bool `gorm:"column:enabled;index:idx_first_recharge_reward_enabled"`
MinAppVersion string `gorm:"column:min_app_version;size:32"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (FirstRechargeRewardConfig) TableName() string { return "first_recharge_reward_config" }
// FirstRechargeRewardLevel stores a recharge threshold and reward resource group.
type FirstRechargeRewardLevel struct {
ID int64 `gorm:"column:id;primaryKey"`
ConfigID int64 `gorm:"column:config_id;uniqueIndex:uk_first_recharge_reward_level,priority:1;uniqueIndex:uk_first_recharge_reward_amount,priority:1;index:idx_first_recharge_reward_level_config"`
Level int `gorm:"column:level;uniqueIndex:uk_first_recharge_reward_level,priority:2"`
RechargeAmountCents int64 `gorm:"column:recharge_amount_cents;uniqueIndex:uk_first_recharge_reward_amount,priority:2"`
GoogleProductID string `gorm:"column:google_product_id;size:128;index:idx_first_recharge_reward_level_google_product"`
RewardGroupID int64 `gorm:"column:reward_group_id"`
RewardGroupName string `gorm:"column:reward_group_name;size:255"`
Enabled bool `gorm:"column:enabled"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (FirstRechargeRewardLevel) TableName() string { return "first_recharge_reward_level" }
// FirstRechargeRewardGrantRecord stores the actual first recharge reward grant result.
type FirstRechargeRewardGrantRecord struct {
ID int64 `gorm:"column:id;primaryKey"`
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_first_recharge_reward_event"`
ConfigID int64 `gorm:"column:config_id"`
LevelID int64 `gorm:"column:level_id"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_first_recharge_reward_user,priority:1;index:idx_first_recharge_reward_status,priority:1"`
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_first_recharge_reward_user,priority:2;index:idx_first_recharge_reward_status,priority:2"`
Account string `gorm:"column:account;size:64"`
UserAvatar string `gorm:"column:user_avatar;size:1024"`
UserNickname string `gorm:"column:user_nickname;size:255"`
CountryCode string `gorm:"column:country_code;size:32"`
CountryName string `gorm:"column:country_name;size:128"`
RechargeAmountCents int64 `gorm:"column:recharge_amount_cents"`
RechargeThresholdCents int64 `gorm:"column:recharge_threshold_cents"`
Level int `gorm:"column:level"`
PayPlatform string `gorm:"column:pay_platform;size:64"`
PaymentMethod string `gorm:"column:payment_method;size:32"`
GoogleProductID string `gorm:"column:google_product_id;size:128;index:idx_first_recharge_reward_grant_google_product"`
SourceOrderID string `gorm:"column:source_order_id;size:128;index:idx_first_recharge_reward_order"`
RewardGroupID int64 `gorm:"column:reward_group_id"`
RewardGroupName string `gorm:"column:reward_group_name;size:255"`
RewardGroupTrackID int64 `gorm:"column:reward_group_track_id"`
Status string `gorm:"column:status;size:32;index:idx_first_recharge_reward_status,priority:3"`
RewardGroupStatus string `gorm:"column:reward_group_status;size:32"`
RetryCount int `gorm:"column:retry_count"`
LastError string `gorm:"column:last_error;size:1024"`
EventTime time.Time `gorm:"column:event_time"`
SentAt *time.Time `gorm:"column:sent_at"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (FirstRechargeRewardGrantRecord) TableName() string {
return "first_recharge_reward_grant_record"
}

View File

@ -0,0 +1,41 @@
package model
import "time"
// HotgameProviderConfig 保存每个系统、每个环境的热游回调验签配置。
type HotgameProviderConfig struct {
ID int64 `gorm:"column:id;primaryKey"` // 主键
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_hotgame_provider_sys_origin_profile,priority:1;index:idx_hotgame_provider_active,priority:1"` // 系统标识
Profile string `gorm:"column:profile;size:32;default:PROD;uniqueIndex:uk_hotgame_provider_sys_origin_profile,priority:2"` // 配置环境PROD/TEST
Active bool `gorm:"column:active;index:idx_hotgame_provider_active,priority:2"` // app 当前启用环境
AppKey string `gorm:"column:app_key;size:255"` // 热游回调 MD5 key
CallbackBaseURL string `gorm:"column:callback_base_url;size:1024"` // 提供给热游配置的回调域名
TestUID string `gorm:"column:test_uid;size:64"` // 联调用 UID 覆盖
TestToken string `gorm:"column:test_token;size:1024"` // 联调用 token 覆盖
CreateTime time.Time `gorm:"column:create_time"` // 创建时间
UpdateTime time.Time `gorm:"column:update_time;index:idx_hotgame_provider_active,priority:3"` // 更新时间
}
// TableName 返回热游接入配置表名。
func (HotgameProviderConfig) TableName() string { return "hotgame_provider_config" }
// HotgameGameCatalog 保存从热游对接表导入的游戏目录。
type HotgameGameCatalog struct {
ID int64 `gorm:"column:id;primaryKey"` // 主键
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_hotgame_catalog_sys_profile_vendor_game,priority:1;index:idx_hotgame_catalog_profile_internal_game,priority:1"` // 系统标识
Profile string `gorm:"column:profile;size:32;default:PROD;uniqueIndex:uk_hotgame_catalog_sys_profile_vendor_game,priority:2;index:idx_hotgame_catalog_profile_internal_game,priority:2"` // 配置环境PROD/TEST
InternalGameID string `gorm:"column:internal_game_id;size:64;index:idx_hotgame_catalog_profile_internal_game,priority:3"` // app 内部游戏 ID
VendorGameID string `gorm:"column:vendor_game_id;size:64;uniqueIndex:uk_hotgame_catalog_sys_profile_vendor_game,priority:3"` // 热游游戏 ID
Name string `gorm:"column:name;size:128"` // 游戏名称
Cover string `gorm:"column:cover;size:1024"` // 封面地址
PreviewURL string `gorm:"column:preview_url;size:1024"` // 七分屏/预览入口
DownloadURL string `gorm:"column:download_url;size:1024"` // 实际启动入口
PackageVersion string `gorm:"column:package_version;size:32"` // 版本号
RawJSON string `gorm:"column:raw_json;type:longtext"` // 原始目录 JSON
Status string `gorm:"column:status;size:32;index:idx_hotgame_catalog_profile_internal_game,priority:4"` // 目录状态
CreateTime time.Time `gorm:"column:create_time"` // 创建时间
UpdateTime time.Time `gorm:"column:update_time"` // 更新时间
}
// TableName 返回热游目录表名。
func (HotgameGameCatalog) TableName() string { return "hotgame_game_catalog" }

View File

@ -79,12 +79,12 @@ type InviteCampaignInviteeProgress struct {
// TableName 返回被邀请人进度表名。
func (InviteCampaignInviteeProgress) TableName() string { return "invite_campaign_invitee_progress" }
// InviteCampaignMonthlyProgress 汇总邀请人月度邀请进展。
// InviteCampaignMonthlyProgress 汇总邀请人当前任务周期邀请进展。
type InviteCampaignMonthlyProgress struct {
ID int64 `gorm:"column:id;primaryKey"` // 统计主键
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_invite_monthly_progress,priority:1"` // 系统标识
InviterUserID int64 `gorm:"column:inviter_user_id;uniqueIndex:uk_invite_monthly_progress,priority:2"` // 邀请人用户 ID
MonthKey string `gorm:"column:month_key;size:6;uniqueIndex:uk_invite_monthly_progress,priority:3"` // 月份标识
MonthKey string `gorm:"column:month_key;size:16;uniqueIndex:uk_invite_monthly_progress,priority:3"` // 任务周期标识
InviteCount int64 `gorm:"column:invite_count"` // 邀请人数
ValidUserCount int64 `gorm:"column:valid_user_count"` // 有效用户数
TotalRecharge int64 `gorm:"column:total_recharge_coins"` // 累计充值金币
@ -93,7 +93,7 @@ type InviteCampaignMonthlyProgress struct {
UpdateTime time.Time `gorm:"column:update_time"` // 更新时间
}
// TableName 返回邀请月度进度表名。
// TableName 返回邀请任务周期进度表名。
func (InviteCampaignMonthlyProgress) TableName() string { return "invite_campaign_monthly_progress" }
// InviteCampaignTaskClaim 记录任务奖励领取行为,防止重复领取。
@ -101,7 +101,7 @@ type InviteCampaignTaskClaim struct {
ID int64 `gorm:"column:id;primaryKey"` // 领取记录主键
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_invite_task_claim,priority:1"` // 系统标识
InviterUserID int64 `gorm:"column:inviter_user_id;uniqueIndex:uk_invite_task_claim,priority:2"` // 邀请人用户 ID
MonthKey string `gorm:"column:month_key;size:6;uniqueIndex:uk_invite_task_claim,priority:3"` // 月份标识
MonthKey string `gorm:"column:month_key;size:16;uniqueIndex:uk_invite_task_claim,priority:3"` // 任务周期标识
RuleID int64 `gorm:"column:rule_id;uniqueIndex:uk_invite_task_claim,priority:4"` // 规则 ID
ClaimTime time.Time `gorm:"column:claim_time"` // 领取时间
CreateTime time.Time `gorm:"column:create_time"` // 创建时间

View File

@ -0,0 +1,25 @@
package model
import "time"
// PropsCommodityStore stores one props store commodity row.
type PropsCommodityStore struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_props_store_source,priority:1"`
SourceID int64 `gorm:"column:source_id;index:idx_props_store_source,priority:2"`
PropsType string `gorm:"column:props_type;size:64"`
CurrencyTypes string `gorm:"column:currency_types;size:128"`
ValidDays string `gorm:"column:valid_days;size:128"`
Discount *float64 `gorm:"column:discount;type:decimal(10,4)"`
ShelfStatus *bool `gorm:"column:shelf_status"`
Sort int `gorm:"column:sort"`
Label string `gorm:"column:label;size:255"`
Del *bool `gorm:"column:is_del"`
CreateTime *time.Time `gorm:"column:create_time"`
UpdateTime *time.Time `gorm:"column:update_time"`
CreateUser *int64 `gorm:"column:create_user"`
UpdateUser *int64 `gorm:"column:update_user"`
}
// TableName returns the props store commodity table name.
func (PropsCommodityStore) TableName() string { return "props_commodity_store" }

View File

@ -0,0 +1,17 @@
package model
import "time"
// RechargeAgencySellerInfo stores H5-only profile fields for recharge agency sellers.
type RechargeAgencySellerInfo struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_recharge_agency_seller_info_user,priority:1"`
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_recharge_agency_seller_info_user,priority:2"`
Whatsapp string `gorm:"column:whatsapp;size:64"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (RechargeAgencySellerInfo) TableName() string {
return "recharge_agency_seller_info"
}

View File

@ -0,0 +1,67 @@
package model
import "time"
// RechargeRewardConfig 保存充值奖励活动的系统级配置。
type RechargeRewardConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_recharge_reward_sys_origin"`
Enabled bool `gorm:"column:enabled;index:idx_recharge_reward_enabled"`
Timezone string `gorm:"column:timezone;size:64"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (RechargeRewardConfig) TableName() string { return "recharge_reward_config" }
// RechargeRewardLevel 保存达到某个充值门槛后展示的奖励配置。
type RechargeRewardLevel struct {
ID int64 `gorm:"column:id;primaryKey"`
ConfigID int64 `gorm:"column:config_id;uniqueIndex:uk_recharge_reward_level,priority:1;uniqueIndex:uk_recharge_reward_amount,priority:1;index:idx_recharge_reward_level_config"`
Level int `gorm:"column:level;uniqueIndex:uk_recharge_reward_level,priority:2"`
RechargeAmountCents int64 `gorm:"column:recharge_amount_cents;uniqueIndex:uk_recharge_reward_amount,priority:2"`
RewardGold int64 `gorm:"column:reward_gold"`
RewardGroupID *int64 `gorm:"column:reward_group_id"`
RewardGroupName string `gorm:"column:reward_group_name;size:255"`
Enabled bool `gorm:"column:enabled"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (RechargeRewardLevel) TableName() string { return "recharge_reward_level" }
// RechargeRewardGrantRecord 记录某个充值周期内用户各档位的实际发放状态。
type RechargeRewardGrantRecord struct {
ID int64 `gorm:"column:id;primaryKey"`
ConfigID int64 `gorm:"column:config_id;index:idx_recharge_reward_grant_config_cycle,priority:1"`
LevelID int64 `gorm:"column:level_id"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_recharge_reward_grant_level,priority:1;index:idx_recharge_reward_grant_date,priority:1"`
CycleKey string `gorm:"column:cycle_key;size:32;uniqueIndex:uk_recharge_reward_grant_level,priority:2;index:idx_recharge_reward_grant_config_cycle,priority:2"`
RechargeDate int `gorm:"column:recharge_date;index:idx_recharge_reward_grant_user,priority:2;index:idx_recharge_reward_grant_date,priority:2"`
PeriodStartAt time.Time `gorm:"column:period_start_at"`
PeriodEndAt time.Time `gorm:"column:period_end_at"`
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_recharge_reward_grant_level,priority:3;index:idx_recharge_reward_grant_user,priority:1"`
Account string `gorm:"column:account;size:64"`
UserAvatar string `gorm:"column:user_avatar;size:1024"`
UserNickname string `gorm:"column:user_nickname;size:255"`
CountryCode string `gorm:"column:country_code;size:32"`
CountryName string `gorm:"column:country_name;size:128"`
RechargeAmountCents int64 `gorm:"column:recharge_amount_cents"`
Level int `gorm:"column:level;uniqueIndex:uk_recharge_reward_grant_level,priority:4"`
RechargeThresholdCents int64 `gorm:"column:recharge_threshold_cents"`
RewardGold int64 `gorm:"column:reward_gold"`
RewardGroupID *int64 `gorm:"column:reward_group_id"`
RewardGroupName string `gorm:"column:reward_group_name;size:255"`
RewardGroupTrackID int64 `gorm:"column:reward_group_track_id"`
GoldEventID string `gorm:"column:gold_event_id;size:128;index:idx_recharge_reward_gold_event"`
Status string `gorm:"column:status;size:32;index:idx_recharge_reward_grant_config_cycle,priority:3;index:idx_recharge_reward_grant_date,priority:3"`
RewardGroupStatus string `gorm:"column:reward_group_status;size:32"`
RewardGoldStatus string `gorm:"column:reward_gold_status;size:32"`
RetryCount int `gorm:"column:retry_count"`
LastError string `gorm:"column:last_error;size:1024"`
SentAt *time.Time `gorm:"column:sent_at"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (RechargeRewardGrantRecord) TableName() string { return "recharge_reward_grant_record" }

View File

@ -0,0 +1,19 @@
package model
import "time"
// VoiceRoomRegionIMGroup 保存区域通用腾讯 IM 群配置。
type VoiceRoomRegionIMGroup struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_voice_room_region_im_group,priority:1"`
RegionCode string `gorm:"column:region_code;size:32;uniqueIndex:uk_voice_room_region_im_group,priority:2;index:idx_voice_room_region_im_group_group,priority:2"`
RegionName string `gorm:"column:region_name;size:128"`
GroupID string `gorm:"column:group_id;size:128;index:idx_voice_room_region_im_group_group,priority:1"`
GroupName string `gorm:"column:group_name;size:128"`
Status string `gorm:"column:status;size:32"`
LastError string `gorm:"column:last_error;size:1024"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (VoiceRoomRegionIMGroup) TableName() string { return "voice_room_region_im_group" }

View File

@ -0,0 +1,61 @@
package model
import "time"
// RoomTurnoverRewardConfig 保存房间流水奖励的系统级配置。
type RoomTurnoverRewardConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_room_turnover_reward_sys_origin"`
Enabled bool `gorm:"column:enabled;index:idx_room_turnover_reward_enabled"`
AutoRewardEnabled bool `gorm:"column:auto_reward_enabled"`
Timezone string `gorm:"column:timezone;size:64"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (RoomTurnoverRewardConfig) TableName() string { return "room_turnover_reward_config" }
// RoomTurnoverRewardLevel 保存达到某个流水门槛后发放的金币奖励。
type RoomTurnoverRewardLevel struct {
ID int64 `gorm:"column:id;primaryKey"`
ConfigID int64 `gorm:"column:config_id;uniqueIndex:uk_room_turnover_reward_level,priority:1;uniqueIndex:uk_room_turnover_reward_threshold,priority:1;index:idx_room_turnover_reward_level_config"`
Level int `gorm:"column:level;uniqueIndex:uk_room_turnover_reward_level,priority:2"`
TurnoverThreshold int64 `gorm:"column:turnover_threshold;uniqueIndex:uk_room_turnover_reward_threshold,priority:2"`
RewardGold int64 `gorm:"column:reward_gold"`
Enabled bool `gorm:"column:enabled"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (RoomTurnoverRewardLevel) TableName() string { return "room_turnover_reward_level" }
// RoomTurnoverRewardRecord 记录每周给房主发放的最高档房间流水奖励。
type RoomTurnoverRewardRecord struct {
ID int64 `gorm:"column:id;primaryKey"`
ConfigID int64 `gorm:"column:config_id;index:idx_room_turnover_reward_record_config"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_room_turnover_reward_record_room,priority:1;index:idx_room_turnover_reward_record_cycle,priority:1"`
CycleKey string `gorm:"column:cycle_key;size:32;uniqueIndex:uk_room_turnover_reward_record_room,priority:2;index:idx_room_turnover_reward_record_cycle,priority:2"`
PeriodStartAt time.Time `gorm:"column:period_start_at"`
PeriodEndAt time.Time `gorm:"column:period_end_at"`
RoomID int64 `gorm:"column:room_id;uniqueIndex:uk_room_turnover_reward_record_room,priority:3;index:idx_room_turnover_reward_record_room"`
RoomAccount string `gorm:"column:room_account;size:64"`
RoomName string `gorm:"column:room_name;size:255"`
RoomCover string `gorm:"column:room_cover;size:1024"`
OwnerUserID int64 `gorm:"column:owner_user_id;index:idx_room_turnover_reward_record_owner"`
CountryCode string `gorm:"column:country_code;size:32"`
CountryName string `gorm:"column:country_name;size:128"`
TurnoverAmount int64 `gorm:"column:turnover_amount"`
Level int `gorm:"column:level"`
TurnoverThreshold int64 `gorm:"column:turnover_threshold"`
RewardGold int64 `gorm:"column:reward_gold"`
TrackID string `gorm:"column:track_id;size:128;uniqueIndex:uk_room_turnover_reward_track"`
SourceID string `gorm:"column:source_id;size:128"`
Status string `gorm:"column:status;size:32;index:idx_room_turnover_reward_record_status"`
RetryCount int `gorm:"column:retry_count"`
LastError string `gorm:"column:last_error;size:1024"`
SentAt *time.Time `gorm:"column:sent_at"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (RoomTurnoverRewardRecord) TableName() string { return "room_turnover_reward_record" }

View File

@ -0,0 +1,98 @@
package model
import "time"
// SmashEggConfig stores the resident smash golden egg activity master config.
type SmashEggConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_smash_egg_config_sys_origin"`
Enabled bool `gorm:"column:enabled;index:idx_smash_egg_config_enabled"`
Timezone string `gorm:"column:timezone;size:64"`
RTPBasisPoints int `gorm:"column:rtp_basis_points"`
NewbiePoolEnabled bool `gorm:"column:newbie_pool_enabled"`
NewbieWindowDays int `gorm:"column:newbie_window_days"`
NewbieMaxDrawCount int `gorm:"column:newbie_max_draw_count"`
NewbieMinRechargeAmount int64 `gorm:"column:newbie_min_recharge_amount"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the smash egg master configuration table name.
func (SmashEggConfig) TableName() string { return "smash_egg_config" }
// SmashEggDrawOptionConfig stores one draw button option.
type SmashEggDrawOptionConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
ConfigID int64 `gorm:"column:config_id;index:idx_smash_egg_option_config,priority:1"`
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_smash_egg_option_enabled,priority:1"`
OptionKey string `gorm:"column:option_key;size:32"`
Label string `gorm:"column:label;size:64"`
Times int `gorm:"column:times"`
PriceGold int64 `gorm:"column:price_gold"`
Sort int `gorm:"column:sort"`
Enabled bool `gorm:"column:enabled;index:idx_smash_egg_option_enabled,priority:2"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the smash egg draw option configuration table name.
func (SmashEggDrawOptionConfig) TableName() string { return "smash_egg_draw_option_config" }
// SmashEggRewardConfig stores one prize on the smash egg activity.
type SmashEggRewardConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
ConfigID int64 `gorm:"column:config_id;index:idx_smash_egg_reward_config,priority:1"`
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_smash_egg_reward_enabled,priority:1"`
PoolType string `gorm:"column:pool_type;size:32;index:idx_smash_egg_reward_pool,priority:2"`
RewardType string `gorm:"column:reward_type;size:32"`
RewardGroupID *int64 `gorm:"column:reward_group_id"`
RewardGroupName string `gorm:"column:reward_group_name;size:255"`
ResourceType string `gorm:"column:resource_type;size:64"`
ResourceURL string `gorm:"column:resource_url;size:512"`
CoverURL string `gorm:"column:cover_url;size:512"`
AnimationURL string `gorm:"column:animation_url;size:512"`
DurationDays int `gorm:"column:duration_days"`
DisplayGoldAmount int64 `gorm:"column:display_gold_amount"`
GoldAmount int64 `gorm:"column:gold_amount"`
RewardValueGold int64 `gorm:"column:reward_value_gold"`
Probability int `gorm:"column:probability"`
Sort int `gorm:"column:sort"`
Enabled bool `gorm:"column:enabled;index:idx_smash_egg_reward_enabled,priority:2"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the smash egg reward configuration table name.
func (SmashEggRewardConfig) TableName() string { return "smash_egg_reward_config" }
// SmashEggDrawRecord stores each prize item produced by a smash egg draw.
type SmashEggDrawRecord struct {
ID int64 `gorm:"column:id;primaryKey"`
DrawNo string `gorm:"column:draw_no;size:64;index:idx_smash_egg_draw_record_draw_no"`
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_smash_egg_draw_record_event"`
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_smash_egg_draw_record_user_time,priority:1;index:idx_smash_egg_draw_record_day,priority:1"`
UserID int64 `gorm:"column:user_id;index:idx_smash_egg_draw_record_user_time,priority:2"`
PoolType string `gorm:"column:pool_type;size:32;index:idx_smash_egg_draw_record_user_pool,priority:3"`
DrawOptionID int64 `gorm:"column:draw_option_id"`
DrawTimes int `gorm:"column:draw_times"`
PaidGold int64 `gorm:"column:paid_gold"`
RewardType string `gorm:"column:reward_type;size:32"`
RewardGroupID *int64 `gorm:"column:reward_group_id"`
RewardGroupName string `gorm:"column:reward_group_name;size:255"`
ResourceType string `gorm:"column:resource_type;size:64"`
ResourceURL string `gorm:"column:resource_url;size:512"`
CoverURL string `gorm:"column:cover_url;size:512"`
AnimationURL string `gorm:"column:animation_url;size:512"`
DurationDays int `gorm:"column:duration_days"`
DisplayGoldAmount int64 `gorm:"column:display_gold_amount"`
GoldAmount int64 `gorm:"column:gold_amount"`
RewardValueGold int64 `gorm:"column:reward_value_gold"`
Probability int `gorm:"column:probability"`
Status string `gorm:"column:status;size:32;index:idx_smash_egg_draw_record_status"`
ErrorMessage string `gorm:"column:error_message;size:1024"`
CreateTime time.Time `gorm:"column:create_time;index:idx_smash_egg_draw_record_user_time,priority:3;index:idx_smash_egg_draw_record_day,priority:2"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the smash egg draw record table name.
func (SmashEggDrawRecord) TableName() string { return "smash_egg_draw_record" }

View File

@ -0,0 +1,158 @@
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" }
// TaskCenterConditionJumpConfig 保存任务条件对应的跳转配置。
type TaskCenterConditionJumpConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
ConfigID int64 `gorm:"column:config_id;index:idx_task_center_condition_jump_config_id"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_task_center_condition_jump,priority:1;index:idx_task_center_condition_jump_sys_category"`
TaskCategory string `gorm:"column:task_category;size:32;uniqueIndex:uk_task_center_condition_jump,priority:2;index:idx_task_center_condition_jump_sys_category"`
ConditionType string `gorm:"column:condition_type;size:64;uniqueIndex:uk_task_center_condition_jump,priority:3;index:idx_task_center_condition_jump_condition"`
JumpType string `gorm:"column:jump_type;size:64"`
JumpPage string `gorm:"column:jump_page;size:512"`
SortOrder int `gorm:"column:sort_order;index:idx_task_center_condition_jump_sort"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (TaskCenterConditionJumpConfig) TableName() string {
return "task_center_condition_jump_config"
}
// TaskCenterUserProgress 保存用户在某个任务周期内的进度。
type TaskCenterUserProgress struct {
ID int64 `gorm:"column:id;primaryKey"`
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" }
// TaskCenterEventDedup 保存业务事件幂等记录。
type TaskCenterEventDedup struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_task_center_event_dedup,priority:1"`
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_task_center_event_dedup,priority:2"`
EventType string `gorm:"column:event_type;size:64"`
UserID int64 `gorm:"column:user_id"`
Status string `gorm:"column:status;size:32;index:idx_task_center_event_dedup_status"`
FailureReason string `gorm:"column:failure_reason;size:512"`
CreateTime time.Time `gorm:"column:create_time;index:idx_task_center_event_dedup_create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (TaskCenterEventDedup) TableName() string { return "task_center_event_dedup" }
// TaskCenterEventArchiveOutbox 保存待归档到 COS 的原始事件。
type TaskCenterEventArchiveOutbox struct {
ID int64 `gorm:"column:id;primaryKey;index:idx_task_center_archive_claim_create,priority:3;index:idx_task_center_archive_claim_stale,priority:3"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_task_center_archive_event,priority:1"`
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_task_center_archive_event,priority:2"`
EventType string `gorm:"column:event_type;size:64"`
UserID int64 `gorm:"column:user_id"`
OccurredAt time.Time `gorm:"column:occurred_at"`
RawJSON string `gorm:"column:raw_json;type:text"`
Status string `gorm:"column:status;size:32;index:idx_task_center_archive_status_time,priority:1;index:idx_task_center_archive_claim_create,priority:1;index:idx_task_center_archive_claim_stale,priority:1"`
RetryCount int `gorm:"column:retry_count"`
COSKey string `gorm:"column:cos_key;size:512"`
FailureReason string `gorm:"column:failure_reason;size:512"`
CreateTime time.Time `gorm:"column:create_time;index:idx_task_center_archive_status_time,priority:2;index:idx_task_center_archive_claim_create,priority:2"`
UpdateTime time.Time `gorm:"column:update_time;index:idx_task_center_archive_claim_stale,priority:2"`
ArchivedAt *time.Time `gorm:"column:archived_at"`
}
func (TaskCenterEventArchiveOutbox) TableName() string {
return "task_center_event_archive_outbox"
}
// TaskCenterClaimRecord 保存任务奖励领取记录。
type TaskCenterClaimRecord struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_task_center_claim_sys"`
UserID int64 `gorm:"column:user_id;index:idx_task_center_claim_user"`
TaskID int64 `gorm:"column:task_id;index:idx_task_center_claim_task"`
TaskCode string `gorm:"column:task_code;size:64;index:idx_task_center_claim_task_code"`
TaskName string `gorm:"column:task_name;size:255"`
TaskCategory string `gorm:"column:task_category;size:32"`
CycleKey string `gorm:"column:cycle_key;size:32;index:idx_task_center_claim_cycle"`
RewardGold int64 `gorm:"column:reward_gold"`
WalletEventID string `gorm:"column:wallet_event_id;size:160;uniqueIndex:uk_task_center_claim_wallet_event"`
Status string `gorm:"column:status;size:32;index:idx_task_center_claim_status"`
FailureReason string `gorm:"column:failure_reason;size:512"`
ClaimedAt *time.Time `gorm:"column:claimed_at"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (TaskCenterClaimRecord) TableName() string { return "task_center_claim_record" }

View File

@ -0,0 +1,109 @@
package model
import "time"
// VipLevelConfig stores the operator-managed 30-day VIP level configuration.
type VipLevelConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_vip_level_config,priority:1;index:idx_vip_level_config_sys_origin,priority:1"`
Level int `gorm:"column:level;uniqueIndex:uk_vip_level_config,priority:2"`
LevelCode string `gorm:"column:level_code;size:16"`
DisplayName string `gorm:"column:display_name;size:64"`
Enabled bool `gorm:"column:enabled"`
DurationDays int `gorm:"column:duration_days"`
PriceGold int64 `gorm:"column:price_gold"`
BadgeResourceID int64 `gorm:"column:badge_resource_id"`
BadgeName string `gorm:"column:badge_name;size:128"`
BadgeURL string `gorm:"column:badge_url;size:512"`
ShortBadgeResourceID int64 `gorm:"column:short_badge_resource_id"`
ShortBadgeName string `gorm:"column:short_badge_name;size:128"`
ShortBadgeURL string `gorm:"column:short_badge_url;size:512"`
AvatarFrameResourceID int64 `gorm:"column:avatar_frame_resource_id"`
AvatarFrameName string `gorm:"column:avatar_frame_name;size:128"`
AvatarFrameURL string `gorm:"column:avatar_frame_url;size:512"`
EntryEffectResourceID int64 `gorm:"column:entry_effect_resource_id"`
EntryEffectName string `gorm:"column:entry_effect_name;size:128"`
EntryEffectURL string `gorm:"column:entry_effect_url;size:512"`
ChatBubbleResourceID int64 `gorm:"column:chat_bubble_resource_id"`
ChatBubbleName string `gorm:"column:chat_bubble_name;size:128"`
ChatBubbleURL string `gorm:"column:chat_bubble_url;size:512"`
FloatPictureResourceID int64 `gorm:"column:float_picture_resource_id"`
FloatPictureName string `gorm:"column:float_picture_name;size:128"`
FloatPictureURL string `gorm:"column:float_picture_url;size:512"`
BackgroundCardResourceID int64 `gorm:"column:background_card_resource_id"`
BackgroundCardName string `gorm:"column:background_card_name;size:128"`
BackgroundCardURL string `gorm:"column:background_card_url;size:512"`
EffectImageResourceID int64 `gorm:"column:effect_image_resource_id"`
EffectImageName string `gorm:"column:effect_image_name;size:128"`
EffectImageURL string `gorm:"column:effect_image_url;size:512"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the VIP level config table name.
func (VipLevelConfig) TableName() string { return "vip_level_config" }
// UserVipState stores the single active VIP state for a user.
type UserVipState struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_user_vip_state,priority:1;index:idx_user_vip_state_lookup,priority:1"`
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_user_vip_state,priority:2;index:idx_user_vip_state_lookup,priority:2"`
Level int `gorm:"column:level"`
LevelCode string `gorm:"column:level_code;size:16"`
DisplayName string `gorm:"column:display_name;size:64"`
Status string `gorm:"column:status;size:32;index:idx_user_vip_state_lookup,priority:3"`
StartAt time.Time `gorm:"column:start_at"`
ExpireAt time.Time `gorm:"column:expire_at;index:idx_user_vip_expire"`
BadgeResourceID int64 `gorm:"column:badge_resource_id"`
BadgeName string `gorm:"column:badge_name;size:128"`
BadgeURL string `gorm:"column:badge_url;size:512"`
ShortBadgeResourceID int64 `gorm:"column:short_badge_resource_id"`
ShortBadgeName string `gorm:"column:short_badge_name;size:128"`
ShortBadgeURL string `gorm:"column:short_badge_url;size:512"`
AvatarFrameResourceID int64 `gorm:"column:avatar_frame_resource_id"`
AvatarFrameName string `gorm:"column:avatar_frame_name;size:128"`
AvatarFrameURL string `gorm:"column:avatar_frame_url;size:512"`
EntryEffectResourceID int64 `gorm:"column:entry_effect_resource_id"`
EntryEffectName string `gorm:"column:entry_effect_name;size:128"`
EntryEffectURL string `gorm:"column:entry_effect_url;size:512"`
ChatBubbleResourceID int64 `gorm:"column:chat_bubble_resource_id"`
ChatBubbleName string `gorm:"column:chat_bubble_name;size:128"`
ChatBubbleURL string `gorm:"column:chat_bubble_url;size:512"`
FloatPictureResourceID int64 `gorm:"column:float_picture_resource_id"`
FloatPictureName string `gorm:"column:float_picture_name;size:128"`
FloatPictureURL string `gorm:"column:float_picture_url;size:512"`
BackgroundCardResourceID int64 `gorm:"column:background_card_resource_id"`
BackgroundCardName string `gorm:"column:background_card_name;size:128"`
BackgroundCardURL string `gorm:"column:background_card_url;size:512"`
EffectImageResourceID int64 `gorm:"column:effect_image_resource_id"`
EffectImageName string `gorm:"column:effect_image_name;size:128"`
EffectImageURL string `gorm:"column:effect_image_url;size:512"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the user VIP state table name.
func (UserVipState) TableName() string { return "user_vip_state" }
// VipOrderRecord stores VIP purchase, renew, and upgrade records.
type VipOrderRecord struct {
ID int64 `gorm:"column:id;primaryKey"`
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_vip_order_event"`
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_vip_order_user_time,priority:1"`
UserID int64 `gorm:"column:user_id;index:idx_vip_order_user_time,priority:2"`
OrderType string `gorm:"column:order_type;size:32"`
FromLevel int `gorm:"column:from_level"`
ToLevel int `gorm:"column:to_level"`
DurationDays int `gorm:"column:duration_days"`
OriginalPriceGold int64 `gorm:"column:original_price_gold"`
PaidGold int64 `gorm:"column:paid_gold"`
Status string `gorm:"column:status;size:32;index:idx_vip_order_status"`
StartAt time.Time `gorm:"column:start_at"`
ExpireAt time.Time `gorm:"column:expire_at"`
ErrorMessage string `gorm:"column:error_message;size:512"`
CreateTime time.Time `gorm:"column:create_time;index:idx_vip_order_user_time,priority:3"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the VIP order table name.
func (VipOrderRecord) TableName() string { return "vip_order_record" }

View File

@ -0,0 +1,127 @@
package model
import "time"
// VoiceRoomRedPacketConfig 保存语音房红包系统级配置。
type VoiceRoomRedPacketConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_voice_room_red_packet_config"`
Enabled bool `gorm:"column:enabled"`
DailySendLimit int `gorm:"column:daily_send_limit"`
DelaySeconds int `gorm:"column:delay_seconds"`
RefundAfterSeconds int `gorm:"column:refund_after_seconds"`
Timezone string `gorm:"column:timezone;size:64"`
AmountOptions string `gorm:"column:amount_options;size:255"`
CountOptions string `gorm:"column:count_options;size:255"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (VoiceRoomRedPacketConfig) TableName() string { return "voice_room_red_packet_config" }
// VoiceRoomRegionCountry 保存国家到区域的映射。
type VoiceRoomRegionCountry struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_voice_room_region_country,priority:1;index:idx_voice_room_region_country_region,priority:1"`
RegionCode string `gorm:"column:region_code;size:32;index:idx_voice_room_region_country_region,priority:2"`
CountryCode string `gorm:"column:country_code;size:32;uniqueIndex:uk_voice_room_region_country,priority:2"`
Enabled bool `gorm:"column:enabled"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (VoiceRoomRegionCountry) TableName() string { return "voice_room_region_country" }
// RoomLiveBroadcastVIP 是 Java 房间资料表Go 侧只读取 room_id 到 room_account 的映射。
type RoomLiveBroadcastVIP struct {
ID int64 `gorm:"column:id;primaryKey"`
UserID int64 `gorm:"column:user_id"`
RoomID int64 `gorm:"column:room_id;index"`
RoomAccount int64 `gorm:"column:room_account"`
ExpireDate time.Time `gorm:"column:expire_date"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (RoomLiveBroadcastVIP) TableName() string { return "room_live_broadcasts_vip" }
// VoiceRoomRedPacket 保存语音房红包主记录。
type VoiceRoomRedPacket struct {
ID int64 `gorm:"column:id;primaryKey"`
PacketNo string `gorm:"column:packet_no;size:64;uniqueIndex:uk_voice_room_red_packet_no"`
RequestID string `gorm:"column:request_id;size:128;uniqueIndex:uk_voice_room_red_packet_request"`
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_voice_room_red_packet_room,priority:1;index:idx_voice_room_red_packet_sender_day,priority:1"`
RoomID int64 `gorm:"column:room_id;index:idx_voice_room_red_packet_room,priority:2"`
SenderUserID int64 `gorm:"column:sender_user_id;index:idx_voice_room_red_packet_sender_day,priority:2"`
SenderRegionCode string `gorm:"column:sender_region_code;size:32"`
PacketMode string `gorm:"column:packet_mode;size:16"`
AmountMode string `gorm:"column:amount_mode;size:16"`
TotalAmount int64 `gorm:"column:total_amount"`
TotalCount int `gorm:"column:total_count"`
RemainAmount int64 `gorm:"column:remain_amount"`
RemainCount int `gorm:"column:remain_count"`
ClaimStartTime time.Time `gorm:"column:claim_start_time"`
ExpireTime time.Time `gorm:"column:expire_time;index:idx_voice_room_red_packet_expire,priority:2"`
Status string `gorm:"column:status;size:32;index:idx_voice_room_red_packet_expire,priority:1"`
SendWalletEventID string `gorm:"column:send_wallet_event_id;size:128;uniqueIndex:uk_voice_room_red_packet_send_event"`
RefundWalletEventID string `gorm:"column:refund_wallet_event_id;size:128"`
RefundAmount int64 `gorm:"column:refund_amount"`
RefundStatus string `gorm:"column:refund_status;size:32"`
ErrorMessage string `gorm:"column:error_message;size:1024"`
CreateTime time.Time `gorm:"column:create_time;index:idx_voice_room_red_packet_sender_day,priority:3"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (VoiceRoomRedPacket) TableName() string { return "voice_room_red_packet" }
// VoiceRoomRedPacketSlice 保存红包预拆分份额。
type VoiceRoomRedPacketSlice struct {
ID int64 `gorm:"column:id;primaryKey;index:idx_voice_room_red_packet_slice_claim_order,priority:4"`
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;index:idx_voice_room_red_packet_slice_claim_order,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;index:idx_voice_room_red_packet_slice_claim_order,priority:3"`
Amount int64 `gorm:"column:amount"`
Status string `gorm:"column:status;size:32;index:idx_voice_room_red_packet_slice_claim,priority:2;index:idx_voice_room_red_packet_slice_claim_order,priority:2"`
ClaimUserID *int64 `gorm:"column:claim_user_id"`
ClaimRecordID *int64 `gorm:"column:claim_record_id"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (VoiceRoomRedPacketSlice) TableName() string { return "voice_room_red_packet_slice" }
// VoiceRoomRedPacketClaim 保存领取记录和入账状态。
type VoiceRoomRedPacketClaim struct {
ID int64 `gorm:"column:id;primaryKey"`
PacketID int64 `gorm:"column:packet_id;uniqueIndex:uk_voice_room_red_packet_claim_user,priority:1;index:idx_voice_room_red_packet_claim_packet,priority:1"`
PacketNo string `gorm:"column:packet_no;size:64"`
SliceID int64 `gorm:"column:slice_id"`
SysOrigin string `gorm:"column:sys_origin;size:32"`
RoomID int64 `gorm:"column:room_id"`
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_voice_room_red_packet_claim_user,priority:2"`
UserRegionCode string `gorm:"column:user_region_code;size:32"`
Amount int64 `gorm:"column:amount"`
WalletEventID string `gorm:"column:wallet_event_id;size:128;uniqueIndex:uk_voice_room_red_packet_claim_event"`
WalletStatus string `gorm:"column:wallet_status;size:32"`
ErrorMessage string `gorm:"column:error_message;size:1024"`
ClaimTime time.Time `gorm:"column:claim_time;index:idx_voice_room_red_packet_claim_packet,priority:2"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (VoiceRoomRedPacketClaim) TableName() string { return "voice_room_red_packet_claim" }
// VoiceRoomRedPacketDailyCounter 保存用户单日发送计数。
type VoiceRoomRedPacketDailyCounter struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_voice_room_red_packet_daily,priority:1"`
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_voice_room_red_packet_daily,priority:2"`
DayKey string `gorm:"column:day_key;size:16;uniqueIndex:uk_voice_room_red_packet_daily,priority:3"`
SendCount int `gorm:"column:send_count"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (VoiceRoomRedPacketDailyCounter) TableName() string {
return "voice_room_red_packet_daily_counter"
}

View File

@ -0,0 +1,214 @@
package model
import "time"
// VoiceRoomRocketConfig 保存语音房火箭系统级配置。
type VoiceRoomRocketConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_voice_room_rocket_config"`
Enabled bool `gorm:"column:enabled"`
Timezone string `gorm:"column:timezone;size:64"`
MaxLevel int `gorm:"column:max_level"`
RankingTopLimit int `gorm:"column:ranking_top_limit"`
RewardRecordKeepDays int `gorm:"column:reward_record_keep_days"`
BroadcastDurationSeconds int `gorm:"column:broadcast_duration_seconds"`
InRoomRewardDelaySeconds int `gorm:"column:in_room_reward_delay_seconds"`
EnergyRuleJSON string `gorm:"column:energy_rule_json;type:json"`
RuleText string `gorm:"column:rule_text;type:json"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (VoiceRoomRocketConfig) TableName() string { return "voice_room_rocket_config" }
// VoiceRoomRocketLevelConfig 保存每级能量、资源和抖动阈值配置。
type VoiceRoomRocketLevelConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_voice_room_rocket_level,priority:1"`
Level int `gorm:"column:level;uniqueIndex:uk_voice_room_rocket_level,priority:2"`
NeedEnergy int64 `gorm:"column:need_energy"`
ShakeThresholdPercent string `gorm:"column:shake_threshold_percent;type:decimal(8,4)"`
PreviewRocketURL string `gorm:"column:preview_rocket_url;size:512"`
RocketIconURL string `gorm:"column:rocket_icon_url;size:512"`
RocketAnimationURL string `gorm:"column:rocket_animation_url;size:512"`
ProgressBarURL string `gorm:"column:progress_bar_url;size:512"`
Enabled bool `gorm:"column:enabled"`
Sort int `gorm:"column:sort"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (VoiceRoomRocketLevelConfig) TableName() string { return "voice_room_rocket_level_config" }
// VoiceRoomRocketRewardConfig 保存 Top1、引爆、在房间三类奖励配置。
type VoiceRoomRocketRewardConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_voice_room_rocket_reward_scene,priority:1"`
Level int `gorm:"column:level;index:idx_voice_room_rocket_reward_scene,priority:2"`
RewardScene string `gorm:"column:reward_scene;size:32;index:idx_voice_room_rocket_reward_scene,priority:3"`
RewardType string `gorm:"column:reward_type;size:32"`
RewardItemID *int64 `gorm:"column:reward_item_id"`
RewardName string `gorm:"column:reward_name;size:128"`
RewardCover string `gorm:"column:reward_cover;size:512"`
RewardAmount int64 `gorm:"column:reward_amount"`
ExpireDays *int `gorm:"column:expire_days"`
RewardCopies int `gorm:"column:reward_copies;default:1"`
Weight int `gorm:"column:weight"`
Sort int `gorm:"column:sort"`
Enabled bool `gorm:"column:enabled;index:idx_voice_room_rocket_reward_scene,priority:4"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (VoiceRoomRocketRewardConfig) TableName() string { return "voice_room_rocket_reward_config" }
// VoiceRoomRocketStatus 保存房间当天当前火箭状态。
type VoiceRoomRocketStatus struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_voice_room_rocket_status,priority:1"`
RoomID int64 `gorm:"column:room_id;uniqueIndex:uk_voice_room_rocket_status,priority:2"`
DayKey string `gorm:"column:day_key;size:16;uniqueIndex:uk_voice_room_rocket_status,priority:3"`
RoundNo int `gorm:"column:round_no"`
CurrentLevel int `gorm:"column:current_level"`
CurrentEnergy int64 `gorm:"column:current_energy"`
NeedEnergy int64 `gorm:"column:need_energy"`
Status string `gorm:"column:status;size:32"`
LevelStartTime time.Time `gorm:"column:level_start_time"`
LastEventTime *time.Time `gorm:"column:last_event_time"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (VoiceRoomRocketStatus) TableName() string { return "voice_room_rocket_status" }
// VoiceRoomRocketLevelRound 保存每轮每级的充能/发射快照。
type VoiceRoomRocketLevelRound struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_voice_room_rocket_level_round,priority:1;index:idx_voice_room_rocket_level_round_room,priority:1"`
RoomID int64 `gorm:"column:room_id;uniqueIndex:uk_voice_room_rocket_level_round,priority:2;index:idx_voice_room_rocket_level_round_room,priority:2"`
DayKey string `gorm:"column:day_key;size:16;uniqueIndex:uk_voice_room_rocket_level_round,priority:3;index:idx_voice_room_rocket_level_round_room,priority:3"`
RoundNo int `gorm:"column:round_no;uniqueIndex:uk_voice_room_rocket_level_round,priority:4;index:idx_voice_room_rocket_level_round_room,priority:4"`
Level int `gorm:"column:level;uniqueIndex:uk_voice_room_rocket_level_round,priority:5"`
NeedEnergy int64 `gorm:"column:need_energy"`
FinalEnergy int64 `gorm:"column:final_energy"`
Status string `gorm:"column:status;size:32"`
Top1UserID *int64 `gorm:"column:top1_user_id"`
IgniteUserID *int64 `gorm:"column:ignite_user_id"`
ReachTime *time.Time `gorm:"column:reach_time"`
LaunchTime *time.Time `gorm:"column:launch_time"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (VoiceRoomRocketLevelRound) TableName() string { return "voice_room_rocket_level_round" }
// VoiceRoomRocketContribution 保存本轮本级用户贡献榜。
type VoiceRoomRocketContribution struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_voice_room_rocket_contribution_user,priority:1;index:idx_voice_room_rocket_contribution_rank,priority:1"`
RoomID int64 `gorm:"column:room_id;uniqueIndex:uk_voice_room_rocket_contribution_user,priority:2;index:idx_voice_room_rocket_contribution_rank,priority:2"`
DayKey string `gorm:"column:day_key;size:16;uniqueIndex:uk_voice_room_rocket_contribution_user,priority:3;index:idx_voice_room_rocket_contribution_rank,priority:3"`
RoundNo int `gorm:"column:round_no;uniqueIndex:uk_voice_room_rocket_contribution_user,priority:4;index:idx_voice_room_rocket_contribution_rank,priority:4"`
Level int `gorm:"column:level;uniqueIndex:uk_voice_room_rocket_contribution_user,priority:5;index:idx_voice_room_rocket_contribution_rank,priority:5"`
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_voice_room_rocket_contribution_user,priority:6"`
ScoreEnergy int64 `gorm:"column:score_energy;index:idx_voice_room_rocket_contribution_rank,priority:6"`
GiftCount int `gorm:"column:gift_count"`
FirstContributeTime time.Time `gorm:"column:first_contribute_time"`
LastContributeTime time.Time `gorm:"column:last_contribute_time;index:idx_voice_room_rocket_contribution_rank,priority:7"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (VoiceRoomRocketContribution) TableName() string { return "voice_room_rocket_contribution" }
// VoiceRoomRocketGiftEventLog 保存送礼事件幂等和处理日志。
type VoiceRoomRocketGiftEventLog struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_voice_room_rocket_event_room,priority:1"`
TrackID string `gorm:"column:track_id;size:128;uniqueIndex:uk_voice_room_rocket_track"`
RoomID int64 `gorm:"column:room_id;index:idx_voice_room_rocket_event_room,priority:2"`
SendUserID int64 `gorm:"column:send_user_id"`
GiftID *int64 `gorm:"column:gift_id"`
GiftType string `gorm:"column:gift_type;size:32"`
GiftTab string `gorm:"column:gift_tab;size:64"`
RawEnergy int64 `gorm:"column:raw_energy"`
AcceptedEnergy int64 `gorm:"column:accepted_energy"`
DayKey string `gorm:"column:day_key;size:16;index:idx_voice_room_rocket_event_room,priority:3"`
RoundNo int `gorm:"column:round_no;index:idx_voice_room_rocket_event_room,priority:4"`
Level int `gorm:"column:level;index:idx_voice_room_rocket_event_room,priority:5"`
Status string `gorm:"column:status;size:32"`
RawPayload string `gorm:"column:raw_payload;type:mediumtext"`
ErrorMessage string `gorm:"column:error_message;size:1024"`
EventTime time.Time `gorm:"column:event_time"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (VoiceRoomRocketGiftEventLog) TableName() string { return "voice_room_rocket_gift_event_log" }
// VoiceRoomRocketLaunch 保存火箭每次发射记录。
type VoiceRoomRocketLaunch struct {
ID int64 `gorm:"column:id;primaryKey"`
LaunchNo string `gorm:"column:launch_no;size:64;uniqueIndex:uk_voice_room_rocket_launch_no"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_voice_room_rocket_launch_level,priority:1;index:idx_voice_room_rocket_launch_room,priority:1"`
RoomID int64 `gorm:"column:room_id;uniqueIndex:uk_voice_room_rocket_launch_level,priority:2;index:idx_voice_room_rocket_launch_room,priority:2"`
RoomAccount string `gorm:"column:room_account;size:64"`
DayKey string `gorm:"column:day_key;size:16;uniqueIndex:uk_voice_room_rocket_launch_level,priority:3"`
RoundNo int `gorm:"column:round_no;uniqueIndex:uk_voice_room_rocket_launch_level,priority:4"`
Level int `gorm:"column:level;uniqueIndex:uk_voice_room_rocket_launch_level,priority:5"`
FinalEnergy int64 `gorm:"column:final_energy"`
Top1UserID *int64 `gorm:"column:top1_user_id"`
IgniteUserID *int64 `gorm:"column:ignite_user_id"`
InRoomSnapshotStatus string `gorm:"column:in_room_snapshot_status;size:32"`
LaunchTime time.Time `gorm:"column:launch_time;index:idx_voice_room_rocket_launch_room,priority:3"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (VoiceRoomRocketLaunch) TableName() string { return "voice_room_rocket_launch" }
// VoiceRoomRocketRewardRecord 保存火箭奖励发放和弹窗状态。
type VoiceRoomRocketRewardRecord struct {
ID int64 `gorm:"column:id;primaryKey"`
LaunchID int64 `gorm:"column:launch_id;index:idx_voice_room_rocket_reward_launch,priority:1"`
LaunchNo string `gorm:"column:launch_no;size:64"`
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_voice_room_rocket_reward_user,priority:1"`
RoomID int64 `gorm:"column:room_id"`
DayKey string `gorm:"column:day_key;size:16"`
RoundNo int `gorm:"column:round_no"`
Level int `gorm:"column:level"`
UserID int64 `gorm:"column:user_id;index:idx_voice_room_rocket_reward_user,priority:2;index:idx_voice_room_rocket_reward_launch,priority:2"`
RewardScene string `gorm:"column:reward_scene;size:32"`
RewardConfigID int64 `gorm:"column:reward_config_id"`
RewardType string `gorm:"column:reward_type;size:32"`
RewardItemID *int64 `gorm:"column:reward_item_id"`
RewardName string `gorm:"column:reward_name;size:128"`
RewardCover string `gorm:"column:reward_cover;size:512"`
RewardAmount int64 `gorm:"column:reward_amount"`
ExpireDays *int `gorm:"column:expire_days"`
GrantEventID string `gorm:"column:grant_event_id;size:128;uniqueIndex:uk_voice_room_rocket_reward_event"`
GrantStatus string `gorm:"column:grant_status;size:32;index:idx_voice_room_rocket_reward_retry,priority:1"`
PopupStatus string `gorm:"column:popup_status;size:32"`
ErrorMessage string `gorm:"column:error_message;size:1024"`
GrantTime *time.Time `gorm:"column:grant_time"`
CreateTime time.Time `gorm:"column:create_time;index:idx_voice_room_rocket_reward_user,priority:3"`
UpdateTime time.Time `gorm:"column:update_time;index:idx_voice_room_rocket_reward_retry,priority:2"`
}
func (VoiceRoomRocketRewardRecord) TableName() string { return "voice_room_rocket_reward_record" }
// VoiceRoomRocketAudienceSnapshot 保存发射后窗口内在线用户快照。
type VoiceRoomRocketAudienceSnapshot struct {
ID int64 `gorm:"column:id;primaryKey"`
LaunchID int64 `gorm:"column:launch_id;uniqueIndex:uk_voice_room_rocket_audience,priority:1;index:idx_voice_room_rocket_audience_launch,priority:1"`
SysOrigin string `gorm:"column:sys_origin;size:32"`
RoomID int64 `gorm:"column:room_id"`
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_voice_room_rocket_audience,priority:2"`
LastSeenTime time.Time `gorm:"column:last_seen_time"`
Selected bool `gorm:"column:selected;index:idx_voice_room_rocket_audience_launch,priority:2"`
CreateTime time.Time `gorm:"column:create_time"`
}
func (VoiceRoomRocketAudienceSnapshot) TableName() string {
return "voice_room_rocket_audience_snapshot"
}

View File

@ -0,0 +1,94 @@
package model
import "time"
// WheelConfig stores the resident activity wheel master configuration.
type WheelConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_wheel_config_sys_origin"`
Enabled bool `gorm:"column:enabled"`
Timezone string `gorm:"column:timezone;size:64"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the wheel master configuration table name.
func (WheelConfig) TableName() string { return "wheel_config" }
// WheelPoolConfig stores category-level wheel prices and switches.
type WheelPoolConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
ConfigID int64 `gorm:"column:config_id;uniqueIndex:uk_wheel_pool_config,priority:1;index:idx_wheel_pool_config,priority:1"`
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_wheel_pool_sys_origin,priority:1"`
Category string `gorm:"column:category;size:32;uniqueIndex:uk_wheel_pool_config,priority:2;index:idx_wheel_pool_sys_origin,priority:2"`
Enabled bool `gorm:"column:enabled"`
PriceOneGold int64 `gorm:"column:price_one_gold"`
PriceTenGold int64 `gorm:"column:price_ten_gold"`
PriceFiftyGold int64 `gorm:"column:price_fifty_gold"`
Sort int `gorm:"column:sort"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the wheel pool configuration table name.
func (WheelPoolConfig) TableName() string { return "wheel_pool_config" }
// WheelRewardConfig stores one reward option on the wheel.
type WheelRewardConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
ConfigID int64 `gorm:"column:config_id;index:idx_wheel_reward_config,priority:1"`
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_wheel_reward_enabled,priority:1"`
Category string `gorm:"column:category;size:32;index:idx_wheel_reward_config,priority:2;index:idx_wheel_reward_enabled,priority:2"`
RewardType string `gorm:"column:reward_type;size:32"`
ResourceID *int64 `gorm:"column:resource_id"`
ResourceType string `gorm:"column:resource_type;size:64"`
ResourceName string `gorm:"column:resource_name;size:255"`
ResourceURL string `gorm:"column:resource_url;size:512"`
CoverURL string `gorm:"column:cover_url;size:512"`
DurationDays int `gorm:"column:duration_days"`
DisplayGoldAmount int64 `gorm:"column:display_gold_amount"`
GoldAmount int64 `gorm:"column:gold_amount"`
Probability int `gorm:"column:probability"`
TotalLimit int64 `gorm:"column:total_limit"`
UserLimit int64 `gorm:"column:user_limit"`
IssuedCount int64 `gorm:"column:issued_count"`
Sort int `gorm:"column:sort"`
Enabled bool `gorm:"column:enabled;index:idx_wheel_reward_enabled,priority:3"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the wheel reward configuration table name.
func (WheelRewardConfig) TableName() string { return "wheel_reward_config" }
// WheelDrawRecord stores each reward item produced by a wheel draw.
type WheelDrawRecord struct {
ID int64 `gorm:"column:id;primaryKey"`
DrawNo string `gorm:"column:draw_no;size:64;index:idx_wheel_draw_record_draw_no"`
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_wheel_draw_record_event"`
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_wheel_draw_record_user_time,priority:1"`
Category string `gorm:"column:category;size:32;index:idx_wheel_draw_record_category"`
UserID int64 `gorm:"column:user_id;index:idx_wheel_draw_record_user_time,priority:2"`
DrawTimes int `gorm:"column:draw_times"`
PaidGold int64 `gorm:"column:paid_gold"`
RewardConfigID int64 `gorm:"column:reward_config_id;index:idx_wheel_draw_reward_user,priority:1"`
RewardType string `gorm:"column:reward_type;size:32"`
ResourceID *int64 `gorm:"column:resource_id"`
ResourceType string `gorm:"column:resource_type;size:64"`
ResourceName string `gorm:"column:resource_name;size:255"`
ResourceURL string `gorm:"column:resource_url;size:512"`
CoverURL string `gorm:"column:cover_url;size:512"`
DurationDays int `gorm:"column:duration_days"`
DisplayGoldAmount int64 `gorm:"column:display_gold_amount"`
GoldAmount int64 `gorm:"column:gold_amount"`
Probability int `gorm:"column:probability"`
Status string `gorm:"column:status;size:32;index:idx_wheel_draw_record_status"`
RetryCount int `gorm:"column:retry_count"`
ErrorMessage string `gorm:"column:error_message;size:1024"`
GrantTime *time.Time `gorm:"column:grant_time"`
CreateTime time.Time `gorm:"column:create_time;index:idx_wheel_draw_record_user_time,priority:3"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the wheel draw record table name.
func (WheelDrawRecord) TableName() string { return "wheel_draw_record" }

View File

@ -0,0 +1,81 @@
package router
import (
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"chatapp3-golang/internal/service/errorlog"
"github.com/gin-gonic/gin"
)
// registerAgoraErrorLogRoutes registers client report and admin page APIs for Agora errors.
func registerAgoraErrorLogRoutes(engine *gin.Engine, javaClient authGateway, service *errorlog.Service) {
if service == nil {
return
}
appGroup := engine.Group("/app/agora")
appGroup.Use(authMiddleware(javaClient))
appGroup.POST("/error-log", func(c *gin.Context) {
raw, err := io.ReadAll(c.Request.Body)
if err != nil {
writeError(c, errorlog.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
var req errorlog.AgoraErrorReportRequest
if len(strings.TrimSpace(string(raw))) > 0 {
if err := json.Unmarshal(raw, &req); err != nil {
writeError(c, errorlog.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
}
resp, err := service.ReportAgoraError(
c.Request.Context(),
mustAuthUser(c),
req,
errorlog.AgoraErrorReportContext{
ReqClient: c.GetHeader("Req-Client"),
ReqAppIntel: c.GetHeader("Req-App-Intel"),
ClientIP: resolveClientIP(c),
UserAgent: c.GetHeader("User-Agent"),
RawPayload: string(raw),
},
)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
adminGroup := engine.Group("/app-system/error-logs/agora")
adminGroup.Use(consoleAuthMiddleware(javaClient))
adminGroup.GET("/page", func(c *gin.Context) {
cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1")))
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
bannedOnly, _ := strconv.ParseBool(strings.TrimSpace(c.DefaultQuery("bannedOnly", "false")))
resp, err := service.PageAgoraErrorLogs(c.Request.Context(), errorlog.AgoraErrorLogQuery{
SysOrigin: c.Query("sysOrigin"),
UserID: parseInt64(c.Query("userId")),
RoomID: c.Query("roomId"),
AgoraUID: c.Query("agoraUid"),
ErrorCodeType: c.Query("errorCodeType"),
ReasonType: c.Query("reasonType"),
BannedOnly: bannedOnly,
NetworkType: c.Query("networkType"),
StartTime: c.Query("startTime"),
EndTime: c.Query("endTime"),
Cursor: cursor,
Limit: limit,
})
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
}

View File

@ -0,0 +1,77 @@
package router
import (
"errors"
"io"
"net/http"
"strconv"
"strings"
"chatapp3-golang/internal/service/apppopup"
"github.com/gin-gonic/gin"
)
// registerAppPopupRoutes 注册 APP 进场弹窗接口。
func registerAppPopupRoutes(engine *gin.Engine, javaClient authGateway, service *apppopup.Service) {
if service == nil {
return
}
appGroup := engine.Group("/app/popups")
appGroup.Use(authMiddleware(javaClient))
appGroup.POST("/entry", func(c *gin.Context) {
var req apppopup.EntryQueryRequest
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
writeError(c, apppopup.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.QueryEntryPopups(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
adminGroup := engine.Group("/app-system/home-popups")
adminGroup.Use(consoleAuthMiddleware(javaClient))
adminGroup.GET("/configs", func(c *gin.Context) {
cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1")))
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
resp, err := service.PageAdminConfigs(
c.Request.Context(),
c.Query("sysOrigin"),
c.Query("scene"),
c.Query("popupKey"),
cursor,
limit,
)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
adminGroup.POST("/configs/save", func(c *gin.Context) {
var req apppopup.SaveAdminConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, apppopup.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.SaveAdminConfig(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
adminGroup.DELETE("/configs/:id", func(c *gin.Context) {
id, _ := strconv.ParseInt(strings.TrimSpace(c.Param("id")), 10, 64)
if err := service.DeleteAdminConfig(c.Request.Context(), id); err != nil {
writeError(c, err)
return
}
writeOK(c, gin.H{"deleted": true})
})
}

View File

@ -0,0 +1,183 @@
package router
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/repo"
"chatapp3-golang/internal/service/apppopup"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type appPopupAuthStub struct{}
func (appPopupAuthStub) AuthenticateToken(context.Context, string) (integration.UserCredential, error) {
return integration.UserCredential{UserID: 123, SysOrigin: "LIKEI"}, nil
}
func (appPopupAuthStub) AuthenticateConsoleToken(context.Context, string) (integration.ConsoleAccount, error) {
return integration.ConsoleAccount{}, nil
}
func TestAppPopupEntryRoute(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newRouterAppPopupDB(t)
if err := db.Create(&model.AppPopupConfig{
ID: 1001,
SysOrigin: "LIKEI",
PopupKey: "invite",
Scene: "APP_ENTER",
Enabled: true,
LimitDays: 7,
Priority: 100,
Version: 1,
ContentJSON: `{"title":"Invite friends","image":"https://cdn.example.com/invite.png"}`,
JumpType: "H5",
JumpURL: "/h5/app-invite/landing.html",
}).Error; err != nil {
t.Fatalf("create config: %v", err)
}
service := apppopup.NewService(config.Config{}, db, nil)
engine := NewRouter(config.Config{}, &repo.Repository{}, appPopupAuthStub{}, Services{AppPopup: service})
body := bytes.NewBufferString(`{"scene":"APP_ENTER","platform":"ios","appVersion":"3.12.0"}`)
req := httptest.NewRequest(http.MethodPost, "/app/popups/entry", body)
req.Header.Set("Authorization", "Bearer token")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
data, ok := payload["data"].(map[string]any)
if !ok {
t.Fatalf("data missing: %#v", payload)
}
queue, ok := data["queue"].([]any)
if !ok || len(queue) != 1 || queue[0] != "invite" {
t.Fatalf("queue = %#v, want [invite]", data["queue"])
}
invite, ok := data["invite"].(map[string]any)
if !ok {
t.Fatalf("invite missing: %#v", data)
}
if invite["value"] != true || invite["limit"] != float64(7) {
t.Fatalf("invite = %#v, want value true limit 7", invite)
}
}
func TestAppPopupEntryRouteRequiresAuth(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newRouterAppPopupDB(t)
service := apppopup.NewService(config.Config{}, db, nil)
engine := NewRouter(config.Config{}, &repo.Repository{}, appPopupAuthStub{}, Services{AppPopup: service})
req := httptest.NewRequest(http.MethodPost, "/app/popups/entry", bytes.NewBufferString(`{}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d body = %s, want 401", rec.Code, rec.Body.String())
}
}
func TestAppPopupEntryRouteAllowsEmptyBody(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newRouterAppPopupDB(t)
if err := db.Create(&model.AppPopupConfig{
ID: 1001,
SysOrigin: "LIKEI",
PopupKey: "invite",
Scene: "APP_ENTER",
Enabled: true,
LimitDays: 7,
Priority: 100,
Version: 1,
UserScopeType: "ALL",
}).Error; err != nil {
t.Fatalf("create config: %v", err)
}
service := apppopup.NewService(config.Config{}, db, nil)
engine := NewRouter(config.Config{}, &repo.Repository{}, appPopupAuthStub{}, Services{AppPopup: service})
req := httptest.NewRequest(http.MethodPost, "/app/popups/entry", nil)
req.Header.Set("Authorization", "Bearer token")
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %s, want 200", rec.Code, rec.Body.String())
}
}
func TestAppPopupAdminRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newRouterAppPopupDB(t)
service := apppopup.NewService(config.Config{}, db, nil)
engine := NewRouter(config.Config{}, &repo.Repository{}, appPopupAuthStub{}, Services{AppPopup: service})
saveBody := bytes.NewBufferString(`{"sysOrigin":"LIKEI","popupKey":"invite","enabled":true,"limitDays":7,"description":"邀请弹窗"}`)
saveReq := httptest.NewRequest(http.MethodPost, "/app-system/home-popups/configs/save", saveBody)
saveReq.Header.Set("Authorization", "Bearer console-token")
saveReq.Header.Set("Content-Type", "application/json")
saveRec := httptest.NewRecorder()
engine.ServeHTTP(saveRec, saveReq)
if saveRec.Code != http.StatusOK {
t.Fatalf("save status = %d body = %s", saveRec.Code, saveRec.Body.String())
}
listReq := httptest.NewRequest(http.MethodGet, "/app-system/home-popups/configs?sysOrigin=LIKEI", nil)
listReq.Header.Set("Authorization", "Bearer console-token")
listRec := httptest.NewRecorder()
engine.ServeHTTP(listRec, listReq)
if listRec.Code != http.StatusOK {
t.Fatalf("list status = %d body = %s", listRec.Code, listRec.Body.String())
}
var listPayload map[string]any
if err := json.Unmarshal(listRec.Body.Bytes(), &listPayload); err != nil {
t.Fatalf("decode list response: %v", err)
}
data := listPayload["data"].(map[string]any)
records := data["records"].([]any)
if len(records) != 1 {
t.Fatalf("records = %#v, want one", records)
}
record := records[0].(map[string]any)
id := record["id"].(string)
deleteReq := httptest.NewRequest(http.MethodDelete, "/app-system/home-popups/configs/"+id, nil)
deleteReq.Header.Set("Authorization", "Bearer console-token")
deleteRec := httptest.NewRecorder()
engine.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusOK {
t.Fatalf("delete status = %d body = %s", deleteRec.Code, deleteRec.Body.String())
}
}
func newRouterAppPopupDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.AppPopupConfig{}); err != nil {
t.Fatalf("migrate app popup config: %v", err)
}
return db
}

View File

@ -96,7 +96,7 @@ func registerBaishunRoutes(
consoleGroup := engine.Group("/operate/baishun-game")
consoleGroup.Use(consoleAuthMiddleware(javaClient))
consoleGroup.GET("/config", func(c *gin.Context) {
resp, err := baishunService.GetProviderConfig(c.Request.Context(), c.Query("sysOrigin"))
resp, err := baishunService.GetProviderConfig(c.Request.Context(), c.Query("sysOrigin"), c.Query("profile"))
if err != nil {
writeError(c, err)
return
@ -116,12 +116,26 @@ func registerBaishunRoutes(
}
writeOK(c, resp)
})
consoleGroup.POST("/config/activate", func(c *gin.Context) {
var req baishun.ActivateBaishunProviderProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, baishun.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := baishunService.ActivateProviderProfile(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
consoleGroup.GET("/page", func(c *gin.Context) {
cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1")))
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
resp, err := baishunService.PageAdminGames(
c.Request.Context(),
c.Query("sysOrigin"),
c.Query("profile"),
c.Query("keyword"),
parseOptionalBool(c.Query("showcase")),
cursor,
@ -139,6 +153,7 @@ func registerBaishunRoutes(
resp, err := baishunService.PageCatalog(
c.Request.Context(),
c.Query("sysOrigin"),
c.Query("profile"),
c.Query("keyword"),
cursor,
limit,
@ -164,7 +179,7 @@ func registerBaishunRoutes(
})
consoleGroup.DELETE("", func(c *gin.Context) {
id, _ := strconv.ParseInt(strings.TrimSpace(c.Query("id")), 10, 64)
if err := baishunService.DeleteAdminGame(c.Request.Context(), c.Query("sysOrigin"), id); err != nil {
if err := baishunService.DeleteAdminGame(c.Request.Context(), c.Query("sysOrigin"), c.Query("profile"), id); err != nil {
writeError(c, err)
return
}
@ -189,7 +204,11 @@ func registerBaishunRoutes(
writeError(c, baishun.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := baishunService.ImportCatalogGames(c.Request.Context(), req.SysOrigin, req.VendorGameIDs)
defaultShowcase := true
if req.DefaultShowcase != nil {
defaultShowcase = *req.DefaultShowcase
}
resp, err := baishunService.ImportCatalogGames(c.Request.Context(), req.SysOrigin, req.Profile, req.VendorGameIDs, defaultShowcase)
if err != nil {
writeError(c, err)
return
@ -229,7 +248,7 @@ func registerBaishunRoutes(
})
callbackGroup.POST("/report", func(c *gin.Context) {
raw, payload := readRawPayload(c)
writeBaishunJSON(c, http.StatusOK, baishunService.HandleReport(c.Request.Context(), payload, raw))
writeBaishunJSON(c, http.StatusOK, baishunService.HandleReport(c.Request.Context(), mapToReportRequest(payload), raw))
})
callbackGroup.POST("/balance-info", func(c *gin.Context) {
raw, payload := readRawPayload(c)
@ -319,6 +338,16 @@ func mapToChangeBalanceRequest(payload map[string]any) baishun.BaishunChangeBala
}
}
// mapToReportRequest 把百顺 report 回调报文映射成服务层 DTO。
func mapToReportRequest(payload map[string]any) baishun.BaishunReportRequest {
return baishun.BaishunReportRequest{
ReportType: asString(payload["report_type"]),
ReportMsg: asMap(payload["report_msg"]),
UserID: asString(payload["user_id"]),
SSToken: asString(payload["ss_token"]),
}
}
// mapToBalanceInfoRequest 把百顺 balance-info 回调报文映射成服务层 DTO。
func mapToBalanceInfoRequest(payload map[string]any) baishun.BaishunBalanceInfoRequest {
return baishun.BaishunBalanceInfoRequest{
@ -331,6 +360,26 @@ func mapToBalanceInfoRequest(payload map[string]any) baishun.BaishunBalanceInfoR
}
}
// asMap 宽松读取对象字段。
func asMap(value any) map[string]any {
switch typed := value.(type) {
case map[string]any:
return typed
case string:
result := map[string]any{}
_ = json.Unmarshal([]byte(typed), &result)
return result
default:
body, err := json.Marshal(typed)
if err != nil {
return map[string]any{}
}
result := map[string]any{}
_ = json.Unmarshal(body, &result)
return result
}
}
// asString 宽松读取字符串字段。
func asString(value any) string {
if value == nil {

View File

@ -0,0 +1,56 @@
package router
import (
"net/http"
"chatapp3-golang/internal/service/binancerecharge"
"github.com/gin-gonic/gin"
)
// registerBinanceRechargeRoutes registers H5 verification and admin config routes.
func registerBinanceRechargeRoutes(engine *gin.Engine, javaClient authGateway, service *binancerecharge.Service) {
if service == nil {
return
}
appGroup := engine.Group("/app/h5/binance-recharge")
appGroup.Use(authMiddleware(javaClient))
appGroup.POST("/verify", func(c *gin.Context) {
var req binancerecharge.VerifyRechargeRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, binancerecharge.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.VerifyAndRecharge(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
adminGroup := engine.Group("/payment/binance")
adminGroup.Use(consoleAuthMiddleware(javaClient))
adminGroup.GET("/config", func(c *gin.Context) {
resp, err := service.GetConfig(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 binancerecharge.SaveConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, binancerecharge.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)
})
}

View File

@ -0,0 +1,101 @@
package router
import (
"net/http"
"strings"
"chatapp3-golang/internal/service/firstrechargereward"
"github.com/gin-gonic/gin"
)
// registerFirstRechargeRewardRoutes registers app and admin first recharge reward APIs.
func registerFirstRechargeRewardRoutes(engine *gin.Engine, javaClient authGateway, service *firstrechargereward.Service) {
if service == nil {
return
}
registerFirstRechargeRewardAppGroup(engine.Group("/app/first-recharge-reward"), javaClient, service)
registerFirstRechargeRewardAppGroup(engine.Group("/app/h5/first-recharge-reward"), javaClient, service)
group := engine.Group("/resident-activity/first-recharge-reward")
group.Use(consoleAuthMiddleware(javaClient))
group.GET("/config", func(c *gin.Context) {
resp, err := service.GetConfig(c.Request.Context(), c.Query("sysOrigin"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.POST("/config/save", func(c *gin.Context) {
var req firstrechargereward.SaveConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, firstrechargereward.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)
})
group.GET("/grant-record/page", func(c *gin.Context) {
resp, err := service.PageGrantRecords(
c.Request.Context(),
c.Query("sysOrigin"),
c.Query("status"),
firstrechargereward.ParseInt64(c.Query("userId")),
int(firstrechargereward.ParseInt64(c.Query("cursor"))),
int(firstrechargereward.ParseInt64(c.Query("limit"))),
)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
}
func registerFirstRechargeRewardAppGroup(group *gin.RouterGroup, javaClient authGateway, service *firstrechargereward.Service) {
group.Use(authMiddleware(javaClient))
group.GET("/home", func(c *gin.Context) {
resp, err := service.GetHome(c.Request.Context(), mustAuthUser(c), firstRechargeRewardRequestVersion(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
}
func firstRechargeRewardRequestVersion(c *gin.Context) string {
return firstNonEmptyString(
c.Query("appVersion"),
c.Query("version"),
c.GetHeader("req-version"),
c.GetHeader("Req-Version"),
versionFromReqAppIntel(c.GetHeader("req-app-intel")),
versionFromReqAppIntel(c.GetHeader("Req-App-Intel")),
)
}
func versionFromReqAppIntel(value string) string {
for _, item := range strings.Split(value, ";") {
parts := strings.SplitN(item, "=", 2)
if len(parts) == 2 && strings.EqualFold(strings.TrimSpace(parts[0]), "version") {
return strings.TrimSpace(parts[1])
}
}
return ""
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if text := strings.TrimSpace(value); text != "" {
return text
}
}
return ""
}

View File

@ -46,6 +46,8 @@ func registerGameOpenRoutes(engine *gin.Engine, cfg config.Config, service *game
c.JSON(http.StatusOK, service.HandleSupplement(c.Request.Context(), req, string(raw)))
})
registerHotgameCallbackRoutes(engine, service)
internalGroup := engine.Group("/internal/game/open")
internalGroup.Use(internalSecretMiddleware(cfg.HTTP.InternalCallbackSecret))
internalGroup.GET("/info", func(c *gin.Context) {
@ -58,6 +60,34 @@ func registerGameOpenRoutes(engine *gin.Engine, cfg config.Config, service *game
})
}
func registerHotgameCallbackRoutes(engine *gin.Engine, service *gameopen.GameOpenService) {
handleUserInfo := func(c *gin.Context) {
raw, _ := c.GetRawData()
var req gameopen.HotgameGetUserInfoRequest
if err := json.Unmarshal(raw, &req); err != nil {
c.JSON(http.StatusOK, gameopen.CallbackResponse{ErrorCode: 4001, ErrorMsg: err.Error()})
return
}
c.JSON(http.StatusOK, service.HandleHotgameGetUserInfo(c.Request.Context(), req, string(raw)))
}
handleUpdateBalance := func(c *gin.Context) {
raw, _ := c.GetRawData()
var req gameopen.HotgameUpdateBalanceRequest
if err := json.Unmarshal(raw, &req); err != nil {
c.JSON(http.StatusOK, gameopen.CallbackResponse{ErrorCode: 4001, ErrorMsg: err.Error()})
return
}
c.JSON(http.StatusOK, service.HandleHotgameUpdateBalance(c.Request.Context(), req, string(raw)))
}
// 热游文档要求平台直接提供 /getUserInfo 和 /updateBalance同时保留命名空间路径便于网关灰度。
engine.POST("/getUserInfo", handleUserInfo)
engine.POST("/updateBalance", handleUpdateBalance)
hotgameGroup := engine.Group("/game/hotgame")
hotgameGroup.POST("/getUserInfo", handleUserInfo)
hotgameGroup.POST("/updateBalance", handleUpdateBalance)
}
func resolvePublicBaseURL(c *gin.Context, cfg config.Config) string {
if value := strings.TrimRight(strings.TrimSpace(cfg.GameOpen.PublicBaseURL), "/"); value != "" {
return value

View File

@ -1,7 +1,9 @@
package router
import (
"errors"
"net/http"
"strings"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/service/gameprovider"
@ -10,7 +12,7 @@ import (
)
// registerGameProviderRoutes 注册统一游戏厂商 app 路由。
func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, registry *gameprovider.Registry) {
func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, registry *gameprovider.Registry, visibility *gameprovider.VisibilityPolicy) {
if registry == nil {
return
}
@ -18,20 +20,36 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
appGroup := engine.Group("/app/game")
appGroup.Use(authMiddleware(javaClient))
appGroup.GET("/room/shortcut", func(c *gin.Context) {
resp, err := registry.ListShortcutGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"))
user, visible, ok := gameListAccess(c, visibility)
if !ok {
return
}
if !visible {
writeOK(c, []publicRoomGameItem{})
return
}
resp, err := registry.ListShortcutGames(c.Request.Context(), user, c.Query("roomId"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, stripProvidersFromRoomGameItems(resp))
writeOK(c, publicRoomGameItems(resp))
})
appGroup.GET("/room/list", func(c *gin.Context) {
resp, err := registry.ListRoomGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"), c.Query("category"))
user, visible, ok := gameListAccess(c, visibility)
if !ok {
return
}
if !visible {
writeOK(c, publicRoomGameListResponse(&gameprovider.RoomGameListResponse{Items: []gameprovider.RoomGameListItem{}}))
return
}
resp, err := registry.ListRoomGames(c.Request.Context(), user, c.Query("roomId"), c.Query("category"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, stripProvidersFromRoomGameListResponse(resp))
writeOK(c, publicRoomGameListResponse(resp))
})
appGroup.GET("/state", func(c *gin.Context) {
resp, err := registry.GetRoomState(c.Request.Context(), mustAuthUser(c), c.Query("roomId"))
@ -39,7 +57,7 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
writeError(c, err)
return
}
writeOK(c, stripProviderFromRoomState(resp))
writeOK(c, publicRoomState(resp))
})
appGroup.POST("/launch", func(c *gin.Context) {
var req gameprovider.LaunchRequest
@ -52,7 +70,7 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
writeError(c, err)
return
}
writeOK(c, stripProviderFromLaunch(resp))
writeOK(c, publicLaunch(resp))
})
appGroup.POST("/close", func(c *gin.Context) {
var req gameprovider.CloseRequest
@ -65,37 +83,61 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
writeError(c, err)
return
}
writeOK(c, stripProviderFromRoomState(resp))
writeOK(c, publicRoomState(resp))
})
providerGroup := engine.Group("/app/game/providers")
providerGroup.Use(authMiddleware(javaClient))
providerGroup.GET("", func(c *gin.Context) {
_, visible, ok := gameListAccess(c, visibility)
if !ok {
return
}
if !visible {
writeOK(c, gameprovider.ProviderListResponse{Items: []gameprovider.ProviderSummary{}})
return
}
writeOK(c, registry.List())
})
providerGroup.GET("/:provider/room/shortcut", func(c *gin.Context) {
user, visible, ok := gameListAccess(c, visibility)
if !ok {
return
}
if !visible {
writeOK(c, gin.H{"items": []publicRoomGameItem{}})
return
}
provider, ok := resolveGameProvider(c, registry)
if !ok {
return
}
resp, err := provider.ListShortcutGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"))
resp, err := provider.ListShortcutGames(c.Request.Context(), user, c.Query("roomId"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, gin.H{"items": resp})
writeOK(c, gin.H{"items": publicRoomGameItems(resp)})
})
providerGroup.GET("/:provider/room/list", func(c *gin.Context) {
user, visible, ok := gameListAccess(c, visibility)
if !ok {
return
}
if !visible {
writeOK(c, publicRoomGameListResponse(&gameprovider.RoomGameListResponse{Items: []gameprovider.RoomGameListItem{}}))
return
}
provider, ok := resolveGameProvider(c, registry)
if !ok {
return
}
resp, err := provider.ListRoomGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"), c.Query("category"))
resp, err := provider.ListRoomGames(c.Request.Context(), user, c.Query("roomId"), c.Query("category"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
writeOK(c, publicRoomGameListResponse(resp))
})
providerGroup.GET("/:provider/state", func(c *gin.Context) {
provider, ok := resolveGameProvider(c, registry)
@ -107,7 +149,7 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
writeError(c, err)
return
}
writeOK(c, resp)
writeOK(c, publicRoomState(resp))
})
providerGroup.POST("/:provider/launch", func(c *gin.Context) {
provider, ok := resolveGameProvider(c, registry)
@ -119,12 +161,12 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := provider.LaunchGame(c.Request.Context(), mustAuthUser(c), req, resolveClientIP(c))
resp, err := launchProviderGame(c, registry, provider, req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
writeOK(c, publicLaunch(resp))
})
providerGroup.POST("/:provider/close", func(c *gin.Context) {
provider, ok := resolveGameProvider(c, registry)
@ -141,10 +183,37 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
writeError(c, err)
return
}
writeOK(c, resp)
writeOK(c, publicRoomState(resp))
})
}
func gameListAccess(c *gin.Context, visibility *gameprovider.VisibilityPolicy) (common.AuthUser, bool, bool) {
user := mustAuthUser(c)
// iOS 审核和上架场景需要隐藏游戏入口;这里放在统一列表门禁最前面,避免继续请求 Java 区域/等级接口或 provider SQL。
if isIOSGameListClient(c) {
return user, false, true
}
if gameprovider.IsEmptyGameListUser(user.UserID) {
return user, false, true
}
if visibility == nil {
return user, true, true
}
// 游戏列表只在列表入口做展示门禁,结果里的区域继续向下传给 provider SQL 做 regions 过滤。
result, err := visibility.Evaluate(c.Request.Context(), user, resolveClientIP(c))
if err != nil {
writeError(c, err)
return user, false, false
}
user.RegionID = result.RegionID
user.RegionCode = result.RegionCode
return user, result.Allow, true
}
func isIOSGameListClient(c *gin.Context) bool {
return strings.EqualFold(strings.TrimSpace(c.GetHeader("Req-Client")), "ios")
}
func resolveGameProvider(c *gin.Context, registry *gameprovider.Registry) (gameprovider.Provider, bool) {
provider, err := registry.Resolve(c.Param("provider"))
if err != nil {
@ -154,42 +223,189 @@ func resolveGameProvider(c *gin.Context, registry *gameprovider.Registry) (gamep
return provider, true
}
func stripProvidersFromRoomGameItems(items []gameprovider.RoomGameListItem) []gameprovider.RoomGameListItem {
if len(items) == 0 {
return []gameprovider.RoomGameListItem{}
func launchProviderGame(c *gin.Context, registry *gameprovider.Registry, provider gameprovider.Provider, req gameprovider.LaunchRequest) (*gameprovider.LaunchResponse, error) {
user := mustAuthUser(c)
clientIP := resolveClientIP(c)
resp, err := provider.LaunchGame(c.Request.Context(), user, req, clientIP)
if err == nil {
return resp, nil
}
result := make([]gameprovider.RoomGameListItem, 0, len(items))
if !isGameNotFoundError(err) {
return nil, err
}
// 旧客户端只能把热游列表项伪装成 LEADER 进入已有 WebView这里再按 gameId 回到真实 provider避免 LEADER 路由找不到 hg_*。
return registry.LaunchGame(c.Request.Context(), user, req, clientIP)
}
func isGameNotFoundError(err error) bool {
var appErr *common.AppError
return errors.As(err, &appErr) && appErr.Code == "game_not_found"
}
type publicRoomGameItem struct {
ID int64 `json:"id"`
GameID string `json:"gameId"`
GameType string `json:"gameType,omitempty"`
Provider string `json:"provider,omitempty"`
VendorType string `json:"vendorType,omitempty"`
ProviderGameID string `json:"providerGameId,omitempty"`
VendorGameID string `json:"vendorGameId,omitempty"`
Name string `json:"name"`
Cover string `json:"cover"`
Category string `json:"category,omitempty"`
Sort int64 `json:"sort,omitempty"`
LaunchMode string `json:"launchMode,omitempty"`
FullScreen bool `json:"fullScreen"`
GameMode int `json:"gameMode,omitempty"`
SafeHeight int `json:"safeHeight,omitempty"`
Orientation int `json:"orientation,omitempty"`
PackageVersion string `json:"packageVersion,omitempty"`
Status string `json:"status,omitempty"`
LaunchParams map[string]any `json:"launchParams,omitempty"`
}
type publicRoomGameList struct {
Items []publicRoomGameItem `json:"items"`
}
type publicRoomStateResponse struct {
RoomID string `json:"roomId"`
State string `json:"state"`
Provider string `json:"provider,omitempty"`
VendorType string `json:"vendorType,omitempty"`
GameSessionID string `json:"gameSessionId,omitempty"`
CurrentGameID string `json:"currentGameId,omitempty"`
CurrentProviderGameID string `json:"currentProviderGameId,omitempty"`
CurrentVendorGameID string `json:"currentVendorGameId,omitempty"`
CurrentGameName string `json:"currentGameName,omitempty"`
CurrentGameCover string `json:"currentGameCover,omitempty"`
HostUserID int64 `json:"hostUserId,omitempty"`
}
type publicLaunchResponse struct {
ID int64 `json:"id,omitempty"`
GameSessionID string `json:"gameSessionId"`
Provider string `json:"provider,omitempty"`
VendorType string `json:"vendorType,omitempty"`
GameID string `json:"gameId"`
ProviderGameID string `json:"providerGameId,omitempty"`
VendorGameID string `json:"vendorGameId,omitempty"`
Entry gameprovider.LaunchEntry `json:"entry"`
LaunchConfig any `json:"launchConfig,omitempty"`
BridgeConfig any `json:"bridgeConfig,omitempty"`
RoomState publicRoomStateResponse `json:"roomState"`
}
func publicRoomGameItems(items []gameprovider.RoomGameListItem) []publicRoomGameItem {
if len(items) == 0 {
return []publicRoomGameItem{}
}
result := make([]publicRoomGameItem, 0, len(items))
for _, item := range items {
item.Provider = ""
result = append(result, item)
provider := appCompatProviderName(item.Provider)
gameType := appCompatProviderName(item.GameType)
result = append(result, publicRoomGameItem{
ID: item.ID,
GameID: item.GameID,
GameType: gameType,
Provider: provider,
VendorType: provider,
ProviderGameID: item.ProviderGameID,
VendorGameID: item.ProviderGameID,
Name: item.Name,
Cover: item.Cover,
Category: item.Category,
Sort: item.Sort,
LaunchMode: item.LaunchMode,
FullScreen: item.FullScreen,
GameMode: item.GameMode,
SafeHeight: item.SafeHeight,
Orientation: item.Orientation,
PackageVersion: item.PackageVersion,
Status: item.Status,
LaunchParams: publicLaunchParams(item.LaunchParams),
})
}
return result
}
func stripProvidersFromRoomGameListResponse(resp *gameprovider.RoomGameListResponse) *gameprovider.RoomGameListResponse {
if resp == nil {
return &gameprovider.RoomGameListResponse{Items: []gameprovider.RoomGameListItem{}}
func publicLaunchParams(params map[string]any) map[string]any {
if len(params) == 0 {
return nil
}
return &gameprovider.RoomGameListResponse{
Items: stripProvidersFromRoomGameItems(resp.Items),
result := make(map[string]any, len(params))
for key, value := range params {
if strings.EqualFold(strings.TrimSpace(key), "gameType") {
if text, ok := value.(string); ok {
result[key] = appCompatProviderName(text)
continue
}
}
result[key] = value
}
return result
}
func publicRoomGameListResponse(resp *gameprovider.RoomGameListResponse) *publicRoomGameList {
if resp == nil {
return &publicRoomGameList{Items: []publicRoomGameItem{}}
}
return &publicRoomGameList{
Items: publicRoomGameItems(resp.Items),
}
}
func stripProviderFromRoomState(resp *gameprovider.RoomStateResponse) *gameprovider.RoomStateResponse {
func publicRoomState(resp *gameprovider.RoomStateResponse) *publicRoomStateResponse {
if resp == nil {
return nil
}
copied := *resp
copied.Provider = ""
return &copied
return &publicRoomStateResponse{
RoomID: resp.RoomID,
State: resp.State,
Provider: appCompatProviderName(resp.Provider),
VendorType: appCompatProviderName(resp.Provider),
GameSessionID: resp.GameSessionID,
CurrentGameID: resp.CurrentGameID,
CurrentProviderGameID: resp.CurrentProviderGameID,
CurrentVendorGameID: resp.CurrentProviderGameID,
CurrentGameName: resp.CurrentGameName,
CurrentGameCover: resp.CurrentGameCover,
HostUserID: resp.HostUserID,
}
}
func stripProviderFromLaunch(resp *gameprovider.LaunchResponse) *gameprovider.LaunchResponse {
func publicLaunch(resp *gameprovider.LaunchResponse) *publicLaunchResponse {
if resp == nil {
return nil
}
copied := *resp
copied.Provider = ""
copied.RoomState = *stripProviderFromRoomState(&resp.RoomState)
return &copied
roomState := publicRoomState(&resp.RoomState)
if roomState == nil {
roomState = &publicRoomStateResponse{}
}
return &publicLaunchResponse{
ID: resp.ID,
GameSessionID: resp.GameSessionID,
Provider: appCompatProviderName(resp.Provider),
VendorType: appCompatProviderName(resp.Provider),
GameID: resp.GameID,
ProviderGameID: resp.ProviderGameID,
VendorGameID: resp.ProviderGameID,
Entry: resp.Entry,
LaunchConfig: resp.LaunchConfig,
BridgeConfig: resp.LaunchConfig,
RoomState: *roomState,
}
}
func appCompatProviderName(provider string) string {
// 客户端历史上只认识 LEADER不认识内部厂商名 LINGXIAN所以出参继续保持旧字段值。
if strings.EqualFold(strings.TrimSpace(provider), "LINGXIAN") {
return "LEADER"
}
// 旧客户端没有 HOTGAME 页面;热游先按 LEADER 展示并进入已有 WebViewlaunch 路由再按 hg_* 分发回真实热游 provider。
switch strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(provider), "_", "")) {
case "HOTGAME", "REYOU", "LALU":
return "LEADER"
}
return provider
}

View File

@ -0,0 +1,533 @@
package router
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/service/gameprovider"
"github.com/gin-gonic/gin"
)
func TestPublicRoomGameListResponseKeepsLegacyProviderFields(t *testing.T) {
resp := publicRoomGameListResponse(&gameprovider.RoomGameListResponse{
Items: []gameprovider.RoomGameListItem{
{
ID: 9527,
GameID: "bs_1046",
GameType: "BAISHUN",
Provider: "BAISHUN",
ProviderGameID: "1046",
Name: "Lucky77Pro",
},
},
})
if len(resp.Items) != 1 {
t.Fatalf("items = %d, want 1", len(resp.Items))
}
item := resp.Items[0]
if item.Provider != "BAISHUN" {
t.Fatalf("provider = %q, want BAISHUN", item.Provider)
}
if item.VendorType != "BAISHUN" {
t.Fatalf("vendorType = %q, want BAISHUN", item.VendorType)
}
if item.ProviderGameID != "1046" {
t.Fatalf("providerGameId = %q, want 1046", item.ProviderGameID)
}
if item.VendorGameID != "1046" {
t.Fatalf("vendorGameId = %q, want 1046", item.VendorGameID)
}
}
func TestPublicRoomGameListResponseMapsLingxianToLeaderForOldClients(t *testing.T) {
resp := publicRoomGameListResponse(&gameprovider.RoomGameListResponse{
Items: []gameprovider.RoomGameListItem{
{
ID: 9528,
GameID: "1090",
GameType: "LINGXIAN",
Provider: "LINGXIAN",
ProviderGameID: "1090",
Name: "Lingxian Game",
LaunchParams: map[string]any{"gameType": "LINGXIAN", "screenMode": "half"},
},
},
})
if len(resp.Items) != 1 {
t.Fatalf("items = %d, want 1", len(resp.Items))
}
item := resp.Items[0]
if item.Provider != "LEADER" {
t.Fatalf("provider = %q, want LEADER", item.Provider)
}
if item.GameType != "LEADER" {
t.Fatalf("gameType = %q, want LEADER", item.GameType)
}
if item.VendorType != "LEADER" {
t.Fatalf("vendorType = %q, want LEADER", item.VendorType)
}
if got := item.LaunchParams["gameType"]; got != "LEADER" {
t.Fatalf("launchParams.gameType = %v, want LEADER", got)
}
if got := item.LaunchParams["screenMode"]; got != "half" {
t.Fatalf("launchParams.screenMode = %v, want half", got)
}
}
func TestPublicRoomGameListResponseMapsHotgameAliasesToLeaderForOldClients(t *testing.T) {
for _, alias := range []string{"HOTGAME", "HOT_GAME", "REYOU", "LALU"} {
resp := publicRoomGameListResponse(&gameprovider.RoomGameListResponse{
Items: []gameprovider.RoomGameListItem{
{
ID: 9530,
GameID: "hg_1",
GameType: alias,
Provider: alias,
ProviderGameID: "1",
Name: "Hotgame",
LaunchParams: map[string]any{"gameType": alias, "screenMode": "seven"},
},
},
})
if len(resp.Items) != 1 {
t.Fatalf("%s items = %d, want 1", alias, len(resp.Items))
}
item := resp.Items[0]
if item.Provider != "LEADER" {
t.Fatalf("%s provider = %q, want LEADER", alias, item.Provider)
}
if item.GameType != "LEADER" {
t.Fatalf("%s gameType = %q, want LEADER", alias, item.GameType)
}
if item.VendorType != "LEADER" {
t.Fatalf("%s vendorType = %q, want LEADER", alias, item.VendorType)
}
if got := item.LaunchParams["gameType"]; got != "LEADER" {
t.Fatalf("%s launchParams.gameType = %v, want LEADER", alias, got)
}
}
}
func TestPublicLaunchKeepsLegacyBridgeAndProviderFields(t *testing.T) {
launchConfig := map[string]any{"code": "launch-code"}
resp := publicLaunch(&gameprovider.LaunchResponse{
ID: 9527,
GameSessionID: "bs_room_1",
Provider: "BAISHUN",
GameID: "bs_1046",
ProviderGameID: "1046",
LaunchConfig: launchConfig,
RoomState: gameprovider.RoomStateResponse{
RoomID: "room-1",
State: "PLAYING",
Provider: "BAISHUN",
CurrentProviderGameID: "1046",
},
})
if resp.Provider != "BAISHUN" {
t.Fatalf("provider = %q, want BAISHUN", resp.Provider)
}
if resp.VendorType != "BAISHUN" {
t.Fatalf("vendorType = %q, want BAISHUN", resp.VendorType)
}
if resp.VendorGameID != "1046" {
t.Fatalf("vendorGameId = %q, want 1046", resp.VendorGameID)
}
bridgeConfig, ok := resp.BridgeConfig.(map[string]any)
if !ok {
t.Fatalf("bridgeConfig type = %T, want map[string]any", resp.BridgeConfig)
}
if got := bridgeConfig["code"]; got != "launch-code" {
t.Fatalf("bridgeConfig[code] = %v, want launch-code", got)
}
if resp.RoomState.Provider != "BAISHUN" {
t.Fatalf("roomState.provider = %q, want BAISHUN", resp.RoomState.Provider)
}
if resp.RoomState.CurrentVendorGameID != "1046" {
t.Fatalf("roomState.currentVendorGameId = %q, want 1046", resp.RoomState.CurrentVendorGameID)
}
}
func TestPublicLaunchMapsLingxianToLeaderForOldClients(t *testing.T) {
resp := publicLaunch(&gameprovider.LaunchResponse{
ID: 9528,
GameSessionID: "lx_1_1090",
Provider: "LINGXIAN",
GameID: "1090",
ProviderGameID: "1090",
LaunchConfig: map[string]any{"uid": "user-1"},
RoomState: gameprovider.RoomStateResponse{
RoomID: "room-1",
State: "PLAYING",
Provider: "LINGXIAN",
CurrentProviderGameID: "1090",
},
})
if resp.Provider != "LEADER" {
t.Fatalf("provider = %q, want LEADER", resp.Provider)
}
if resp.VendorType != "LEADER" {
t.Fatalf("vendorType = %q, want LEADER", resp.VendorType)
}
if resp.RoomState.Provider != "LEADER" {
t.Fatalf("roomState.provider = %q, want LEADER", resp.RoomState.Provider)
}
}
func TestGameProviderRoutesReturnEmptyWhenVisibilityPolicyDenies(t *testing.T) {
gin.SetMode(gin.TestMode)
provider := &routeStubProvider{}
engine := gin.New()
registerGameProviderRoutes(
engine,
routeStubAuthGateway{},
gameprovider.NewRegistry(provider),
gameprovider.NewVisibilityPolicy(
routeStubVisibilityGateway{
region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
level: integration.UserLevel{WealthLevel: 2},
targetRegionCode: "土耳其",
},
routeStubIPCountryResolver{countryCode: "TR"},
),
)
req := httptest.NewRequest(http.MethodGet, "/app/game/room/list?roomId=1", nil)
req.Header.Set("Authorization", "Bearer token")
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
}
var payload struct {
Body struct {
Items []publicRoomGameItem `json:"items"`
} `json:"body"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("response json error = %v: %s", err, rec.Body.String())
}
if len(payload.Body.Items) != 0 {
t.Fatalf("items = %d, want 0", len(payload.Body.Items))
}
if provider.listRoomCalls != 0 {
t.Fatalf("provider list calls = %d, want 0", provider.listRoomCalls)
}
}
func TestGameProviderRoutesReturnEmptyRoomListForIOSClient(t *testing.T) {
gin.SetMode(gin.TestMode)
provider := &routeStubProvider{}
engine := gin.New()
registerGameProviderRoutes(
engine,
routeStubAuthGateway{},
gameprovider.NewRegistry(provider),
nil,
)
req := httptest.NewRequest(http.MethodGet, "/app/game/room/list?roomId=1", nil)
req.Header.Set("Authorization", "Bearer token")
req.Header.Set("Req-Client", "ios")
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
}
var payload struct {
Body struct {
Items []publicRoomGameItem `json:"items"`
} `json:"body"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("response json error = %v: %s", err, rec.Body.String())
}
if len(payload.Body.Items) != 0 {
t.Fatalf("items = %d, want 0", len(payload.Body.Items))
}
if provider.listRoomCalls != 0 {
t.Fatalf("provider list calls = %d, want 0", provider.listRoomCalls)
}
}
func TestGameProviderRoutesReturnEmptyShortcutForIOSClient(t *testing.T) {
gin.SetMode(gin.TestMode)
provider := &routeStubProvider{}
engine := gin.New()
registerGameProviderRoutes(
engine,
routeStubAuthGateway{},
gameprovider.NewRegistry(provider),
nil,
)
req := httptest.NewRequest(http.MethodGet, "/app/game/room/shortcut?roomId=1", nil)
req.Header.Set("Authorization", "Bearer token")
req.Header.Set("Req-Client", " iOS ")
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
}
var payload struct {
Body []publicRoomGameItem `json:"body"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("response json error = %v: %s", err, rec.Body.String())
}
if len(payload.Body) != 0 {
t.Fatalf("items = %d, want 0", len(payload.Body))
}
if provider.shortcutCalls != 0 {
t.Fatalf("provider shortcut calls = %d, want 0", provider.shortcutCalls)
}
}
func TestGameProviderRoutesReturnEmptyProvidersForIOSClient(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
registerGameProviderRoutes(
engine,
routeStubAuthGateway{},
gameprovider.NewRegistry(&routeStubProvider{}),
nil,
)
req := httptest.NewRequest(http.MethodGet, "/app/game/providers", nil)
req.Header.Set("Authorization", "Bearer token")
req.Header.Set("Req-Client", "IOS")
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
}
var payload struct {
Body struct {
Items []gameprovider.ProviderSummary `json:"items"`
} `json:"body"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("response json error = %v: %s", err, rec.Body.String())
}
if len(payload.Body.Items) != 0 {
t.Fatalf("providers = %d, want 0", len(payload.Body.Items))
}
}
func TestGameProviderRoutesReturnProvidersWhenVisibilityPolicyAllows(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
registerGameProviderRoutes(
engine,
routeStubAuthGateway{},
gameprovider.NewRegistry(&routeStubProvider{}),
gameprovider.NewVisibilityPolicy(
routeStubVisibilityGateway{
region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
level: integration.UserLevel{WealthLevel: 3},
targetRegionCode: "土耳其",
},
routeStubIPCountryResolver{countryCode: "ZA"},
),
)
req := httptest.NewRequest(http.MethodGet, "/app/game/providers", nil)
req.Header.Set("Authorization", "Bearer token")
req.Header.Set("X-Real-IP", "88.255.1.1")
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
}
var payload struct {
Body struct {
Items []gameprovider.ProviderSummary `json:"items"`
} `json:"body"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("response json error = %v: %s", err, rec.Body.String())
}
if len(payload.Body.Items) != 1 {
t.Fatalf("providers = %d, want 1", len(payload.Body.Items))
}
if payload.Body.Items[0].Key != "TEST" {
t.Fatalf("provider key = %q, want TEST", payload.Body.Items[0].Key)
}
}
func TestProviderLaunchFallsBackToRegistryWhenLegacyProviderCannotFindGame(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
registerGameProviderRoutes(
engine,
routeStubAuthGateway{},
gameprovider.NewRegistry(
&routeLaunchStubProvider{key: "LINGXIAN"},
&routeLaunchStubProvider{key: "HOTGAME", matchGameID: "hg_1"},
),
nil,
)
req := httptest.NewRequest(http.MethodPost, "/app/game/providers/LEADER/launch", strings.NewReader(`{"roomId":"room-1","gameId":"hg_1"}`))
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, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
}
var payload struct {
Body publicLaunchResponse `json:"body"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("response json error = %v: %s", err, rec.Body.String())
}
if payload.Body.GameID != "hg_1" {
t.Fatalf("gameId = %q, want hg_1", payload.Body.GameID)
}
if payload.Body.Provider != "LEADER" {
t.Fatalf("provider = %q, want LEADER for old-client compat", payload.Body.Provider)
}
if payload.Body.GameSessionID != "hg-session" {
t.Fatalf("gameSessionId = %q, want hg-session", payload.Body.GameSessionID)
}
}
type routeStubAuthGateway struct{}
func (routeStubAuthGateway) AuthenticateToken(context.Context, string) (integration.UserCredential, error) {
return integration.UserCredential{UserID: integration.Int64Value(1001), SysOrigin: "LIKEI"}, nil
}
func (routeStubAuthGateway) AuthenticateConsoleToken(context.Context, string) (integration.ConsoleAccount, error) {
return integration.ConsoleAccount{}, nil
}
type routeStubVisibilityGateway struct {
region integration.UserRegion
level integration.UserLevel
targetRegionCode string
}
func (s routeStubVisibilityGateway) GetUserRegion(context.Context, int64, string) (integration.UserRegion, error) {
return s.region, nil
}
func (s routeStubVisibilityGateway) GetUserLevel(context.Context, string, int64) (integration.UserLevel, error) {
return s.level, nil
}
func (s routeStubVisibilityGateway) ResolveRegionCodeByCountryCode(context.Context, string, string) (string, error) {
return s.targetRegionCode, nil
}
type routeStubIPCountryResolver struct {
countryCode string
}
func (s routeStubIPCountryResolver) CountryCode(context.Context, string) (string, error) {
return s.countryCode, nil
}
type routeStubProvider struct {
listRoomCalls int
shortcutCalls int
}
func (p *routeStubProvider) Key() string { return "TEST" }
func (p *routeStubProvider) DisplayName() string { return "Test Provider" }
func (p *routeStubProvider) SupportsLaunch(context.Context, gameprovider.AuthUser, gameprovider.LaunchRequest) (bool, error) {
return false, nil
}
func (p *routeStubProvider) ListShortcutGames(context.Context, gameprovider.AuthUser, string) ([]gameprovider.RoomGameListItem, error) {
p.shortcutCalls++
return []gameprovider.RoomGameListItem{{ID: 1, GameID: "test_1", Provider: "TEST", ProviderGameID: "1", Name: "Test Game"}}, nil
}
func (p *routeStubProvider) ListRoomGames(context.Context, gameprovider.AuthUser, string, string) (*gameprovider.RoomGameListResponse, error) {
p.listRoomCalls++
return &gameprovider.RoomGameListResponse{
Items: []gameprovider.RoomGameListItem{{ID: 1, GameID: "test_1", Provider: "TEST", ProviderGameID: "1", Name: "Test Game"}},
}, nil
}
func (p *routeStubProvider) GetRoomState(context.Context, gameprovider.AuthUser, string) (*gameprovider.RoomStateResponse, error) {
return &gameprovider.RoomStateResponse{}, nil
}
func (p *routeStubProvider) LaunchGame(context.Context, gameprovider.AuthUser, gameprovider.LaunchRequest, string) (*gameprovider.LaunchResponse, error) {
return nil, common.NewAppError(http.StatusBadRequest, "unsupported", "unsupported")
}
func (p *routeStubProvider) CloseGame(context.Context, gameprovider.AuthUser, gameprovider.CloseRequest) (*gameprovider.RoomStateResponse, error) {
return &gameprovider.RoomStateResponse{}, nil
}
type routeLaunchStubProvider struct {
key string
matchGameID string
}
func (p *routeLaunchStubProvider) Key() string { return p.key }
func (p *routeLaunchStubProvider) DisplayName() string { return p.key }
func (p *routeLaunchStubProvider) SupportsLaunch(_ context.Context, _ gameprovider.AuthUser, req gameprovider.LaunchRequest) (bool, error) {
return p.matchGameID != "" && req.GameID == p.matchGameID, nil
}
func (p *routeLaunchStubProvider) ListShortcutGames(context.Context, gameprovider.AuthUser, string) ([]gameprovider.RoomGameListItem, error) {
return nil, nil
}
func (p *routeLaunchStubProvider) ListRoomGames(context.Context, gameprovider.AuthUser, string, string) (*gameprovider.RoomGameListResponse, error) {
return &gameprovider.RoomGameListResponse{}, nil
}
func (p *routeLaunchStubProvider) GetRoomState(context.Context, gameprovider.AuthUser, string) (*gameprovider.RoomStateResponse, error) {
return &gameprovider.RoomStateResponse{}, nil
}
func (p *routeLaunchStubProvider) LaunchGame(_ context.Context, _ gameprovider.AuthUser, req gameprovider.LaunchRequest, _ string) (*gameprovider.LaunchResponse, error) {
if p.matchGameID == "" || req.GameID != p.matchGameID {
return nil, common.NewAppError(http.StatusNotFound, "game_not_found", "game not found")
}
return &gameprovider.LaunchResponse{
GameSessionID: "hg-session",
Provider: p.key,
GameID: req.GameID,
ProviderGameID: "1",
Entry: gameprovider.LaunchEntry{LaunchMode: "H5_REMOTE"},
RoomState: gameprovider.RoomStateResponse{
RoomID: req.RoomID,
State: "PLAYING",
Provider: p.key,
GameSessionID: "hg-session",
},
}, nil
}
func (p *routeLaunchStubProvider) CloseGame(context.Context, gameprovider.AuthUser, gameprovider.CloseRequest) (*gameprovider.RoomStateResponse, error) {
return &gameprovider.RoomStateResponse{}, nil
}

View File

@ -0,0 +1,55 @@
package router
import (
"net/http"
"net/url"
"strings"
"chatapp3-golang/internal/service/hostcenter"
"github.com/gin-gonic/gin"
)
func registerHostCenterRoutes(engine *gin.Engine, service *hostcenter.Service) {
if service == nil {
return
}
group := engine.Group("/app/h5/host-center")
group.GET("/policy", func(c *gin.Context) {
token := strings.TrimSpace(queryValuePreservePlus(c.Request.URL.RawQuery, "token"))
if token == "" {
writeError(c, hostcenter.NewAppError(http.StatusUnauthorized, "missing_token", "token is required"))
return
}
resp, err := service.GetAnchorPolicy(c.Request.Context(), hostcenter.AnchorPolicyRequest{
Token: token,
PolicyType: c.Query("policyType"),
})
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
}
func queryValuePreservePlus(rawQuery, name string) string {
for _, part := range strings.Split(rawQuery, "&") {
if part == "" {
continue
}
key, value, _ := strings.Cut(part, "=")
decodedKey, err := url.QueryUnescape(key)
if err != nil || decodedKey != name {
continue
}
decodedValue, err := url.QueryUnescape(strings.ReplaceAll(value, "+", "%2B"))
if err != nil {
return value
}
return decodedValue
}
return ""
}

View File

@ -0,0 +1,10 @@
package router
import "testing"
func TestQueryValuePreservePlusKeepsTokenBase64Plus(t *testing.T) {
got := queryValuePreservePlus("lang=en&token=abc+def%2Bghi", "token")
if got != "abc+def+ghi" {
t.Fatalf("token = %q, want abc+def+ghi", got)
}
}

View File

@ -0,0 +1,115 @@
package router
import (
"net/http"
"strconv"
"strings"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/service/hotgame"
"github.com/gin-gonic/gin"
)
// registerHotgameRoutes 注册热游后台管理类 Go 接口。
func registerHotgameRoutes(engine *gin.Engine, javaClient authGateway, service *hotgame.Service) {
if service == nil {
return
}
consoleGroup := engine.Group("/operate/hotgame-game")
consoleGroup.Use(consoleAuthMiddleware(javaClient))
consoleGroup.GET("/config", func(c *gin.Context) {
resp, err := service.GetProviderConfig(c.Request.Context(), c.Query("sysOrigin"), c.Query("profile"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
consoleGroup.POST("/config", func(c *gin.Context) {
var req hotgame.SaveProviderConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.SaveProviderConfig(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
consoleGroup.POST("/config/activate", func(c *gin.Context) {
var req hotgame.ActivateProviderProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.ActivateProviderProfile(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
consoleGroup.GET("/catalog/page", 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.PageCatalog(
c.Request.Context(),
c.Query("sysOrigin"),
c.Query("profile"),
c.Query("keyword"),
cursor,
limit,
)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
consoleGroup.POST("/catalog/fetch", func(c *gin.Context) {
var req hotgame.SyncCatalogRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.FetchAdminCatalog(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
consoleGroup.POST("/import-catalog", func(c *gin.Context) {
var req hotgame.SyncCatalogRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
defaultShowcase := true
if req.DefaultShowcase != nil {
defaultShowcase = *req.DefaultShowcase
}
resp, err := service.ImportCatalogGames(c.Request.Context(), req.SysOrigin, req.Profile, req.VendorGameIDs, defaultShowcase)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
consoleGroup.POST("/sync", func(c *gin.Context) {
var req hotgame.SyncCatalogRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.SyncAdminGames(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
}

View File

@ -1,7 +1,10 @@
package router
import (
"errors"
"io"
"net/http"
"strconv"
"chatapp3-golang/internal/service/invite"
@ -37,6 +40,19 @@ func registerInviteRoutes(engine *gin.Engine, javaClient authGateway, inviteServ
}
writeOK(c, resp)
})
internalGroup.POST("/register-bind-code", func(c *gin.Context) {
var req invite.RegisterBindCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, invite.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := inviteService.BindRegisteredCode(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup := engine.Group("/app/h5/invite-campaign")
appGroup.Use(authMiddleware(javaClient))
@ -48,19 +64,21 @@ func registerInviteRoutes(engine *gin.Engine, javaClient authGateway, inviteServ
}
writeOK(c, resp)
})
appGroup.POST("/bind-code", func(c *gin.Context) {
var req invite.BindCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, invite.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := inviteService.BindCode(c.Request.Context(), mustAuthUser(c), resolveClientIP(c), req)
appGroup.GET("/rank", func(c *gin.Context) {
limit, _ := strconv.Atoi(c.Query("limit"))
resp, err := inviteService.GetRank(c.Request.Context(), mustAuthUser(c), limit)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.POST("/bind-code", func(c *gin.Context) {
bindInviteCode(c, inviteService)
})
appGroup.POST("/bind-invite-code", func(c *gin.Context) {
bindInviteCode(c, inviteService)
})
appGroup.POST("/tasks/claim", func(c *gin.Context) {
var req invite.TaskClaimRequest
if err := c.ShouldBindJSON(&req); err != nil {
@ -75,3 +93,23 @@ func registerInviteRoutes(engine *gin.Engine, javaClient authGateway, inviteServ
writeOK(c, resp)
})
}
func bindInviteCode(c *gin.Context, inviteService *invite.InviteService) {
var req invite.BindCodeRequest
if err := c.ShouldBind(&req); err != nil && !errors.Is(err, io.EOF) {
writeError(c, invite.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
if req.NormalizedInviteCode() == "" {
req.InviteCodeSnake = c.Query("invite_code")
req.InviteCode = c.Query("inviteCode")
req.InvitationCode = c.Query("invitationCode")
req.InvitationCodeSnake = c.Query("invitation_code")
}
resp, err := inviteService.BindCode(c.Request.Context(), mustAuthUser(c), resolveClientIP(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
}

View File

@ -0,0 +1,114 @@
package router
import (
"net/http"
"strconv"
"strings"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/service/lingxian"
"github.com/gin-gonic/gin"
)
func registerLingxianRoutes(engine *gin.Engine, javaClient authGateway, service *lingxian.Service) {
if service == nil {
return
}
consoleGroup := engine.Group("/operate/lingxian-game")
consoleGroup.Use(consoleAuthMiddleware(javaClient))
consoleGroup.GET("/config", func(c *gin.Context) {
resp, err := service.GetProviderConfig(c.Request.Context(), c.Query("sysOrigin"), c.Query("profile"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
consoleGroup.POST("/config", func(c *gin.Context) {
var req lingxian.SaveProviderConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.SaveProviderConfig(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
consoleGroup.POST("/config/activate", func(c *gin.Context) {
var req lingxian.ActivateProviderProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.ActivateProviderProfile(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
consoleGroup.GET("/catalog/page", 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.PageCatalog(
c.Request.Context(),
c.Query("sysOrigin"),
c.Query("profile"),
c.Query("keyword"),
cursor,
limit,
)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
consoleGroup.POST("/catalog/fetch", func(c *gin.Context) {
var req lingxian.SyncCatalogRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.FetchAdminCatalog(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
consoleGroup.POST("/import-catalog", func(c *gin.Context) {
var req lingxian.SyncCatalogRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
defaultShowcase := true
if req.DefaultShowcase != nil {
defaultShowcase = *req.DefaultShowcase
}
resp, err := service.ImportCatalogGames(c.Request.Context(), req.SysOrigin, req.Profile, req.VendorGameIDs, defaultShowcase)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
consoleGroup.POST("/sync", func(c *gin.Context) {
var req lingxian.SyncCatalogRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.SyncAdminGames(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
}

View File

@ -0,0 +1,136 @@
package router
import (
"net/http"
"strings"
"chatapp3-golang/internal/service/managercenter"
"github.com/gin-gonic/gin"
)
// registerManagerCenterRoutes 注册经理中心 H5 路由。
func registerManagerCenterRoutes(engine *gin.Engine, javaClient authGateway, service *managercenter.Service) {
if service == nil {
return
}
group := engine.Group("/app/h5/manager-center")
group.Use(authMiddleware(javaClient))
group.GET("/profile", func(c *gin.Context) {
resp, err := service.GetProfile(c.Request.Context(), mustAuthUser(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.GET("/users/search", func(c *gin.Context) {
account := strings.TrimSpace(c.Query("account"))
resp, err := service.SearchUser(c.Request.Context(), mustAuthUser(c), account)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.GET("/countries", func(c *gin.Context) {
resp, err := service.ListCountries(c.Request.Context(), mustAuthUser(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.GET("/props", func(c *gin.Context) {
resp, err := service.ListProps(c.Request.Context(), mustAuthUser(c), c.Query("type"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.POST("/props/send", func(c *gin.Context) {
var req managercenter.SendPropsRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, managercenter.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.SendProps(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.GET("/bd-leaders", func(c *gin.Context) {
resp, err := service.ListBDLeaders(c.Request.Context(), mustAuthUser(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.POST("/bd-leaders", func(c *gin.Context) {
var req managercenter.AddBDLeaderRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, managercenter.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.AddBDLeader(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.POST("/users/ban", func(c *gin.Context) {
var req managercenter.BanUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, managercenter.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.BanUser(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.POST("/users/unban", func(c *gin.Context) {
var req managercenter.UnbanUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, managercenter.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.UnbanUser(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.POST("/users/country", func(c *gin.Context) {
var req managercenter.ChangeUserCountryRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, managercenter.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.ChangeUserCountry(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
}

View File

@ -112,6 +112,9 @@ func trimBearer(authorization string) string {
// resolveClientIP 解析请求来源 IP。
func resolveClientIP(c *gin.Context) string {
if realIP := strings.TrimSpace(c.GetHeader("X-Real-IP")); realIP != "" {
return realIP
}
if forwarded := strings.TrimSpace(c.GetHeader("X-Forwarded-For")); forwarded != "" {
parts := strings.Split(forwarded, ",")
if len(parts) > 0 {

View File

@ -0,0 +1,45 @@
package router
import (
"net/http"
"chatapp3-golang/internal/service/propsstore"
"github.com/gin-gonic/gin"
)
// registerPropsStoreRoutes registers admin props store routes.
func registerPropsStoreRoutes(engine *gin.Engine, javaClient authGateway, service *propsstore.Service) {
if service == nil {
return
}
adminGroup := engine.Group("/props/store")
adminGroup.Use(consoleAuthMiddleware(javaClient))
adminGroup.POST("/source-map", func(c *gin.Context) {
var req propsstore.MapBySourceIDsRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, propsstore.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.MapBySourceIDs(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
adminGroup.POST("/add-or-update", func(c *gin.Context) {
var req propsstore.SaveCommodityRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, propsstore.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.SaveCommodity(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
}

View File

@ -0,0 +1,95 @@
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/propsstore"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestPropsStoreAdminRoutesSaveAndMap(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newRouterPropsStoreDB(t)
service := propsstore.NewService(db, nil)
engine := NewRouter(config.Config{}, &repo.Repository{}, propsStoreAuthStub{}, Services{
PropsStore: service,
})
saveBody := []byte(`{
"sysOrigin": "LIKEI",
"sourceId": "100",
"propsType": "AVATAR_FRAME",
"currencyTypes": "GOLD",
"validDays": "7",
"discount": 0,
"shelfStatus": true,
"sort": 0,
"label": ""
}`)
saveReq := httptest.NewRequest(http.MethodPost, "/props/store/add-or-update", bytes.NewReader(saveBody))
saveReq.Header.Set("Authorization", "Bearer test-token")
saveReq.Header.Set("Content-Type", "application/json")
saveResp := httptest.NewRecorder()
engine.ServeHTTP(saveResp, saveReq)
if saveResp.Code != http.StatusOK {
t.Fatalf("save status = %d, body = %s", saveResp.Code, saveResp.Body.String())
}
mapReq := httptest.NewRequest(http.MethodPost, "/props/store/source-map", bytes.NewReader([]byte(`{
"sysOrigin": "LIKEI",
"sourceIds": ["100"]
}`)))
mapReq.Header.Set("Authorization", "Bearer test-token")
mapReq.Header.Set("Content-Type", "application/json")
mapResp := httptest.NewRecorder()
engine.ServeHTTP(mapResp, mapReq)
if mapResp.Code != http.StatusOK {
t.Fatalf("map status = %d, body = %s", mapResp.Code, mapResp.Body.String())
}
var decoded struct {
ErrorCode int `json:"errorCode"`
Body map[string]propsstore.CommodityView `json:"body"`
}
if err := json.Unmarshal(mapResp.Body.Bytes(), &decoded); err != nil {
t.Fatalf("decode response: %v", err)
}
item, ok := decoded.Body["100"]
if decoded.ErrorCode != 0 || !ok || item.ShelfStatus == nil || !*item.ShelfStatus {
t.Fatalf("unexpected source-map body: %#v", decoded)
}
}
type propsStoreAuthStub struct{}
func (propsStoreAuthStub) AuthenticateToken(context.Context, string) (integration.UserCredential, error) {
return integration.UserCredential{}, nil
}
func (propsStoreAuthStub) AuthenticateConsoleToken(context.Context, string) (integration.ConsoleAccount, error) {
return integration.ConsoleAccount{LoginName: "admin"}, nil
}
func newRouterPropsStoreDB(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.PropsCommodityStore{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
return db
}

View File

@ -0,0 +1,59 @@
package router
import (
"net/http"
"strconv"
"strings"
"chatapp3-golang/internal/service/rechargeagency"
"github.com/gin-gonic/gin"
)
// registerRechargeAgencyRoutes 注册充值代理 H5 币商名单路由。
func registerRechargeAgencyRoutes(engine *gin.Engine, javaClient authGateway, service *rechargeagency.Service) {
if service == nil {
return
}
handler := 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.PageSellers(c.Request.Context(), mustAuthUser(c), cursor, limit)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
}
listGroup := engine.Group("/app/h5/recharge-agency-list")
listGroup.Use(authMiddleware(javaClient))
listGroup.GET("/sellers", handler)
agencyGroup := engine.Group("/app/h5/recharge-agency")
agencyGroup.Use(authMiddleware(javaClient))
agencyGroup.GET("/sellers", handler)
agencyGroup.GET("/profile", func(c *gin.Context) {
resp, err := service.GetProfile(c.Request.Context(), mustAuthUser(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
agencyGroup.POST("/profile/whatsapp", func(c *gin.Context) {
var req rechargeagency.UpdateWhatsappRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, rechargeagency.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.UpdateWhatsapp(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
}

View File

@ -0,0 +1,161 @@
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/service/rechargeagency"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type rechargeAgencyRouteAuth struct{}
func (rechargeAgencyRouteAuth) AuthenticateToken(context.Context, string) (integration.UserCredential, error) {
return integration.UserCredential{
UserID: integration.Int64Value(7),
SysOrigin: "LIKEI",
}, nil
}
func (rechargeAgencyRouteAuth) AuthenticateConsoleToken(context.Context, string) (integration.ConsoleAccount, error) {
return integration.ConsoleAccount{}, nil
}
type rechargeAgencyRouteJava struct {
authorization string
sysOrigin string
}
func (f *rechargeAgencyRouteJava) PageFreightSellers(_ context.Context, authorization string, sysOrigin string, cursor int, limit int) (integration.FreightSellerPage, error) {
f.authorization = authorization
f.sysOrigin = sysOrigin
return integration.FreightSellerPage{
Total: 1,
Current: cursor,
Size: limit,
Records: []integration.FreightSellerRecord{
{
ID: integration.Int64Value(1),
SysOrigin: sysOrigin,
UserID: integration.Int64Value(99),
Balance: integration.DecimalString("10"),
UserBaseInfo: integration.FreightSellerProfile{
Account: "u99",
UserNickname: "Nina",
},
},
},
}, nil
}
func TestRechargeAgencySellersRoute(t *testing.T) {
java := &rechargeAgencyRouteJava{}
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.RechargeAgencySellerInfo{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
if err := db.Create(&model.RechargeAgencySellerInfo{
SysOrigin: "LIKEI",
UserID: 99,
Whatsapp: "+8613800138000",
}).Error; err != nil {
t.Fatalf("seed whatsapp: %v", err)
}
service := rechargeagency.NewService(config.Config{}, db, java)
engine := NewRouter(config.Config{}, nil, rechargeAgencyRouteAuth{}, Services{
RechargeAgency: service,
})
req := httptest.NewRequest(http.MethodGet, "/app/h5/recharge-agency-list/sellers?cursor=2&limit=1", 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", rec.Code, rec.Body.String())
}
if java.authorization != "Bearer token" || java.sysOrigin != "LIKEI" {
t.Fatalf("gateway authorization/sysOrigin = %q/%q", java.authorization, java.sysOrigin)
}
var payload struct {
Body struct {
Total int64 `json:"total"`
Records []struct {
UserID string `json:"userId"`
UserNickname string `json:"userNickname"`
Whatsapp string `json:"whatsapp"`
} `json:"records"`
} `json:"body"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Body.Total != 1 || len(payload.Body.Records) != 1 {
t.Fatalf("payload body mismatch: %+v", payload.Body)
}
if payload.Body.Records[0].UserID != "99" || payload.Body.Records[0].UserNickname != "Nina" {
t.Fatalf("record mismatch: %+v", payload.Body.Records[0])
}
if payload.Body.Records[0].Whatsapp != "+8613800138000" {
t.Fatalf("whatsapp mismatch: %+v", payload.Body.Records[0])
}
}
func TestRechargeAgencyProfileRoutes(t *testing.T) {
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.RechargeAgencySellerInfo{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
service := rechargeagency.NewService(config.Config{}, db, nil)
engine := NewRouter(config.Config{}, nil, rechargeAgencyRouteAuth{}, Services{
RechargeAgency: service,
})
postReq := httptest.NewRequest(http.MethodPost, "/app/h5/recharge-agency/profile/whatsapp", bytes.NewBufferString(`{"whatsapp":"+8613800138000"}`))
postReq.Header.Set("Authorization", "Bearer token")
postReq.Header.Set("Content-Type", "application/json")
postRec := httptest.NewRecorder()
engine.ServeHTTP(postRec, postReq)
if postRec.Code != http.StatusOK {
t.Fatalf("post status = %d body = %s", postRec.Code, postRec.Body.String())
}
getReq := httptest.NewRequest(http.MethodGet, "/app/h5/recharge-agency/profile", nil)
getReq.Header.Set("Authorization", "Bearer token")
getRec := httptest.NewRecorder()
engine.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("get status = %d body = %s", getRec.Code, getRec.Body.String())
}
var payload struct {
Body struct {
UserID string `json:"userId"`
Whatsapp string `json:"whatsapp"`
} `json:"body"`
}
if err := json.Unmarshal(getRec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode get response: %v", err)
}
if payload.Body.UserID != "7" || payload.Body.Whatsapp != "+8613800138000" {
t.Fatalf("profile body mismatch: %+v", payload.Body)
}
}

View File

@ -0,0 +1,69 @@
package router
import (
"net/http"
"chatapp3-golang/internal/service/rechargereward"
"github.com/gin-gonic/gin"
)
// registerRechargeRewardRoutes 注册充值奖励 H5 和后台配置路由。
func registerRechargeRewardRoutes(engine *gin.Engine, javaClient authGateway, service *rechargereward.Service) {
if service == nil {
return
}
appGroup := engine.Group("/app/h5/recharge-reward")
appGroup.Use(authMiddleware(javaClient))
appGroup.GET("/home", func(c *gin.Context) {
resp, err := service.GetHome(c.Request.Context(), mustAuthUser(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group := engine.Group("/resident-activity/recharge-reward")
group.Use(consoleAuthMiddleware(javaClient))
group.GET("/config", func(c *gin.Context) {
resp, err := service.GetConfig(c.Request.Context(), c.Query("sysOrigin"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.POST("/config/save", func(c *gin.Context) {
var req rechargereward.SaveRechargeRewardConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, rechargereward.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)
})
group.GET("/claim-record/page", func(c *gin.Context) {
resp, err := service.PageClaimRecords(
c.Request.Context(),
c.Query("sysOrigin"),
c.Query("month"),
rechargereward.ParseInt64(c.Query("userId")),
int(rechargereward.ParseInt64(c.Query("cursor"))),
int(rechargereward.ParseInt64(c.Query("limit"))),
)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
}

View File

@ -0,0 +1,150 @@
package router
import (
"net/http"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/repo"
"chatapp3-golang/internal/service/highwin"
"chatapp3-golang/internal/service/regionimgroup"
"github.com/gin-gonic/gin"
)
// registerRegionIMGroupRoutes 注册区域通用 IM 群组和区域广播接口。
func registerRegionIMGroupRoutes(
engine *gin.Engine,
cfg config.Config,
repository *repo.Repository,
javaClient authGateway,
service *regionimgroup.Service,
) {
if service == nil {
return
}
registerRegionIMGroupAppRoutes(engine, javaClient, service)
registerRegionIMGroupInternalRoutes(engine, cfg, service)
registerRegionIMGroupAdminRoutes(engine, repository, 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, repository *repo.Repository, javaClient authGateway, service *regionimgroup.Service) {
registerRegionIMGroupAdminGroup(engine.Group("/region/config"), javaClient, service)
registerRegionIMGroupAdminGroup(engine.Group("/resident-activity/voice-room-red-packet"), javaClient, service)
var worker *highwin.BroadcastSimulationWorker
if repository != nil {
worker = highwin.NewBroadcastSimulationWorker(repository.DB, repository.Redis, service)
}
registerRegionBroadcastSimulationAdminGroup(engine.Group("/operate/region-broadcast"), javaClient, service, worker)
}
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)
})
}
func registerRegionBroadcastSimulationAdminGroup(group *gin.RouterGroup, javaClient authGateway, service *regionimgroup.Service, worker *highwin.BroadcastSimulationWorker) {
group.Use(consoleAuthMiddleware(javaClient))
group.POST("/simulate", func(c *gin.Context) {
var req highwin.BroadcastSimulationRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, regionimgroup.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := highwin.SendSimulationBroadcast(c.Request.Context(), service, req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.GET("/worker/status", func(c *gin.Context) {
writeOK(c, worker.Status())
})
group.POST("/worker/start", func(c *gin.Context) {
var req highwin.ContinuousSimulationRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, regionimgroup.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := worker.Start(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.POST("/worker/stop", func(c *gin.Context) {
writeOK(c, worker.Stop())
})
}

View File

@ -0,0 +1,120 @@
package router
import (
"errors"
"io"
"net/http"
"strconv"
"strings"
"chatapp3-golang/internal/service/roomturnoverreward"
"github.com/gin-gonic/gin"
)
// registerRoomTurnoverRewardRoutes 注册房间流水奖励后台配置和发奖记录路由。
func registerRoomTurnoverRewardRoutes(engine *gin.Engine, javaClient authGateway, service *roomturnoverreward.Service) {
if service == nil {
return
}
engine.GET("/public/h5/room-turnover-reward/page", func(c *gin.Context) {
resp, err := service.GetPublicPage(c.Request.Context(), c.Query("sysOrigin"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup := engine.Group("/app/h5/room-turnover-reward")
appGroup.Use(authMiddleware(javaClient))
appGroup.GET("/home", func(c *gin.Context) {
resp, err := service.GetHome(c.Request.Context(), mustAuthUser(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.GET("/reward/claim/status", func(c *gin.Context) {
resp, err := service.GetClaimStatus(c.Request.Context(), mustAuthUser(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.POST("/reward/claim", func(c *gin.Context) {
var req roomturnoverreward.RoomTurnoverRewardClaimRequest
if c.Request.ContentLength != 0 {
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, roomturnoverreward.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
} else if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
writeError(c, roomturnoverreward.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.ClaimReward(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group := engine.Group("/resident-activity/room-turnover-reward")
group.Use(consoleAuthMiddleware(javaClient))
group.GET("/config", func(c *gin.Context) {
resp, err := service.GetConfig(c.Request.Context(), c.Query("sysOrigin"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.POST("/config/save", func(c *gin.Context) {
var req roomturnoverreward.SaveRoomTurnoverRewardConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, roomturnoverreward.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)
})
group.GET("/reward-record/page", 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.PageRewardRecords(
c.Request.Context(),
c.Query("sysOrigin"),
c.Query("cycleKey"),
c.Query("status"),
cursor,
limit,
)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.GET("/current-rank", func(c *gin.Context) {
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
resp, err := service.CurrentRoomRanking(c.Request.Context(), c.Query("sysOrigin"), limit)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
}

View File

@ -6,28 +6,69 @@ import (
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/repo"
"chatapp3-golang/internal/service/apppopup"
"chatapp3-golang/internal/service/baishun"
"chatapp3-golang/internal/service/binancerecharge"
"chatapp3-golang/internal/service/errorlog"
"chatapp3-golang/internal/service/firstrechargereward"
"chatapp3-golang/internal/service/gameopen"
"chatapp3-golang/internal/service/gameprovider"
"chatapp3-golang/internal/service/hostcenter"
"chatapp3-golang/internal/service/hotgame"
"chatapp3-golang/internal/service/invite"
"chatapp3-golang/internal/service/lingxian"
"chatapp3-golang/internal/service/luckygift"
"chatapp3-golang/internal/service/managercenter"
"chatapp3-golang/internal/service/propsstore"
"chatapp3-golang/internal/service/rechargeagency"
"chatapp3-golang/internal/service/rechargereward"
"chatapp3-golang/internal/service/regionimgroup"
"chatapp3-golang/internal/service/registerreward"
"chatapp3-golang/internal/service/roomturnoverreward"
"chatapp3-golang/internal/service/signinreward"
"chatapp3-golang/internal/service/smashegg"
"chatapp3-golang/internal/service/taskcenter"
"chatapp3-golang/internal/service/userbadge"
"chatapp3-golang/internal/service/vip"
"chatapp3-golang/internal/service/voiceroomredpacket"
"chatapp3-golang/internal/service/voiceroomrocket"
"chatapp3-golang/internal/service/weekstar"
"chatapp3-golang/internal/service/wheel"
"github.com/gin-gonic/gin"
)
// Services 聚合所有业务模块服务,供路由层统一装配。
type Services struct {
Invite *invite.InviteService
Baishun *baishun.BaishunService
GameOpen *gameopen.GameOpenService
GameProviders *gameprovider.Registry
LuckyGift *luckygift.LuckyGiftService
RegisterReward *registerreward.RegisterRewardService
SignInReward *signinreward.SignInRewardService
WeekStar *weekstar.WeekStarService
AppPopup *apppopup.Service
ErrorLog *errorlog.Service
Invite *invite.InviteService
Baishun *baishun.BaishunService
BinanceRecharge *binancerecharge.Service
FirstRechargeReward *firstrechargereward.Service
Lingxian *lingxian.Service
GameOpen *gameopen.GameOpenService
GameProviders *gameprovider.Registry
GameVisibility *gameprovider.VisibilityPolicy
LuckyGift *luckygift.LuckyGiftService
ManagerCenter *managercenter.Service
PropsStore *propsstore.Service
HostCenter *hostcenter.Service
Hotgame *hotgame.Service
RechargeAgency *rechargeagency.Service
RechargeReward *rechargereward.Service
RegionIMGroup *regionimgroup.Service
RegisterReward *registerreward.RegisterRewardService
RoomTurnoverReward *roomturnoverreward.Service
SignInReward *signinreward.SignInRewardService
SmashEgg *smashegg.Service
TaskCenter *taskcenter.Service
UserBadge *userbadge.Service
VIP *vip.Service
Wheel *wheel.Service
VoiceRoomRedPacket *voiceroomredpacket.Service
VoiceRoomRocket *voiceroomrocket.Service
WeekStar *weekstar.WeekStarService
}
// NewRouter 创建 Gin 路由,并按模块注册所有接口。
@ -38,21 +79,63 @@ func NewRouter(
services Services,
) *gin.Engine {
engine := gin.New()
engine.Use(gin.Logger(), gin.Recovery())
engine.Use(gin.Logger(), gin.Recovery(), corsMiddleware())
registerHealthRoutes(engine, cfg, repository)
registerAppPopupRoutes(engine, javaClient, services.AppPopup)
registerAgoraErrorLogRoutes(engine, javaClient, services.ErrorLog)
registerInviteRoutes(engine, javaClient, services.Invite)
registerRechargeAgencyRoutes(engine, javaClient, services.RechargeAgency)
registerRechargeRewardRoutes(engine, javaClient, services.RechargeReward)
registerFirstRechargeRewardRoutes(engine, javaClient, services.FirstRechargeReward)
registerBinanceRechargeRoutes(engine, javaClient, services.BinanceRecharge)
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter)
registerUserBadgeRoutes(engine, javaClient, services.UserBadge)
registerVipRoutes(engine, javaClient, services.VIP)
registerWheelRoutes(engine, cfg, javaClient, services.Wheel)
registerSmashEggRoutes(engine, javaClient, services.SmashEgg)
registerRegionIMGroupRoutes(engine, cfg, repository, javaClient, services.RegionIMGroup)
registerVoiceRoomRedPacketRoutes(engine, javaClient, services.VoiceRoomRedPacket)
registerVoiceRoomRocketRoutes(engine, cfg, javaClient, services.VoiceRoomRocket)
registerWeekStarRoutes(engine, javaClient, services.WeekStar)
registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward)
registerHostCenterRoutes(engine, services.HostCenter)
registerGameOpenRoutes(engine, cfg, services.GameOpen)
registerGameProviderRoutes(engine, javaClient, services.GameProviders)
registerGameProviderRoutes(engine, javaClient, services.GameProviders, services.GameVisibility)
registerBaishunRoutes(engine, cfg, javaClient, services.Baishun)
registerLingxianRoutes(engine, javaClient, services.Lingxian)
registerHotgameRoutes(engine, javaClient, services.Hotgame)
registerLuckyGiftRoutes(engine, cfg, services.LuckyGift)
registerManagerCenterRoutes(engine, javaClient, services.ManagerCenter)
registerPropsStoreRoutes(engine, javaClient, services.PropsStore)
return engine
}
func corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin == "" {
origin = "*"
}
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
c.Header("Access-Control-Allow-Headers", "Authorization,Content-Type,Origin,Accept,sign,timestamp,token,req-lang,req-client,req-imei,req-app-intel,req-sys-origin,req-version,req-zone,X-Requested-With")
c.Header("Access-Control-Expose-Headers", "Authorization")
c.Header("Access-Control-Max-Age", "86400")
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
// registerHealthRoutes 注册健康检查接口。
func registerHealthRoutes(engine *gin.Engine, cfg config.Config, repository *repo.Repository) {
engine.GET("/health", func(c *gin.Context) {

View File

@ -0,0 +1,263 @@
package router
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/service/smashegg"
"github.com/gin-gonic/gin"
)
// registerSmashEggRoutes registers app, legacy H5, and admin smash egg APIs.
func registerSmashEggRoutes(engine *gin.Engine, javaClient authGateway, service *smashegg.Service) {
if service == nil {
return
}
appGroup := engine.Group("/app/smash-golden-egg")
appGroup.Use(authMiddleware(javaClient))
appGroup.GET("/config", func(c *gin.Context) {
resp, err := service.GetAppConfig(c.Request.Context(), mustAuthUser(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.POST("/draw", func(c *gin.Context) {
var req smashegg.DrawRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, smashegg.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
req.IdempotencyKey = requestIdempotencyKey(c, req.IdempotencyKey)
resp, err := service.Draw(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.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.PageUserRecords(c.Request.Context(), mustAuthUser(c), cursor, limit)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.GET("/notices", func(c *gin.Context) {
resp, err := service.ListRecentNotices(c.Request.Context(), mustAuthUser(c).SysOrigin, 20)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.GET("/rank/day", func(c *gin.Context) {
cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1")))
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "100")))
resp, err := service.PageDayRank(c.Request.Context(), mustAuthUser(c).SysOrigin, cursor, limit)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
adminGroup := engine.Group("/resident-activity/smash-golden-egg")
adminGroup.Use(consoleAuthMiddleware(javaClient))
adminGroup.GET("/config", func(c *gin.Context) {
resp, err := service.GetConfig(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 smashegg.SaveConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, smashegg.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("/draw-record/page", 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"),
smashegg.ParseUserID(c.Query("userId")),
c.Query("status"),
cursor,
limit,
c.Query("startTime"),
c.Query("endTime"),
)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
h5Group := engine.Group("/h5/smash-golden-egg")
h5Group.Any("/config", func(c *gin.Context) {
handleLegacySmashEggAction(c, javaClient, service, "Action/TreasureHunt.getPrizeList")
})
h5Group.Any("/draw", func(c *gin.Context) {
handleLegacySmashEggAction(c, javaClient, service, "Action/TreasureHunt.startHunt")
})
h5Group.Any("/records", func(c *gin.Context) {
handleLegacySmashEggAction(c, javaClient, service, "Action/TreasureHunt.getMyHuntRecord")
})
h5Group.Any("/notices", func(c *gin.Context) {
handleLegacySmashEggAction(c, javaClient, service, "Action/TreasureHunt.getHuntRecord")
})
h5Group.Any("/rank/day", func(c *gin.Context) {
handleLegacySmashEggAction(c, javaClient, service, "Action/TreasureHunt.getDayRank")
})
engine.Any("/index.php", func(c *gin.Context) {
action := strings.TrimSpace(c.Query("action"))
if !strings.HasPrefix(action, "Action/TreasureHunt.") {
writeError(c, smashegg.NewAppError(http.StatusNotFound, "not_found", "legacy action is not supported"))
return
}
handleLegacySmashEggAction(c, javaClient, service, action)
})
}
func handleLegacySmashEggAction(c *gin.Context, javaClient authGateway, service *smashegg.Service, action string) {
user, err := legacySmashEggUser(c, javaClient)
if err != nil {
writeLegacyError(c, http.StatusUnauthorized, err.Error())
return
}
var resp map[string]any
switch action {
case "Action/TreasureHunt.getPrizeList":
resp, err = service.LegacyPrizeList(c.Request.Context(), user)
case "Action/TreasureHunt.startHunt":
times, _ := strconv.Atoi(strings.TrimSpace(firstNonEmptyQuery(c, "times", "num")))
if times <= 0 {
times = 1
}
resp, err = service.LegacyStartHunt(c.Request.Context(), user, times, requestIdempotencyKey(c, ""))
case "Action/TreasureHunt.getMyHuntRecord":
start, _ := strconv.Atoi(strings.TrimSpace(firstNonEmptyQuery(c, "offset", "start")))
limit, _ := strconv.Atoi(strings.TrimSpace(firstNonEmptyQuery(c, "limit", "num")))
if limit <= 0 {
limit = 20
}
resp, err = service.LegacyMyHuntRecords(c.Request.Context(), user, start, limit)
case "Action/TreasureHunt.getHuntRecord":
resp, err = service.LegacyHuntNotices(c.Request.Context(), user.SysOrigin)
case "Action/TreasureHunt.getDayRank":
resp, err = service.LegacyDayRank(c.Request.Context(), user.SysOrigin)
default:
writeLegacyError(c, http.StatusNotFound, "legacy action is not supported")
return
}
if err != nil {
writeLegacyError(c, http.StatusBadRequest, err.Error())
return
}
writeLegacyOK(c, resp)
}
func legacySmashEggUser(c *gin.Context, javaClient authGateway) (common.AuthUser, error) {
token := strings.TrimSpace(c.Query("token"))
if token == "" {
token = trimBearer(strings.TrimSpace(c.GetHeader("Authorization")))
}
if token != "" && javaClient != nil {
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
credential, err := javaClient.AuthenticateToken(ctx, token)
if err != nil {
return common.AuthUser{}, err
}
return common.AuthUser{
UserID: int64(credential.UserID),
SysOrigin: credential.SysOrigin,
Token: token,
}, nil
}
userID := smashegg.ParseUserID(firstNonEmptyQuery(c, "_login_uid", "uid", "userId"))
sysOrigin := strings.ToUpper(strings.TrimSpace(firstNonEmptyQuery(c, "sysOrigin", "originSys")))
if sysOrigin == "" {
sysOrigin = strings.ToUpper(strings.TrimSpace(c.GetHeader("req-sys-origin")))
}
if userID <= 0 || sysOrigin == "" {
return common.AuthUser{}, common.NewAppError(http.StatusUnauthorized, "missing_token", "token is required")
}
return common.AuthUser{UserID: userID, SysOrigin: sysOrigin, Token: token}, nil
}
func firstNonEmptyQuery(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(c.Query(key)); value != "" {
return value
}
}
return ""
}
func requestIdempotencyKey(c *gin.Context, fallback string) string {
for _, value := range []string{
fallback,
c.GetHeader("Idempotency-Key"),
c.GetHeader("X-Idempotency-Key"),
firstNonEmptyQuery(c, "idempotencyKey", "idempotency_key", "requestId", "request_id", "nonce"),
strings.TrimSpace(c.PostForm("idempotencyKey")),
strings.TrimSpace(c.PostForm("idempotency_key")),
strings.TrimSpace(c.PostForm("requestId")),
strings.TrimSpace(c.PostForm("request_id")),
strings.TrimSpace(c.PostForm("nonce")),
} {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func writeLegacyOK(c *gin.Context, data any) {
c.JSON(http.StatusOK, gin.H{
"response_data": data,
"response_status": gin.H{
"code": 0,
"error": "",
},
})
}
func writeLegacyError(c *gin.Context, code int, message string) {
if message == "" {
message = "error"
}
c.JSON(http.StatusOK, gin.H{
"response_data": gin.H{},
"response_status": gin.H{
"code": code,
"error": message,
},
})
}

View File

@ -0,0 +1,135 @@
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"),
c.Query("startTime"),
c.Query("endTime"),
int(taskcenter.ParseInt64(c.Query("cursor"))),
int(taskcenter.ParseInt64(c.Query("limit"))),
)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
}

View File

@ -0,0 +1,37 @@
package router
import (
"chatapp3-golang/internal/service/userbadge"
"github.com/gin-gonic/gin"
)
// registerUserBadgeRoutes registers App user badge APIs.
func registerUserBadgeRoutes(engine *gin.Engine, javaClient authGateway, service *userbadge.Service) {
if service == nil {
return
}
appGroup := engine.Group("/app/user/badge")
appGroup.Use(authMiddleware(javaClient))
appGroup.GET("/current", func(c *gin.Context) {
resp, err := service.Current(c.Request.Context(), mustAuthUser(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.GET("/other", func(c *gin.Context) {
targetUserID := userbadge.ParseUserID(c.Query("userId"))
if targetUserID <= 0 {
targetUserID = userbadge.ParseUserID(c.Query("targetUserId"))
}
resp, err := service.Other(c.Request.Context(), mustAuthUser(c), targetUserID)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
}

View File

@ -0,0 +1,199 @@
package router
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/repo"
"chatapp3-golang/internal/service/userbadge"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type userBadgeAuthStub struct{}
func (userBadgeAuthStub) AuthenticateToken(context.Context, string) (integration.UserCredential, error) {
return integration.UserCredential{UserID: 123, SysOrigin: "LIKEI"}, nil
}
func (userBadgeAuthStub) AuthenticateConsoleToken(context.Context, string) (integration.ConsoleAccount, error) {
return integration.ConsoleAccount{}, nil
}
func TestUserBadgeCurrentRoute(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newRouterUserBadgeDB(t)
now := time.Now().Add(time.Hour)
if err := db.Create(&model.UserBadgeBackpack{
ID: 1,
UserID: 123,
BadgeID: 501,
ExpireType: "TEMPORARY",
ExpireTime: now,
UseProps: true,
UpdateTime: time.Now(),
}).Error; err != nil {
t.Fatalf("create backpack: %v", err)
}
if err := db.Create(&model.SysBadgeConfig{
ID: 501,
Type: "ACTIVITY",
BadgeName: "Activity Badge",
BadgeKey: "ACTIVITY_BADGE",
}).Error; err != nil {
t.Fatalf("create badge config: %v", err)
}
if err := db.Create(&model.SysBadgePictureConfig{
ID: 601,
BadgeConfigID: 501,
SysOrigin: "LIKEI",
SelectURL: "https://cdn.example.com/activity.png",
}).Error; err != nil {
t.Fatalf("create badge picture: %v", err)
}
service := userbadge.NewService(db)
engine := NewRouter(config.Config{}, &repo.Repository{}, userBadgeAuthStub{}, Services{UserBadge: service})
req := httptest.NewRequest(http.MethodGet, "/app/user/badge/current", 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", 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)
}
shortBadges, ok := data["shortBadges"].([]any)
if !ok || len(shortBadges) != 1 {
t.Fatalf("shortBadges = %#v", data["shortBadges"])
}
item, ok := shortBadges[0].(map[string]any)
if !ok || item["type"] != "SHORT" || item["badgeId"] != "501" {
t.Fatalf("short badge item = %#v", shortBadges[0])
}
}
func TestUserBadgeCurrentRouteRequiresAuth(t *testing.T) {
gin.SetMode(gin.TestMode)
service := userbadge.NewService(newRouterUserBadgeDB(t))
engine := NewRouter(config.Config{}, &repo.Repository{}, userBadgeAuthStub{}, Services{UserBadge: service})
req := httptest.NewRequest(http.MethodGet, "/app/user/badge/current", nil)
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 TestUserBadgeOtherRoute(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newRouterUserBadgeDB(t)
targetUserID := int64(456)
if err := db.Create(&model.UserBadgeBackpack{
ID: 2,
UserID: targetUserID,
BadgeID: 502,
ExpireType: "TEMPORARY",
ExpireTime: time.Now().Add(time.Hour),
UseProps: true,
UpdateTime: time.Now(),
}).Error; err != nil {
t.Fatalf("create backpack: %v", err)
}
if err := db.Create(&model.SysBadgeConfig{
ID: 502,
Type: "ACHIEVEMENT",
BadgeName: "Achievement Badge",
BadgeKey: "ACHIEVEMENT_BADGE",
}).Error; err != nil {
t.Fatalf("create badge config: %v", err)
}
if err := db.Create(&model.SysBadgePictureConfig{
ID: 602,
BadgeConfigID: 502,
SysOrigin: "LIKEI",
SelectURL: "https://cdn.example.com/achievement.png",
}).Error; err != nil {
t.Fatalf("create badge picture: %v", err)
}
service := userbadge.NewService(db)
engine := NewRouter(config.Config{}, &repo.Repository{}, userBadgeAuthStub{}, Services{UserBadge: service})
req := httptest.NewRequest(http.MethodGet, "/app/user/badge/other?userId=456", 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", 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)
}
if data["userId"] != float64(targetUserID) {
t.Fatalf("userId = %#v, want target user", data["userId"])
}
shortBadges, ok := data["shortBadges"].([]any)
if !ok || len(shortBadges) != 1 {
t.Fatalf("shortBadges = %#v", data["shortBadges"])
}
item, ok := shortBadges[0].(map[string]any)
if !ok || item["sourceType"] != "ACHIEVEMENT" || item["badgeId"] != "502" {
t.Fatalf("short badge item = %#v", shortBadges[0])
}
}
func TestUserBadgeOtherRouteRequiresTargetUser(t *testing.T) {
gin.SetMode(gin.TestMode)
service := userbadge.NewService(newRouterUserBadgeDB(t))
engine := NewRouter(config.Config{}, &repo.Repository{}, userBadgeAuthStub{}, Services{UserBadge: service})
req := httptest.NewRequest(http.MethodGet, "/app/user/badge/other", nil)
req.Header.Set("Authorization", "Bearer token")
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d body = %s, want 400", rec.Code, rec.Body.String())
}
}
func newRouterUserBadgeDB(t *testing.T) *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.UserVipState{},
&model.UserBadgeBackpack{},
&model.SysBadgeConfig{},
&model.SysBadgePictureConfig{},
); err != nil {
t.Fatalf("migrate sqlite: %v", err)
}
return db
}

View File

@ -0,0 +1,132 @@
package router
import (
"net/http"
"strconv"
"strings"
"chatapp3-golang/internal/service/vip"
"github.com/gin-gonic/gin"
)
// registerVipRoutes registers app and admin VIP APIs.
func registerVipRoutes(engine *gin.Engine, javaClient authGateway, vipService *vip.Service) {
if vipService == nil {
return
}
appGroup := engine.Group("/app/vip")
appGroup.Use(authMiddleware(javaClient))
appGroup.GET("/home", func(c *gin.Context) {
resp, err := vipService.GetHome(c.Request.Context(), mustAuthUser(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.GET("/status", func(c *gin.Context) {
resp, err := vipService.GetStatus(c.Request.Context(), mustAuthUser(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.POST("/preview", func(c *gin.Context) {
var req vip.PurchaseRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, vip.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := vipService.PreviewPurchase(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.POST("/purchase", func(c *gin.Context) {
var req vip.PurchaseRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, vip.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := vipService.Purchase(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.GET("/orders", func(c *gin.Context) {
cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1")))
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
resp, err := vipService.PageUserOrders(c.Request.Context(), mustAuthUser(c), cursor, limit)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
residentGroup := engine.Group("/resident-activity/vip")
residentGroup.Use(consoleAuthMiddleware(javaClient))
residentGroup.GET("/config", func(c *gin.Context) {
resp, err := vipService.GetConfig(c.Request.Context(), c.Query("sysOrigin"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
residentGroup.POST("/config/save", func(c *gin.Context) {
var req vip.SaveConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, vip.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := vipService.SaveConfig(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
residentGroup.GET("/order/page", func(c *gin.Context) {
cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1")))
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
resp, err := vipService.PageOrders(
c.Request.Context(),
c.Query("sysOrigin"),
vip.ParseUserID(c.Query("userId")),
c.Query("status"),
c.Query("orderType"),
cursor,
limit,
)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
residentGroup.GET("/user-state/page", func(c *gin.Context) {
cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1")))
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
resp, err := vipService.PageUserStates(
c.Request.Context(),
c.Query("sysOrigin"),
vip.ParseUserID(c.Query("userId")),
c.Query("status"),
cursor,
limit,
)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
}

View File

@ -0,0 +1,164 @@
package router
import (
"net/http"
"strconv"
"strings"
"chatapp3-golang/internal/service/voiceroomredpacket"
"github.com/gin-gonic/gin"
)
// registerVoiceRoomRedPacketRoutes 注册语音房红包 App 和后台接口。
func registerVoiceRoomRedPacketRoutes(
engine *gin.Engine,
javaClient authGateway,
service *voiceroomredpacket.Service,
) {
if service == nil {
return
}
appGroup := engine.Group("/app/voice-room/red-packet")
appGroup.Use(authMiddleware(javaClient))
appGroup.GET("/config", func(c *gin.Context) {
resp, err := service.GetConfig(c.Request.Context(), mustAuthUser(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.POST("/presence/heartbeat", func(c *gin.Context) {
var req voiceroomredpacket.PresenceRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, voiceroomredpacket.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.Heartbeat(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.POST("/presence/leave", func(c *gin.Context) {
var req voiceroomredpacket.PresenceRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, voiceroomredpacket.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.Leave(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.POST("/send", func(c *gin.Context) {
var req voiceroomredpacket.SendRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, voiceroomredpacket.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.Send(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.POST("/claim", func(c *gin.Context) {
var req voiceroomredpacket.ClaimRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, voiceroomredpacket.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.Claim(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.GET("/detail", func(c *gin.Context) {
resp, err := service.Detail(c.Request.Context(), mustAuthUser(c), c.Query("packetNo"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.GET("/claim-records", func(c *gin.Context) {
resp, err := service.ListClaimRecords(c.Request.Context(), mustAuthUser(c), c.Query("packetNo"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.GET("/sent-records", func(c *gin.Context) {
cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1")))
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
resp, err := service.PageSentRecords(c.Request.Context(), mustAuthUser(c), cursor, limit)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
adminGroup := engine.Group("/resident-activity/voice-room-red-packet")
adminGroup.Use(consoleAuthMiddleware(javaClient))
adminGroup.GET("/config", func(c *gin.Context) {
resp, err := service.GetAdminConfig(c.Request.Context(), c.Query("sysOrigin"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
adminGroup.POST("/config/save", func(c *gin.Context) {
var req voiceroomredpacket.SaveConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, voiceroomredpacket.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.SaveAdminConfig(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
adminGroup.GET("/records", func(c *gin.Context) {
cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1")))
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
resp, err := service.PageAdminRecords(
c.Request.Context(),
c.Query("sysOrigin"),
c.Query("status"),
voiceroomredpacket.ParseUserID(c.Query("userId")),
cursor,
limit,
)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
adminGroup.POST("/refund/retry", func(c *gin.Context) {
var req voiceroomredpacket.ClaimRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, voiceroomredpacket.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
if err := service.RetryRefund(c.Request.Context(), req.PacketNo); err != nil {
writeError(c, err)
return
}
writeOK(c, gin.H{"success": true})
})
}

View File

@ -0,0 +1,243 @@
package router
import (
"net/http"
"strconv"
"strings"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/service/voiceroomrocket"
"github.com/gin-gonic/gin"
)
// registerVoiceRoomRocketRoutes 注册语音房火箭 App、后台和内部调试接口。
func registerVoiceRoomRocketRoutes(
engine *gin.Engine,
cfg config.Config,
javaClient authGateway,
service *voiceroomrocket.Service,
) {
if service == nil {
return
}
appGroup := engine.Group("/app/voice-room/rocket")
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.GET("/level-contents", func(c *gin.Context) {
resp, err := service.GetLevelContents(c.Request.Context(), mustAuthUser(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.GET("/status", func(c *gin.Context) {
resp, err := service.GetStatus(c.Request.Context(), mustAuthUser(c), parseInt64(c.Query("roomId")))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.GET("/king", func(c *gin.Context) {
cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1")))
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "100")))
level, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("level", "1")))
roundNo, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("roundNo", "0")))
resp, err := service.GetKingRanking(c.Request.Context(), mustAuthUser(c), parseInt64(c.Query("roomId")), level, roundNo, cursor, limit)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.GET("/reward-popups", func(c *gin.Context) {
resp, err := service.ListRewardPopups(c.Request.Context(), mustAuthUser(c), parseInt64(c.Query("roomId")))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.POST("/reward-popups/ack", func(c *gin.Context) {
var req voiceroomrocket.AckRewardPopupsRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, voiceroomrocket.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
if err := service.AckRewardPopups(c.Request.Context(), mustAuthUser(c), req); err != nil {
writeError(c, err)
return
}
writeOK(c, gin.H{"success": true})
})
appGroup.GET("/reward-records", func(c *gin.Context) {
cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1")))
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
user := mustAuthUser(c)
roomID := parseInt64(c.Query("roomId"))
launchNo := strings.TrimSpace(c.Query("launchNo"))
scope := strings.ToLower(strings.TrimSpace(c.Query("scope")))
var (
resp *voiceroomrocket.RewardRecordPageResponse
err error
)
if launchNo != "" || scope == "room" {
resp, err = service.PageRoomRewardRecords(c.Request.Context(), user, roomID, launchNo, cursor, limit)
} else {
resp, err = service.PageRewardRecords(c.Request.Context(), user, roomID, cursor, limit)
}
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.GET("/rule", func(c *gin.Context) {
resp, err := service.GetRule(c.Request.Context(), mustAuthUser(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
adminGroup := engine.Group("/resident-activity/voice-room-rocket")
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 voiceroomrocket.SaveConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, voiceroomrocket.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("/level-configs", func(c *gin.Context) {
resp, err := service.ListLevelConfigs(c.Request.Context(), c.Query("sysOrigin"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
adminGroup.POST("/level-configs/save", func(c *gin.Context) {
var req voiceroomrocket.SaveLevelConfigsRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, voiceroomrocket.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.SaveLevelConfigs(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
adminGroup.GET("/reward-configs", func(c *gin.Context) {
level, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("level", "0")))
resp, err := service.ListRewardConfigs(c.Request.Context(), c.Query("sysOrigin"), level)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
adminGroup.POST("/reward-configs/save", func(c *gin.Context) {
var req voiceroomrocket.SaveRewardConfigsRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, voiceroomrocket.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.SaveRewardConfigs(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
adminGroup.GET("/launch-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.PageAdminLaunchRecords(c.Request.Context(), c.Query("sysOrigin"), parseInt64(c.Query("roomId")), cursor, limit)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
adminGroup.GET("/reward-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.PageAdminRewardRecords(
c.Request.Context(),
c.Query("sysOrigin"),
parseInt64(c.Query("roomId")),
parseInt64(c.Query("userId")),
c.Query("grantStatus"),
cursor,
limit,
)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
adminGroup.POST("/reward-records/retry", func(c *gin.Context) {
var req struct {
ID int64 `json:"id,string"`
}
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, voiceroomrocket.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
if err := service.RetryReward(c.Request.Context(), req.ID); err != nil {
writeError(c, err)
return
}
writeOK(c, gin.H{"success": true})
})
internalGroup := engine.Group("/internal/voice-room/rocket")
internalGroup.Use(internalSecretMiddleware(cfg.HTTP.InternalCallbackSecret))
internalGroup.POST("/gift-event", func(c *gin.Context) {
body, err := c.GetRawData()
if err != nil {
writeError(c, voiceroomrocket.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.ProcessGiftPayload(c.Request.Context(), string(body))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
}
func parseInt64(value string) int64 {
parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return parsed
}

View File

@ -68,6 +68,16 @@ func registerSpecifiedGiftWeeklyRankRoutes(group *gin.RouterGroup, javaClient au
writeOK(c, resp)
})
group.GET("/current-rank", func(c *gin.Context) {
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
resp, err := weekStarService.CurrentRanking(c.Request.Context(), c.Query("sysOrigin"), limit)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.GET("/snapshot/page", func(c *gin.Context) {
cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1")))
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
@ -98,7 +108,7 @@ func registerSpecifiedGiftWeeklyRankRoutes(group *gin.RouterGroup, javaClient au
writeError(c, weekstar.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := weekStarService.RetryRewardRecord(c.Request.Context(), req.ID)
resp, err := weekStarService.RetryRewardRecord(c.Request.Context(), req.ID.Int64())
if err != nil {
writeError(c, err)
return

View File

@ -0,0 +1,428 @@
package router
import (
"net/http"
"strconv"
"strings"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/service/wheel"
"github.com/gin-gonic/gin"
)
const legacyGoldRewardCoverURL = "static/img/yumi-gold-coin.png"
// registerWheelRoutes registers app, admin, and copied H5-compatible wheel APIs.
func registerWheelRoutes(engine *gin.Engine, cfg config.Config, javaClient authGateway, wheelService *wheel.Service) {
if wheelService == nil {
return
}
appGroup := engine.Group("/app/wheel")
appGroup.Use(authMiddleware(javaClient))
appGroup.GET("/config", func(c *gin.Context) {
resp, err := wheelService.GetAppConfig(c.Request.Context(), mustAuthUser(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.POST("/draw", func(c *gin.Context) {
var req wheel.DrawRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, wheel.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
req.IdempotencyKey = requestIdempotencyKey(c, req.IdempotencyKey)
resp, err := wheelService.Draw(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.GET("/history", func(c *gin.Context) {
cursor, limit := parseCursorLimit(c)
user := mustAuthUser(c)
resp, err := wheelService.PageRecords(c.Request.Context(), user.SysOrigin, c.Query("category"), user.UserID, "SUCCESS", cursor, limit)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.GET("/hints", func(c *gin.Context) {
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
resp, err := wheelService.Hints(c.Request.Context(), mustAuthUser(c), c.Query("category"), limit)
if err != nil {
writeError(c, err)
return
}
writeOK(c, gin.H{"values": resp})
})
residentGroup := engine.Group("/resident-activity/wheel")
residentGroup.Use(consoleAuthMiddleware(javaClient))
residentGroup.GET("/config", func(c *gin.Context) {
resp, err := wheelService.GetConfig(c.Request.Context(), c.Query("sysOrigin"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
residentGroup.POST("/config/save", func(c *gin.Context) {
var req wheel.SaveConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, wheel.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := wheelService.SaveConfig(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
residentGroup.POST("/config/category/save", func(c *gin.Context) {
var req wheel.SaveCategoryConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, wheel.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := wheelService.SaveCategoryConfig(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
residentGroup.GET("/record/page", func(c *gin.Context) {
cursor, limit := parseCursorLimit(c)
resp, err := wheelService.PageAdminRecords(
c.Request.Context(),
c.Query("sysOrigin"),
c.Query("category"),
wheel.ParseUserID(c.Query("userId")),
strings.TrimSpace(c.Query("status")),
cursor,
limit,
c.Query("startTime"),
c.Query("endTime"),
)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
residentGroup.POST("/record/retry", func(c *gin.Context) {
var req struct {
ID int64 `json:"id,string"`
}
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, wheel.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
if err := wheelService.RetryDrawRecord(c.Request.Context(), req.ID); err != nil {
writeError(c, err)
return
}
writeOK(c, gin.H{"success": true})
})
internalGroup := engine.Group("/internal/wheel")
internalGroup.Use(internalSecretMiddleware(cfg.HTTP.InternalCallbackSecret))
internalGroup.POST("/records/retry-failed", func(c *gin.Context) {
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "50")))
processed, err := wheelService.ProcessFailedRecords(c.Request.Context(), limit)
if err != nil {
writeError(c, err)
return
}
writeOK(c, gin.H{"processed": processed})
})
registerWheelLegacyH5Routes(engine, javaClient, wheelService)
}
func registerWheelLegacyH5Routes(engine *gin.Engine, javaClient authGateway, wheelService *wheel.Service) {
legacyGroup := engine.Group("/client/api/v1")
legacyGroup.Use(authMiddleware(javaClient))
legacyGroup.GET("/config/mobile", func(c *gin.Context) {
resp, err := wheelService.GetAppConfig(c.Request.Context(), mustAuthUser(c))
if err != nil {
legacyError(c, err)
return
}
classic := legacyCategoryConfig(resp, "CLASSIC")
luxury := legacyCategoryConfig(resp, "LUXURY")
advanced := legacyCategoryConfig(resp, "ADVANCED")
legacyOK(c, gin.H{
"luckyBoxEnabled": classic.Enabled,
"middleLuckyBoxEnabled": luxury.Enabled,
"advancedLuckyBoxEnabled": advanced.Enabled,
"luckyBoxDrawAmount": classic.PriceOneGold,
"middleLuckyBoxDrawAmount": luxury.PriceOneGold,
"advancedLuckyBoxDrawAmount": advanced.PriceOneGold,
"luckyBoxDraw10Amount": classic.PriceTenGold,
"middleLuckyBoxDraw10Amount": luxury.PriceTenGold,
"advancedLuckyBoxDraw10Amount": advanced.PriceTenGold,
"luckyBoxDraw50Amount": classic.PriceFiftyGold,
"middleLuckyBoxDraw50Amount": luxury.PriceFiftyGold,
"advancedLuckyBoxDraw50Amount": advanced.PriceFiftyGold,
"wheelDrawAmount": classic.PriceOneGold,
"wheelDraw10Amount": classic.PriceTenGold,
"wheelDraw50Amount": classic.PriceFiftyGold,
})
})
legacyGroup.POST("/wallet/list", func(c *gin.Context) {
balance, err := wheelService.WalletBalances(c.Request.Context(), mustAuthUser(c))
if err != nil {
legacyError(c, err)
return
}
legacyOK(c, []gin.H{
{"walletType": "COIN", "balance": balance},
{"walletType": "DCOIN", "balance": 0},
})
})
probabilityGroup := legacyGroup.Group("/probability")
for _, prefix := range []string{"lucky-box", "middle-lucky-box", "advanced-lucky-box"} {
pathPrefix := "/" + prefix
category := legacyCategoryFromPrefix(prefix)
probabilityGroup.GET(pathPrefix+"/rewards", legacyRewardsHandler(wheelService, category))
probabilityGroup.POST(pathPrefix+"/rewards", legacyRewardsHandler(wheelService, category))
probabilityGroup.GET(pathPrefix+"/history", legacyHistoryHandler(wheelService, category))
probabilityGroup.POST(pathPrefix+"/history", legacyHistoryHandler(wheelService, category))
probabilityGroup.GET(pathPrefix+"/hints", legacyHintsHandler(wheelService, category))
probabilityGroup.POST(pathPrefix+"/hints", legacyHintsHandler(wheelService, category))
probabilityGroup.POST(pathPrefix+"/draw", legacyDrawHandler(wheelService, category))
}
}
func legacyRewardsHandler(wheelService *wheel.Service, category string) gin.HandlerFunc {
return func(c *gin.Context) {
resp, err := wheelService.GetAppConfig(c.Request.Context(), mustAuthUser(c))
if err != nil {
legacyError(c, err)
return
}
pool := legacyCategoryConfig(resp, category)
if !pool.Enabled {
legacyOK(c, []gin.H{})
return
}
items := make([]gin.H, 0, len(pool.Rewards))
for _, reward := range pool.Rewards {
items = append(items, gin.H{
"id": reward.ID,
"animationUrl": reward.ResourceURL,
"resourceUrl": reward.ResourceURL,
"value": legacyRewardValue(reward),
"merchandise": gin.H{
"animationUrl": reward.ResourceURL,
"coverUrl": legacyRewardCover(reward),
"name": legacyRewardName(reward),
"resourceUrl": reward.ResourceURL,
},
})
}
legacyOK(c, items)
}
}
func legacyDrawHandler(wheelService *wheel.Service, category string) gin.HandlerFunc {
return func(c *gin.Context) {
var req wheel.DrawRequest
_ = c.ShouldBindJSON(&req)
req.Category = category
req.IdempotencyKey = requestIdempotencyKey(c, req.IdempotencyKey)
resp, err := wheelService.Draw(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
legacyError(c, err)
return
}
rewards := make([]gin.H, 0, len(resp.Rewards))
for _, reward := range resp.Rewards {
rewards = append(rewards, gin.H{
"animationUrl": reward.ResourceURL,
"coverUrl": legacyDrawRewardCover(reward),
"count": reward.Count,
"name": legacyDrawRewardName(reward),
"resourceUrl": reward.ResourceURL,
"value": reward.Value,
})
}
legacyOK(c, gin.H{
"drawNo": resp.DrawNo,
"roomId": resp.RoomID,
"rewardValue": resp.RewardValue,
"rewards": rewards,
"balanceAfter": resp.BalanceAfter,
})
}
}
func legacyHistoryHandler(wheelService *wheel.Service, category string) gin.HandlerFunc {
return func(c *gin.Context) {
cursor, limit := parseCursorLimit(c)
user := mustAuthUser(c)
resp, err := wheelService.PageRecords(c.Request.Context(), user.SysOrigin, category, user.UserID, "SUCCESS", cursor, limit)
if err != nil {
legacyError(c, err)
return
}
records := make([]gin.H, 0, len(resp.Records))
for _, record := range resp.Records {
records = append(records, gin.H{
"createdTime": record.CreateTime,
"drawNo": record.DrawNo,
"drawTimes": record.DrawTimes,
"rewardValue": legacyRecordValue(record),
"rewards": []gin.H{{
"animationUrl": record.ResourceURL,
"coverUrl": legacyRecordCover(record),
"count": 1,
"name": legacyRecordName(record),
"resourceUrl": record.ResourceURL,
"value": legacyRecordValue(record),
}},
})
}
legacyOK(c, gin.H{"records": records, "total": resp.Total})
}
}
func legacyHintsHandler(wheelService *wheel.Service, category string) gin.HandlerFunc {
return func(c *gin.Context) {
resp, err := wheelService.Hints(c.Request.Context(), mustAuthUser(c), category, 20)
if err != nil {
legacyError(c, err)
return
}
values := make([]gin.H, 0, len(resp))
for index, reward := range resp {
values = append(values, gin.H{
"avatar": reward.CoverURL,
"nickname": "User" + strconv.Itoa(index+1),
"value": legacyDrawRewardName(reward),
})
}
legacyOK(c, gin.H{"values": values})
}
}
func parseCursorLimit(c *gin.Context) (int, int) {
cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1")))
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
return cursor, limit
}
func legacyOK(c *gin.Context, data any) {
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": data})
}
func legacyError(c *gin.Context, err error) {
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "data": nil})
}
func legacyCategoryFromPrefix(prefix string) string {
switch prefix {
case "middle-lucky-box":
return "LUXURY"
case "advanced-lucky-box":
return "ADVANCED"
default:
return "CLASSIC"
}
}
func legacyCategoryConfig(resp *wheel.ConfigResponse, category string) wheel.CategoryConfigPayload {
for _, item := range resp.Categories {
if strings.EqualFold(item.Category, category) {
return item
}
}
return wheel.CategoryConfigPayload{Category: category, Rewards: []wheel.RewardConfigPayload{}}
}
func legacyRewardValue(reward wheel.RewardConfigPayload) int64 {
if strings.EqualFold(reward.RewardType, "GOLD") {
return reward.GoldAmount
}
return reward.DisplayGoldAmount
}
func legacyRewardCover(reward wheel.RewardConfigPayload) string {
if strings.TrimSpace(reward.CoverURL) != "" {
return reward.CoverURL
}
if strings.EqualFold(reward.RewardType, "GOLD") {
return legacyGoldRewardCoverURL
}
return ""
}
func legacyDrawRewardCover(reward wheel.DrawRewardPayload) string {
if strings.TrimSpace(reward.CoverURL) != "" {
return reward.CoverURL
}
if strings.EqualFold(reward.RewardType, "GOLD") {
return legacyGoldRewardCoverURL
}
return ""
}
func legacyRecordCover(record wheel.DrawRecordPayload) string {
if strings.TrimSpace(record.CoverURL) != "" {
return record.CoverURL
}
if strings.EqualFold(record.RewardType, "GOLD") {
return legacyGoldRewardCoverURL
}
return ""
}
func legacyRecordValue(record wheel.DrawRecordPayload) int64 {
if strings.EqualFold(record.RewardType, "GOLD") {
return record.GoldAmount
}
return record.DisplayGoldAmount
}
func legacyRewardName(reward wheel.RewardConfigPayload) string {
if reward.ResourceName != "" {
return reward.ResourceName
}
if reward.RewardType == "GOLD" {
return "金币"
}
return "Reward"
}
func legacyDrawRewardName(reward wheel.DrawRewardPayload) string {
if reward.Name != "" {
return reward.Name
}
if reward.ResourceName != "" {
return reward.ResourceName
}
if reward.RewardType == "GOLD" {
return "金币"
}
return "Reward"
}
func legacyRecordName(record wheel.DrawRecordPayload) string {
if record.ResourceName != "" {
return record.ResourceName
}
if record.RewardType == "GOLD" {
return "金币"
}
return "Reward"
}

View File

@ -0,0 +1,118 @@
package router
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/repo"
wheelservice "chatapp3-golang/internal/service/wheel"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestWheelLegacyRewardsExposeDynamicResourceURL(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newRouterWheelDB(t)
now := time.Now()
resourceID := int64(2045029471274201089)
resourceURL := "https://cdn.example.com/reward.svga"
coverURL := "https://cdn.example.com/reward.png"
if err := db.Create(&model.WheelConfig{
ID: 1,
SysOrigin: "LIKEI",
Enabled: true,
Timezone: "Asia/Shanghai",
CreateTime: now,
UpdateTime: now,
}).Error; err != nil {
t.Fatalf("create wheel config: %v", err)
}
if err := db.Create(&model.WheelPoolConfig{
ID: 10,
ConfigID: 1,
SysOrigin: "LIKEI",
Category: "CLASSIC",
Enabled: true,
PriceOneGold: 500,
Sort: 1,
CreateTime: now,
UpdateTime: now,
}).Error; err != nil {
t.Fatalf("create wheel pool: %v", err)
}
if err := db.Create(&model.WheelRewardConfig{
ID: 100,
ConfigID: 1,
SysOrigin: "LIKEI",
Category: "CLASSIC",
RewardType: "RESOURCE",
ResourceID: &resourceID,
ResourceType: "GIFT",
ResourceName: "Dynamic Gift",
ResourceURL: resourceURL,
CoverURL: coverURL,
DurationDays: 7,
DisplayGoldAmount: 1000,
Probability: 1000000,
Sort: 1,
Enabled: true,
CreateTime: now,
UpdateTime: now,
}).Error; err != nil {
t.Fatalf("create wheel reward: %v", err)
}
service := wheelservice.NewService(config.Config{}, db, nil)
engine := NewRouter(config.Config{}, &repo.Repository{}, appPopupAuthStub{}, Services{Wheel: service})
req := httptest.NewRequest(http.MethodGet, "/client/api/v1/probability/lucky-box/rewards", 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", 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)
}
if payload["code"] != float64(0) {
t.Fatalf("code = %#v body = %s", payload["code"], rec.Body.String())
}
data, ok := payload["data"].([]any)
if !ok || len(data) == 0 {
t.Fatalf("data = %#v, want rewards", payload["data"])
}
item := data[0].(map[string]any)
if item["resourceUrl"] != resourceURL || item["animationUrl"] != resourceURL {
t.Fatalf("dynamic urls = resource:%#v animation:%#v", item["resourceUrl"], item["animationUrl"])
}
merchandise := item["merchandise"].(map[string]any)
if merchandise["resourceUrl"] != resourceURL || merchandise["animationUrl"] != resourceURL {
t.Fatalf("merchandise dynamic urls = resource:%#v animation:%#v", merchandise["resourceUrl"], merchandise["animationUrl"])
}
if merchandise["coverUrl"] != coverURL {
t.Fatalf("coverUrl = %#v, want %q", merchandise["coverUrl"], coverURL)
}
}
func newRouterWheelDB(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.WheelConfig{}, &model.WheelPoolConfig{}, &model.WheelRewardConfig{}); err != nil {
t.Fatalf("migrate wheel tables: %v", err)
}
return db
}

View File

@ -0,0 +1,237 @@
package apppopup
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
)
// PageAdminConfigs 返回后台首页弹窗配置分页。
func (s *Service) PageAdminConfigs(ctx context.Context, sysOrigin, scene, popupKey string, cursor, limit int) (*AdminConfigPageResponse, error) {
if s == nil || s.db == nil {
return nil, NewAppError(http.StatusServiceUnavailable, "app_popup_unavailable", "app popup service is unavailable")
}
sysOrigin = normalizeSysOrigin(sysOrigin)
scene = normalizeScene(scene)
cursor = normalizePageCursor(cursor)
limit = normalizePageLimit(limit)
query := s.db.WithContext(ctx).
Model(&model.AppPopupConfig{}).
Where("sys_origin = ? AND scene = ?", sysOrigin, scene)
if key := normalizePopupKey(popupKey); key != "" {
query = query.Where("popup_key LIKE ?", "%"+key+"%")
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, err
}
var rows []model.AppPopupConfig
if err := query.
Order("priority DESC").
Order("id ASC").
Limit(limit).
Offset((cursor - 1) * limit).
Find(&rows).Error; err != nil {
return nil, err
}
items := make([]AdminConfigItem, 0, len(rows))
for _, row := range rows {
items = append(items, adminItemFromModel(row))
}
return &AdminConfigPageResponse{
Records: items,
Total: total,
Current: cursor,
Size: limit,
}, nil
}
// SaveAdminConfig 创建或更新后台首页弹窗配置。
func (s *Service) SaveAdminConfig(ctx context.Context, req SaveAdminConfigRequest) (*AdminConfigItem, error) {
if s == nil || s.db == nil {
return nil, NewAppError(http.StatusServiceUnavailable, "app_popup_unavailable", "app popup service is unavailable")
}
sysOrigin := normalizeSysOrigin(req.SysOrigin)
scene := normalizeScene(req.Scene)
popupKey := normalizePopupKey(req.PopupKey)
if popupKey == "" {
return nil, NewAppError(http.StatusBadRequest, "popup_key_required", "popupKey is required")
}
if req.LimitDays < 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_limit_days", "limitDays must be greater than or equal to 0")
}
var savedID int64
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var row model.AppPopupConfig
requestID := req.ID.Int64()
if requestID > 0 {
err := tx.Where("id = ?", requestID).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return NewAppError(http.StatusNotFound, "app_popup_config_not_found", "config not found")
}
if err != nil {
return err
}
} else {
id, err := utils.NextID()
if err != nil {
return err
}
row.ID = id
row.CreateTime = time.Now()
}
var duplicate model.AppPopupConfig
err := tx.Where("sys_origin = ? AND scene = ? AND popup_key = ? AND id <> ?", sysOrigin, scene, popupKey, row.ID).
First(&duplicate).Error
if err == nil {
return NewAppError(http.StatusConflict, "duplicate_popup_key", "popupKey already exists")
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
now := time.Now()
row.SysOrigin = sysOrigin
row.Scene = scene
row.PopupKey = popupKey
row.Name = defaultIfBlank(req.Name, popupKey)
row.Enabled = req.Enabled
row.LimitDays = req.LimitDays
row.Priority = req.Priority
row.Version = normalizeVersion(req.Version)
row.ContentJSON = mustContentJSON(req.Title, req.Image)
row.JumpType = strings.TrimSpace(req.JumpType)
row.JumpURL = strings.TrimSpace(req.JumpURL)
row.Remark = strings.TrimSpace(req.Description)
row.PlatformsJSON = normalizeJSONField(req.PlatformsJSON)
row.MinAppVersion = strings.TrimSpace(req.MinAppVersion)
row.MaxAppVersion = strings.TrimSpace(req.MaxAppVersion)
row.UserScopeType = "ALL"
if strings.TrimSpace(row.UserScopeJSON) == "" {
row.UserScopeJSON = "{}"
}
row.UpdateTime = now
if row.CreateTime.IsZero() {
row.CreateTime = now
}
if err := tx.Save(&row).Error; err != nil {
return err
}
savedID = row.ID
return nil
}); err != nil {
return nil, err
}
s.ClearConfigCache(ctx, sysOrigin, scene)
var saved model.AppPopupConfig
if err := s.db.WithContext(ctx).Where("id = ?", savedID).First(&saved).Error; err != nil {
return nil, err
}
item := adminItemFromModel(saved)
return &item, nil
}
// DeleteAdminConfig 删除后台首页弹窗配置。
func (s *Service) DeleteAdminConfig(ctx context.Context, id int64) error {
if s == nil || s.db == nil {
return NewAppError(http.StatusServiceUnavailable, "app_popup_unavailable", "app popup service is unavailable")
}
if id <= 0 {
return NewAppError(http.StatusBadRequest, "invalid_id", "id is required")
}
var row model.AppPopupConfig
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return NewAppError(http.StatusNotFound, "app_popup_config_not_found", "config not found")
}
return err
}
if err := s.db.WithContext(ctx).Delete(&row).Error; err != nil {
return err
}
s.ClearConfigCache(ctx, row.SysOrigin, row.Scene)
return nil
}
// ClearConfigCache 清理指定场景的配置缓存,供后台配置保存后调用。
func (s *Service) ClearConfigCache(ctx context.Context, sysOrigin, scene string) {
if s == nil || s.cache == nil {
return
}
_ = s.cache.Del(ctx, appPopupConfigCacheKey(sysOrigin, scene)).Err()
}
func normalizePageCursor(cursor int) int {
if cursor <= 0 {
return 1
}
return cursor
}
func normalizePageLimit(limit int) int {
if limit <= 0 {
return 20
}
if limit > 100 {
return 100
}
return limit
}
func defaultIfBlank(value, fallback string) string {
value = strings.TrimSpace(value)
if value != "" {
return value
}
return strings.TrimSpace(fallback)
}
func mustContentJSON(title, image string) string {
payload := map[string]any{}
if title = strings.TrimSpace(title); title != "" {
payload["title"] = title
}
if image = strings.TrimSpace(image); image != "" {
payload["image"] = image
}
if len(payload) == 0 {
return "{}"
}
raw, err := json.Marshal(payload)
if err != nil {
return ""
}
return string(raw)
}
func normalizeJSONField(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return "null"
}
var value any
if err := json.Unmarshal([]byte(raw), &value); err != nil {
return "null"
}
encoded, err := json.Marshal(value)
if err != nil {
return "null"
}
return string(encoded)
}

View File

@ -0,0 +1,299 @@
package apppopup
import (
"context"
"encoding/json"
"errors"
"net/http"
"sort"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/model"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// QueryEntryPopups 返回 APP 进场弹窗配置。服务端不做用户展示频控。
func (s *Service) QueryEntryPopups(ctx context.Context, user AuthUser, req EntryQueryRequest) (*EntryQueryResponse, error) {
if s == nil || s.db == nil {
return nil, NewAppError(http.StatusServiceUnavailable, "app_popup_unavailable", "app popup service is unavailable")
}
sysOrigin := normalizeSysOrigin(user.SysOrigin)
scene := normalizeScene(req.Scene)
configs, err := s.loadConfigs(ctx, sysOrigin, scene)
if err != nil {
return nil, err
}
now := s.now()
resp := &EntryQueryResponse{
RequestID: newRequestID(),
Queue: []string{},
Items: map[string]PopupItem{},
}
for _, cfg := range configs {
key := normalizePopupKey(cfg.PopupKey)
if key == "" {
continue
}
item := s.buildPopupItem(cfg, req, user.UserID, now)
resp.set(key, item)
if item.Value {
resp.Queue = append(resp.Queue, key)
}
}
sort.SliceStable(resp.Queue, func(i, j int) bool {
left := resp.Items[resp.Queue[i]]
right := resp.Items[resp.Queue[j]]
if left.Priority == right.Priority {
return resp.Queue[i] < resp.Queue[j]
}
return left.Priority > right.Priority
})
return resp, nil
}
func (s *Service) loadConfigs(ctx context.Context, sysOrigin, scene string) ([]cachedConfig, error) {
cacheKey := appPopupConfigCacheKey(sysOrigin, scene)
if s.cache != nil {
raw, err := s.cache.Get(ctx, cacheKey).Result()
if err == nil && strings.TrimSpace(raw) != "" {
var configs []cachedConfig
if jsonErr := json.Unmarshal([]byte(raw), &configs); jsonErr == nil {
return configs, nil
}
} else if err != nil && !errors.Is(err, redis.Nil) {
return nil, NewAppError(http.StatusServiceUnavailable, "app_popup_cache_failed", err.Error())
}
}
var rows []model.AppPopupConfig
err := s.db.WithContext(ctx).
Where("sys_origin = ? AND scene = ?", sysOrigin, scene).
Order("priority DESC").
Order("id ASC").
Find(&rows).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return []cachedConfig{}, nil
}
if err != nil {
return nil, err
}
configs := make([]cachedConfig, 0, len(rows))
for _, row := range rows {
configs = append(configs, toCachedConfig(row))
}
if s.cache != nil {
if encoded, err := json.Marshal(configs); err == nil {
_ = s.cache.Set(ctx, cacheKey, string(encoded), configCacheExpiration).Err()
}
}
return configs, nil
}
func appPopupConfigCacheKey(sysOrigin, scene string) string {
return "app_popup:configs:" + normalizeSysOrigin(sysOrigin) + ":" + normalizeScene(scene)
}
func (s *Service) buildPopupItem(cfg cachedConfig, req EntryQueryRequest, userID int64, now time.Time) PopupItem {
content := parsePopupContent(cfg.ContentJSON)
item := PopupItem{
Value: true,
Limit: normalizeLimitDays(cfg.LimitDays),
PopupID: cfg.ID,
Version: normalizeVersion(cfg.Version),
Priority: cfg.Priority,
Title: content.Title,
Image: content.Image,
JumpType: strings.TrimSpace(cfg.JumpType),
JumpURL: strings.TrimSpace(cfg.JumpURL),
Reason: "",
Content: content.Raw,
}
if !cfg.Enabled {
item.Value = false
item.Reason = reasonDisabled
return item
}
if cfg.StartTime != nil && !cfg.StartTime.IsZero() && now.Before(*cfg.StartTime) {
item.Value = false
item.Reason = reasonNotStarted
return item
}
if cfg.EndTime != nil && !cfg.EndTime.IsZero() && !now.Before(*cfg.EndTime) {
item.Value = false
item.Reason = reasonExpired
return item
}
if !matchPlatforms(cfg.PlatformsJSON, req.Platform) {
item.Value = false
item.Reason = reasonPlatform
return item
}
if !matchMinVersion(req.AppVersion, cfg.MinAppVersion) {
item.Value = false
item.Reason = reasonMinVersion
return item
}
if !matchMaxVersion(req.AppVersion, cfg.MaxAppVersion) {
item.Value = false
item.Reason = reasonMaxVersion
return item
}
if !matchUserScope(cfg.UserScopeType, cfg.UserScopeJSON, userID) {
item.Value = false
item.Reason = reasonUserScope
return item
}
return item
}
func normalizeLimitDays(limit int) int {
if limit < 0 {
return 0
}
return limit
}
func normalizeVersion(version int) int {
if version <= 0 {
return 1
}
return version
}
func matchPlatforms(raw, platform string) bool {
raw = strings.TrimSpace(raw)
platform = normalizePlatform(platform)
if raw == "" || raw == "null" || platform == "" {
return true
}
var platforms []string
if err := json.Unmarshal([]byte(raw), &platforms); err != nil {
return false
}
if len(platforms) == 0 {
return true
}
for _, item := range platforms {
if normalizePlatform(item) == platform {
return true
}
}
return false
}
func matchMinVersion(appVersion, minVersion string) bool {
appVersion = strings.TrimSpace(appVersion)
minVersion = strings.TrimSpace(minVersion)
if appVersion == "" || minVersion == "" {
return true
}
return compareVersion(appVersion, minVersion) >= 0
}
func matchMaxVersion(appVersion, maxVersion string) bool {
appVersion = strings.TrimSpace(appVersion)
maxVersion = strings.TrimSpace(maxVersion)
if appVersion == "" || maxVersion == "" {
return true
}
return compareVersion(appVersion, maxVersion) <= 0
}
func compareVersion(left, right string) int {
leftParts := parseVersion(left)
rightParts := parseVersion(right)
maxLen := len(leftParts)
if len(rightParts) > maxLen {
maxLen = len(rightParts)
}
for i := 0; i < maxLen; i++ {
var lv, rv int
if i < len(leftParts) {
lv = leftParts[i]
}
if i < len(rightParts) {
rv = rightParts[i]
}
if lv > rv {
return 1
}
if lv < rv {
return -1
}
}
return 0
}
func parseVersion(version string) []int {
version = strings.TrimSpace(version)
if version == "" {
return []int{0}
}
parts := strings.Split(version, ".")
result := make([]int, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
result = append(result, 0)
continue
}
digits := strings.Builder{}
for _, r := range part {
if r < '0' || r > '9' {
break
}
digits.WriteRune(r)
}
if digits.Len() == 0 {
result = append(result, 0)
continue
}
value, err := strconv.Atoi(digits.String())
if err != nil {
result = append(result, 0)
continue
}
result = append(result, value)
}
return result
}
func matchUserScope(scopeType, raw string, userID int64) bool {
switch strings.ToUpper(strings.TrimSpace(scopeType)) {
case "", "ALL":
return true
case "WHITELIST":
return userID > 0 && userScopeContains(raw, userID)
case "BLACKLIST":
return userID <= 0 || !userScopeContains(raw, userID)
default:
return false
}
}
func userScopeContains(raw string, userID int64) bool {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "null" {
return false
}
var payload struct {
UserIDs []int64 `json:"userIds"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return false
}
for _, item := range payload.UserIDs {
if item == userID {
return true
}
}
return false
}

View File

@ -0,0 +1,351 @@
package apppopup
import (
"context"
"encoding/json"
"strconv"
"testing"
"time"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestQueryEntryPopupsReturnsConfigWithoutUserCooldown(t *testing.T) {
db := newTestDB(t)
now := time.Date(2026, 5, 7, 12, 0, 0, 0, time.UTC)
mustCreatePopupConfig(t, db, model.AppPopupConfig{
ID: 1001,
SysOrigin: "LIKEI",
PopupKey: "invite",
Name: "邀请活动弹窗",
Scene: defaultScene,
Enabled: true,
LimitDays: 7,
Priority: 100,
Version: 3,
ContentJSON: `{"title":"Invite friends","image":"https://cdn.example.com/invite.png"}`,
JumpType: "H5",
JumpURL: "/h5/app-invite/landing.html",
})
mustCreatePopupConfig(t, db, model.AppPopupConfig{
ID: 1002,
SysOrigin: "LIKEI",
PopupKey: "game",
Name: "游戏引导弹窗",
Scene: defaultScene,
Enabled: true,
LimitDays: 0,
Priority: 90,
Version: 1,
ContentJSON: `{"title":"Play games","image":"https://cdn.example.com/game.png"}`,
JumpType: "APP_ROUTE",
JumpURL: "/game-center",
})
service := NewService(config.Config{}, db, nil)
service.now = func() time.Time { return now }
resp, err := service.QueryEntryPopups(context.Background(), AuthUser{
UserID: 123,
SysOrigin: "LIKEI",
}, EntryQueryRequest{Scene: defaultScene, Platform: "ios", AppVersion: "3.12.0"})
if err != nil {
t.Fatalf("QueryEntryPopups() error = %v", err)
}
if len(resp.Queue) != 2 || resp.Queue[0] != "invite" || resp.Queue[1] != "game" {
t.Fatalf("queue = %#v, want [invite game]", resp.Queue)
}
invite := resp.Items["invite"]
if !invite.Value || invite.Limit != 7 || invite.PopupID != 1001 || invite.Version != 3 {
t.Fatalf("invite = %+v, want enabled limit 7 id 1001 version 3", invite)
}
if invite.Title != "Invite friends" || invite.Image != "https://cdn.example.com/invite.png" {
t.Fatalf("invite content = title %q image %q", invite.Title, invite.Image)
}
game := resp.Items["game"]
if !game.Value || game.Limit != 0 {
t.Fatalf("game = %+v, want enabled limit 0", game)
}
}
func TestQueryEntryPopupsFiltersByConfigRulesOnly(t *testing.T) {
db := newTestDB(t)
now := time.Date(2026, 5, 7, 12, 0, 0, 0, time.UTC)
future := now.Add(time.Hour)
past := now.Add(-time.Hour)
mustCreatePopupConfig(t, db, model.AppPopupConfig{
ID: 1001,
SysOrigin: "LIKEI",
PopupKey: "invite",
Scene: defaultScene,
Enabled: false,
LimitDays: 7,
Priority: 100,
Version: 1,
UserScopeType: "ALL",
})
mustCreatePopupConfig(t, db, model.AppPopupConfig{
ID: 1002,
SysOrigin: "LIKEI",
PopupKey: "game",
Scene: defaultScene,
Enabled: true,
LimitDays: 7,
Priority: 90,
Version: 1,
PlatformsJSON: `["android"]`,
UserScopeType: "ALL",
})
mustCreatePopupConfig(t, db, model.AppPopupConfig{
ID: 1003,
SysOrigin: "LIKEI",
PopupKey: "future",
Scene: defaultScene,
Enabled: true,
LimitDays: 7,
Priority: 80,
Version: 1,
StartTime: &future,
UserScopeType: "ALL",
})
mustCreatePopupConfig(t, db, model.AppPopupConfig{
ID: 1004,
SysOrigin: "LIKEI",
PopupKey: "expired",
Scene: defaultScene,
Enabled: true,
LimitDays: 7,
Priority: 70,
Version: 1,
EndTime: &past,
UserScopeType: "ALL",
})
service := NewService(config.Config{}, db, nil)
service.now = func() time.Time { return now }
resp, err := service.QueryEntryPopups(context.Background(), AuthUser{
UserID: 123,
SysOrigin: "LIKEI",
}, EntryQueryRequest{Platform: "ios", AppVersion: "3.12.0"})
if err != nil {
t.Fatalf("QueryEntryPopups() error = %v", err)
}
if len(resp.Queue) != 0 {
t.Fatalf("queue = %#v, want empty", resp.Queue)
}
assertReason(t, resp.Items["invite"], reasonDisabled)
assertReason(t, resp.Items["game"], reasonPlatform)
assertReason(t, resp.Items["future"], reasonNotStarted)
assertReason(t, resp.Items["expired"], reasonExpired)
}
func TestQueryEntryPopupsVersionBoundaries(t *testing.T) {
db := newTestDB(t)
mustCreatePopupConfig(t, db, model.AppPopupConfig{
ID: 1001,
SysOrigin: "LIKEI",
PopupKey: "invite",
Scene: defaultScene,
Enabled: true,
LimitDays: 7,
Priority: 100,
Version: 1,
MinAppVersion: "3.9.8",
MaxAppVersion: "3.13.0",
UserScopeType: "ALL",
})
service := NewService(config.Config{}, db, nil)
resp, err := service.QueryEntryPopups(context.Background(), AuthUser{
UserID: 123,
SysOrigin: "LIKEI",
}, EntryQueryRequest{AppVersion: "3.12.0"})
if err != nil {
t.Fatalf("QueryEntryPopups() error = %v", err)
}
if !resp.Items["invite"].Value {
t.Fatalf("invite = %+v, want value true", resp.Items["invite"])
}
resp, err = service.QueryEntryPopups(context.Background(), AuthUser{
UserID: 123,
SysOrigin: "LIKEI",
}, EntryQueryRequest{AppVersion: "3.8.9"})
if err != nil {
t.Fatalf("QueryEntryPopups() error = %v", err)
}
assertReason(t, resp.Items["invite"], reasonMinVersion)
resp, err = service.QueryEntryPopups(context.Background(), AuthUser{
UserID: 123,
SysOrigin: "LIKEI",
}, EntryQueryRequest{AppVersion: "3.14.0"})
if err != nil {
t.Fatalf("QueryEntryPopups() error = %v", err)
}
assertReason(t, resp.Items["invite"], reasonMaxVersion)
}
func TestEntryQueryResponseMarshalFlattensPopupKeys(t *testing.T) {
resp := EntryQueryResponse{
RequestID: "req-1",
Queue: []string{"invite"},
Items: map[string]PopupItem{
"invite": {Value: true, Limit: 7},
},
}
raw, err := json.Marshal(resp)
if err != nil {
t.Fatalf("MarshalJSON() error = %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("Unmarshal response error = %v", err)
}
if decoded["requestId"] != "req-1" {
t.Fatalf("requestId = %v, want req-1", decoded["requestId"])
}
invite, ok := decoded["invite"].(map[string]any)
if !ok {
t.Fatalf("invite field missing in %s", raw)
}
if invite["value"] != true || invite["limit"] != float64(7) {
t.Fatalf("invite = %#v, want value true limit 7", invite)
}
}
func TestAdminConfigCRUD(t *testing.T) {
db := newTestDB(t)
service := NewService(config.Config{}, db, nil)
saved, err := service.SaveAdminConfig(context.Background(), SaveAdminConfigRequest{
SysOrigin: "LIKEI",
Scene: defaultScene,
PopupKey: "invite",
Name: "邀请弹窗",
Enabled: true,
LimitDays: 7,
Priority: 100,
Version: 1,
Title: "Invite friends",
Image: "https://cdn.example.com/invite.png",
JumpType: "H5",
JumpURL: "/h5/app-invite/landing.html",
Description: "邀请活动首页弹窗",
})
if err != nil {
t.Fatalf("SaveAdminConfig() error = %v", err)
}
if saved.ID == "" || saved.PopupKey != "invite" || saved.LimitDays != 7 || saved.Description != "邀请活动首页弹窗" {
t.Fatalf("saved = %+v", saved)
}
savedID, err := strconv.ParseInt(saved.ID, 10, 64)
if err != nil {
t.Fatalf("parse saved id: %v", err)
}
page, err := service.PageAdminConfigs(context.Background(), "LIKEI", defaultScene, "inv", 1, 20)
if err != nil {
t.Fatalf("PageAdminConfigs() error = %v", err)
}
if page.Total != 1 || len(page.Records) != 1 {
t.Fatalf("page = %+v, want one record", page)
}
updated, err := service.SaveAdminConfig(context.Background(), SaveAdminConfigRequest{
ID: flexibleInt64{value: savedID},
SysOrigin: "LIKEI",
Scene: defaultScene,
PopupKey: "invite",
Name: "邀请弹窗",
Enabled: false,
LimitDays: 0,
Priority: 90,
Version: 2,
Description: "每次进入都弹",
})
if err != nil {
t.Fatalf("SaveAdminConfig(update) error = %v", err)
}
if updated.Enabled || updated.LimitDays != 0 || updated.Version != 2 {
t.Fatalf("updated = %+v, want disabled limit 0 version 2", updated)
}
if err := service.DeleteAdminConfig(context.Background(), savedID); err != nil {
t.Fatalf("DeleteAdminConfig() error = %v", err)
}
page, err = service.PageAdminConfigs(context.Background(), "LIKEI", defaultScene, "", 1, 20)
if err != nil {
t.Fatalf("PageAdminConfigs(after delete) error = %v", err)
}
if page.Total != 0 {
t.Fatalf("total = %d, want 0", page.Total)
}
}
func TestAdminConfigRejectsDuplicateKey(t *testing.T) {
db := newTestDB(t)
service := NewService(config.Config{}, db, nil)
_, err := service.SaveAdminConfig(context.Background(), SaveAdminConfigRequest{
SysOrigin: "LIKEI",
Scene: defaultScene,
PopupKey: "invite",
})
if err != nil {
t.Fatalf("SaveAdminConfig(first) error = %v", err)
}
_, err = service.SaveAdminConfig(context.Background(), SaveAdminConfigRequest{
SysOrigin: "LIKEI",
Scene: defaultScene,
PopupKey: "invite",
})
if err == nil {
t.Fatalf("SaveAdminConfig(duplicate) error = nil, want error")
}
}
func newTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.AppPopupConfig{}); err != nil {
t.Fatalf("migrate app popup config: %v", err)
}
return db
}
func mustCreatePopupConfig(t *testing.T, db *gorm.DB, row model.AppPopupConfig) {
t.Helper()
if row.Scene == "" {
row.Scene = defaultScene
}
if row.SysOrigin == "" {
row.SysOrigin = defaultSysOrigin
}
if row.Version == 0 {
row.Version = 1
}
if row.UserScopeType == "" {
row.UserScopeType = "ALL"
}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("create popup config %s: %v", row.PopupKey, err)
}
}
func assertReason(t *testing.T, item PopupItem, want string) {
t.Helper()
if item.Value {
t.Fatalf("item = %+v, want value false", item)
}
if item.Reason != want {
t.Fatalf("reason = %q, want %q", item.Reason, want)
}
}

View File

@ -0,0 +1,353 @@
package apppopup
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
const (
defaultScene = "APP_ENTER"
defaultSysOrigin = "LIKEI"
adminTimeLayout = "2006-01-02 15:04:05"
reasonDisabled = "DISABLED"
reasonNotStarted = "NOT_STARTED"
reasonExpired = "EXPIRED"
reasonPlatform = "PLATFORM_NOT_MATCH"
reasonMinVersion = "MIN_VERSION_NOT_MATCH"
reasonMaxVersion = "MAX_VERSION_NOT_MATCH"
reasonUserScope = "USER_SCOPE_NOT_MATCH"
reasonInvalidConfig = "INVALID_CONFIG"
configCacheExpiration = 60 * time.Second
)
type AppError = common.AppError
type AuthUser = common.AuthUser
var NewAppError = common.NewAppError
type dbHandle interface {
WithContext(ctx context.Context) *gorm.DB
}
// Service 负责 APP 进场弹窗配置查询。
type Service struct {
cfg config.Config
db dbHandle
cache redis.Cmdable
now func() time.Time
}
// NewService 创建 APP 进场弹窗服务。
func NewService(cfg config.Config, db dbHandle, cache redis.Cmdable) *Service {
return &Service{
cfg: cfg,
db: db,
cache: cache,
now: time.Now,
}
}
// EntryQueryRequest 是 App 查询进场弹窗的请求。
type EntryQueryRequest struct {
Scene string `json:"scene"`
Platform string `json:"platform"`
AppVersion string `json:"appVersion"`
DeviceID string `json:"deviceId"`
Language string `json:"language"`
}
// EntryQueryResponse 是 App 查询进场弹窗的返回。
type EntryQueryResponse struct {
RequestID string `json:"requestId"`
Queue []string `json:"queue"`
Items map[string]PopupItem `json:"-"`
}
// MarshalJSON 把弹窗 key 展平成 response 顶层字段,例如 invite/game。
func (r EntryQueryResponse) MarshalJSON() ([]byte, error) {
body := map[string]any{
"requestId": r.RequestID,
"queue": r.Queue,
}
for key, item := range r.Items {
if key == "" || key == "requestId" || key == "queue" {
continue
}
body[key] = item
}
return json.Marshal(body)
}
func (r *EntryQueryResponse) set(key string, item PopupItem) {
if r.Items == nil {
r.Items = make(map[string]PopupItem)
}
r.Items[key] = item
}
// PopupItem 是单个弹窗对 App 的返回结构。
type PopupItem struct {
Value bool `json:"value"`
Limit int `json:"limit"`
PopupID int64 `json:"popupId,omitempty"`
Version int `json:"version,omitempty"`
Priority int `json:"priority,omitempty"`
Title string `json:"title,omitempty"`
Image string `json:"image,omitempty"`
JumpType string `json:"jumpType,omitempty"`
JumpURL string `json:"jumpUrl,omitempty"`
Reason string `json:"reason"`
Content map[string]any `json:"content,omitempty"`
}
type popupContent struct {
Title string `json:"title"`
Image string `json:"image"`
Raw map[string]any `json:"-"`
}
func normalizeScene(scene string) string {
scene = strings.ToUpper(strings.TrimSpace(scene))
if scene == "" {
return defaultScene
}
return scene
}
func normalizeSysOrigin(sysOrigin string) string {
sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin))
if sysOrigin == "" {
return defaultSysOrigin
}
return sysOrigin
}
func normalizePopupKey(key string) string {
return strings.ToLower(strings.TrimSpace(key))
}
func normalizePlatform(platform string) string {
return strings.ToLower(strings.TrimSpace(platform))
}
func newRequestID() string {
id, err := utils.NextID()
if err == nil {
return fmt.Sprintf("%d", id)
}
return fmt.Sprintf("%d", time.Now().UnixNano())
}
func parsePopupContent(raw string) popupContent {
raw = strings.TrimSpace(raw)
if raw == "" {
return popupContent{}
}
var values map[string]any
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return popupContent{}
}
return popupContent{
Title: stringFromMap(values, "title"),
Image: firstStringFromMap(values, "image", "imageUrl", "cover"),
Raw: values,
}
}
func stringFromMap(values map[string]any, key string) string {
if values == nil {
return ""
}
value, _ := values[key].(string)
return strings.TrimSpace(value)
}
func firstStringFromMap(values map[string]any, keys ...string) string {
for _, key := range keys {
if value := stringFromMap(values, key); value != "" {
return value
}
}
return ""
}
type cachedConfig struct {
ID int64 `json:"id"`
SysOrigin string `json:"sysOrigin"`
PopupKey string `json:"popupKey"`
Name string `json:"name"`
Scene string `json:"scene"`
Enabled bool `json:"enabled"`
LimitDays int `json:"limitDays"`
Priority int `json:"priority"`
Version int `json:"version"`
StartTime *time.Time `json:"startTime"`
EndTime *time.Time `json:"endTime"`
PlatformsJSON string `json:"platformsJson"`
MinAppVersion string `json:"minAppVersion"`
MaxAppVersion string `json:"maxAppVersion"`
UserScopeType string `json:"userScopeType"`
UserScopeJSON string `json:"userScopeJson"`
ContentJSON string `json:"contentJson"`
JumpType string `json:"jumpType"`
JumpURL string `json:"jumpUrl"`
}
// AdminConfigPageResponse 是后台首页弹窗配置分页响应。
type AdminConfigPageResponse struct {
Records []AdminConfigItem `json:"records"`
Total int64 `json:"total"`
Current int `json:"current"`
Size int `json:"size"`
}
// AdminConfigItem 是后台首页弹窗配置项。
type AdminConfigItem struct {
ID string `json:"id"`
SysOrigin string `json:"sysOrigin"`
Scene string `json:"scene"`
PopupKey string `json:"popupKey"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
LimitDays int `json:"limitDays"`
Priority int `json:"priority"`
Version int `json:"version"`
Title string `json:"title"`
Image string `json:"image"`
JumpType string `json:"jumpType"`
JumpURL string `json:"jumpUrl"`
Description string `json:"description"`
StartTime string `json:"startTime,omitempty"`
EndTime string `json:"endTime,omitempty"`
PlatformsJSON string `json:"platformsJson,omitempty"`
MinAppVersion string `json:"minAppVersion,omitempty"`
MaxAppVersion string `json:"maxAppVersion,omitempty"`
CreateTime string `json:"createTime,omitempty"`
UpdateTime string `json:"updateTime,omitempty"`
}
// SaveAdminConfigRequest 是后台保存首页弹窗配置请求。
type SaveAdminConfigRequest struct {
ID flexibleInt64 `json:"id"`
SysOrigin string `json:"sysOrigin"`
Scene string `json:"scene"`
PopupKey string `json:"popupKey"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
LimitDays int `json:"limitDays"`
Priority int `json:"priority"`
Version int `json:"version"`
Title string `json:"title"`
Image string `json:"image"`
JumpType string `json:"jumpType"`
JumpURL string `json:"jumpUrl"`
Description string `json:"description"`
PlatformsJSON string `json:"platformsJson"`
MinAppVersion string `json:"minAppVersion"`
MaxAppVersion string `json:"maxAppVersion"`
}
type flexibleInt64 struct {
value int64
}
func (v *flexibleInt64) UnmarshalJSON(data []byte) error {
var raw any
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
switch value := raw.(type) {
case float64:
v.value = int64(value)
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil {
v.value = 0
return nil
}
v.value = parsed
}
return nil
}
func (v flexibleInt64) Int64() int64 {
return v.value
}
func toCachedConfig(row model.AppPopupConfig) cachedConfig {
return cachedConfig{
ID: row.ID,
SysOrigin: row.SysOrigin,
PopupKey: row.PopupKey,
Name: row.Name,
Scene: row.Scene,
Enabled: row.Enabled,
LimitDays: row.LimitDays,
Priority: row.Priority,
Version: row.Version,
StartTime: row.StartTime,
EndTime: row.EndTime,
PlatformsJSON: row.PlatformsJSON,
MinAppVersion: row.MinAppVersion,
MaxAppVersion: row.MaxAppVersion,
UserScopeType: row.UserScopeType,
UserScopeJSON: row.UserScopeJSON,
ContentJSON: row.ContentJSON,
JumpType: row.JumpType,
JumpURL: row.JumpURL,
}
}
func adminItemFromModel(row model.AppPopupConfig) AdminConfigItem {
content := parsePopupContent(row.ContentJSON)
return AdminConfigItem{
ID: strconv.FormatInt(row.ID, 10),
SysOrigin: row.SysOrigin,
Scene: row.Scene,
PopupKey: row.PopupKey,
Name: row.Name,
Enabled: row.Enabled,
LimitDays: row.LimitDays,
Priority: row.Priority,
Version: normalizeVersion(row.Version),
Title: content.Title,
Image: content.Image,
JumpType: row.JumpType,
JumpURL: row.JumpURL,
Description: row.Remark,
StartTime: formatAdminTimePtr(row.StartTime),
EndTime: formatAdminTimePtr(row.EndTime),
PlatformsJSON: row.PlatformsJSON,
MinAppVersion: row.MinAppVersion,
MaxAppVersion: row.MaxAppVersion,
CreateTime: formatAdminTime(row.CreateTime),
UpdateTime: formatAdminTime(row.UpdateTime),
}
}
func formatAdminTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(adminTimeLayout)
}
func formatAdminTimePtr(t *time.Time) string {
if t == nil {
return ""
}
return formatAdminTime(*t)
}

View File

@ -14,10 +14,12 @@ import (
)
const adminTimeLayout = "2006-01-02 15:04:05"
const baishunRoomCategory = "CHAT_ROOM"
type adminGameRow struct {
ID int64
SysOrigin string
Profile string
InternalGameID string
Name string
Category string
@ -57,16 +59,21 @@ type adminGameRow struct {
func (s *BaishunService) PageAdminGames(
ctx context.Context,
sysOrigin string,
profile string,
keyword string,
showcase *bool,
cursor int,
limit int,
) (*AdminBaishunGamePageResponse, error) {
sysOrigin = normalizeAdminSysOrigin(sysOrigin)
profile, err := s.requestProfile(ctx, sysOrigin, profile)
if err != nil {
return nil, err
}
cursor = normalizePageCursor(cursor)
limit = normalizePageLimit(limit)
countQuery := s.buildAdminGameQuery(ctx, sysOrigin, keyword, showcase)
countQuery := s.buildAdminGameQuery(ctx, sysOrigin, profile, keyword, showcase)
var total int64
if err := countQuery.Count(&total).Error; err != nil {
@ -74,11 +81,12 @@ func (s *BaishunService) PageAdminGames(
}
var rows []adminGameRow
listQuery := s.buildAdminGameQuery(ctx, sysOrigin, keyword, showcase)
listQuery := s.buildAdminGameQuery(ctx, sysOrigin, profile, keyword, showcase)
if err := listQuery.
Select(`
cfg.id AS id,
cfg.sys_origin AS sys_origin,
ext.profile AS profile,
cfg.game_id AS internal_game_id,
cfg.name AS name,
cfg.category AS category,
@ -138,12 +146,16 @@ func (s *BaishunService) PageAdminGames(
// SaveAdminGame 保存后台百顺游戏配置。
func (s *BaishunService) SaveAdminGame(ctx context.Context, req SaveAdminBaishunGameRequest) (*AdminBaishunGameItem, error) {
sysOrigin := normalizeAdminSysOrigin(req.SysOrigin)
runtimeCfg, err := s.resolveRuntimeConfig(ctx, sysOrigin)
profile, err := s.requestProfile(ctx, sysOrigin, req.Profile)
if err != nil {
return nil, err
}
runtimeCfg, err := s.resolveRuntimeConfigForProfile(ctx, sysOrigin, profile)
if err != nil {
return nil, err
}
catalog, err := s.findCatalogForAdmin(ctx, sysOrigin, req.VendorGameID, req.GameID)
catalog, err := s.findCatalogForAdmin(ctx, sysOrigin, profile, req.VendorGameID, req.GameID)
if err != nil {
return nil, err
}
@ -166,7 +178,7 @@ func (s *BaishunService) SaveAdminGame(ctx context.Context, req SaveAdminBaishun
if name == "" {
return nil, NewAppError(http.StatusBadRequest, "name_required", "name is required")
}
category := defaultIfBlank(strings.TrimSpace(req.Category), "OTHER")
category := defaultIfBlank(strings.TrimSpace(req.Category), baishunRoomCategory)
cover := defaultIfBlank(strings.TrimSpace(req.Cover), defaultIfBlank(catalog.Cover, catalog.PreviewURL))
clientOrigin := defaultIfBlank(strings.TrimSpace(req.ClientOrigin), "COMMON")
gameCode := defaultIfBlank(strings.TrimSpace(req.GameCode), strconv.Itoa(req.VendorGameID))
@ -197,11 +209,12 @@ func (s *BaishunService) SaveAdminGame(ctx context.Context, req SaveAdminBaishun
}
}()
cfg, err := s.findAdminConfigForSave(tx, sysOrigin, req.ID, gameID, req.VendorGameID)
cfg, err := s.findAdminConfigForSave(tx, sysOrigin, profile, req.ID, gameID, req.VendorGameID)
if err != nil {
tx.Rollback()
return nil, err
}
showcase := resolveAdminShowcase(req.Showcase, cfg.Showcase, cfg.ID == 0)
cfgValues := map[string]any{
"sys_origin": sysOrigin,
@ -212,7 +225,7 @@ func (s *BaishunService) SaveAdminGame(ctx context.Context, req SaveAdminBaishun
"game_code": gameCode,
"cover": cover,
"amounts": strings.TrimSpace(req.Amounts),
"is_showcase": req.Showcase,
"is_showcase": showcase,
"sort": req.Sort,
"height": req.Height,
"width": req.Width,
@ -238,7 +251,7 @@ func (s *BaishunService) SaveAdminGame(ctx context.Context, req SaveAdminBaishun
GameCode: gameCode,
Cover: cover,
Amounts: strings.TrimSpace(req.Amounts),
Showcase: req.Showcase,
Showcase: showcase,
Sort: req.Sort,
Height: req.Height,
Width: req.Width,
@ -261,7 +274,7 @@ func (s *BaishunService) SaveAdminGame(ctx context.Context, req SaveAdminBaishun
}
var ext model.SysGameListVendorExt
err = tx.Where("game_list_config_id = ? AND vendor_type = ?", cfg.ID, baishunVendorType).First(&ext).Error
err = tx.Where("game_list_config_id = ? AND vendor_type = ? AND profile = ?", cfg.ID, baishunVendorType, profile).First(&ext).Error
if err != nil && err != gorm.ErrRecordNotFound {
tx.Rollback()
return nil, err
@ -279,6 +292,7 @@ func (s *BaishunService) SaveAdminGame(ctx context.Context, req SaveAdminBaishun
ID: nextID,
GameListConfigID: cfg.ID,
SysOrigin: sysOrigin,
Profile: profile,
VendorType: baishunVendorType,
VendorGameID: strconv.Itoa(req.VendorGameID),
LaunchMode: launchMode,
@ -303,6 +317,7 @@ func (s *BaishunService) SaveAdminGame(ctx context.Context, req SaveAdminBaishun
Where("id = ?", ext.ID).
Updates(map[string]any{
"sys_origin": sysOrigin,
"profile": profile,
"vendor_game_id": strconv.Itoa(req.VendorGameID),
"launch_mode": launchMode,
"package_version": packageVersion,
@ -325,40 +340,63 @@ func (s *BaishunService) SaveAdminGame(ctx context.Context, req SaveAdminBaishun
if err := tx.Commit().Error; err != nil {
return nil, err
}
return s.getAdminGame(ctx, sysOrigin, cfg.ID)
s.clearJavaGameListCache(ctx)
return s.getAdminGame(ctx, sysOrigin, profile, cfg.ID)
}
// DeleteAdminGame 删除后台百顺游戏配置,不影响目录表。
func (s *BaishunService) DeleteAdminGame(ctx context.Context, sysOrigin string, id int64) error {
func (s *BaishunService) DeleteAdminGame(ctx context.Context, sysOrigin string, profile string, id int64) error {
if id <= 0 {
return NewAppError(http.StatusBadRequest, "id_required", "id is required")
}
sysOrigin = normalizeAdminSysOrigin(sysOrigin)
profile, err := s.requestProfile(ctx, sysOrigin, profile)
if err != nil {
return err
}
tx := s.repo.DB.WithContext(ctx).Begin()
if tx.Error != nil {
return tx.Error
}
if err := tx.Where("game_list_config_id = ? AND vendor_type = ?", id, baishunVendorType).
if err := tx.Where("game_list_config_id = ? AND vendor_type = ? AND profile = ?", id, baishunVendorType, profile).
Delete(&model.SysGameListVendorExt{}).Error; err != nil {
tx.Rollback()
return err
}
if err := tx.Where("id = ? AND sys_origin = ? AND game_origin = ?", id, sysOrigin, baishunVendorType).
Delete(&model.SysGameListConfig{}).Error; err != nil {
var remaining int64
if err := tx.Model(&model.SysGameListVendorExt{}).
Where("game_list_config_id = ? AND vendor_type = ?", id, baishunVendorType).
Count(&remaining).Error; err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
if remaining == 0 {
if err := tx.Where("id = ? AND sys_origin = ? AND game_origin = ?", id, sysOrigin, baishunVendorType).
Delete(&model.SysGameListConfig{}).Error; err != nil {
tx.Rollback()
return err
}
}
if err := tx.Commit().Error; err != nil {
return err
}
s.clearJavaGameListCache(ctx)
return nil
}
// ImportCatalogGames 把本地目录中尚未进入后台列表的百顺游戏补齐成可配置记录。
func (s *BaishunService) ImportCatalogGames(ctx context.Context, sysOrigin string, vendorGameIDs []int) (*ImportCatalogResponse, error) {
func (s *BaishunService) ImportCatalogGames(ctx context.Context, sysOrigin string, profile string, vendorGameIDs []int, defaultShowcase ...bool) (*ImportCatalogResponse, error) {
sysOrigin = normalizeAdminSysOrigin(sysOrigin)
profile, err := s.requestProfile(ctx, sysOrigin, profile)
if err != nil {
return nil, err
}
tx := s.repo.DB.WithContext(ctx).Begin()
if tx.Error != nil {
return nil, tx.Error
}
resp, err := s.importCatalogGamesTx(tx, normalizeAdminSysOrigin(sysOrigin), buildVendorGameIDFilter(vendorGameIDs))
resp, err := s.importCatalogGamesTx(tx, sysOrigin, profile, buildVendorGameIDFilter(vendorGameIDs), resolveImportDefaultShowcase(defaultShowcase...))
if err != nil {
tx.Rollback()
return nil, err
@ -366,6 +404,7 @@ func (s *BaishunService) ImportCatalogGames(ctx context.Context, sysOrigin strin
if err := tx.Commit().Error; err != nil {
return nil, err
}
s.clearJavaGameListCache(ctx)
return resp, nil
}
@ -375,7 +414,7 @@ func (s *BaishunService) SyncAdminGames(ctx context.Context, req SyncCatalogRequ
if err != nil {
return nil, err
}
importResp, err := s.ImportCatalogGames(ctx, req.SysOrigin, req.VendorGameIDs)
importResp, err := s.ImportCatalogGames(ctx, req.SysOrigin, req.Profile, req.VendorGameIDs, resolveBoolPtrDefault(req.DefaultShowcase, true))
if err != nil {
return nil, err
}
@ -388,13 +427,14 @@ func (s *BaishunService) SyncAdminGames(ctx context.Context, req SyncCatalogRequ
}, nil
}
func (s *BaishunService) buildAdminGameQuery(ctx context.Context, sysOrigin, keyword string, showcase *bool) *gorm.DB {
func (s *BaishunService) buildAdminGameQuery(ctx context.Context, sysOrigin, profile, keyword string, showcase *bool) *gorm.DB {
query := s.repo.DB.WithContext(ctx).
Table("sys_game_list_config AS cfg").
Joins("LEFT JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.vendor_type = ?", baishunVendorType).
Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.vendor_type = ? AND ext.profile = ?", baishunVendorType, profile).
Joins(`
LEFT JOIN baishun_game_catalog cat
ON cat.sys_origin = cfg.sys_origin
AND cat.profile = ext.profile
AND (
CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id
OR cat.internal_game_id = cfg.game_id
@ -417,13 +457,14 @@ func (s *BaishunService) buildAdminGameQuery(ctx context.Context, sysOrigin, key
return query
}
func (s *BaishunService) getAdminGame(ctx context.Context, sysOrigin string, id int64) (*AdminBaishunGameItem, error) {
func (s *BaishunService) getAdminGame(ctx context.Context, sysOrigin string, profile string, id int64) (*AdminBaishunGameItem, error) {
var rows []adminGameRow
err := s.buildAdminGameQuery(ctx, sysOrigin, "", nil).
err := s.buildAdminGameQuery(ctx, sysOrigin, profile, "", nil).
Where("cfg.id = ?", id).
Select(`
cfg.id AS id,
cfg.sys_origin AS sys_origin,
ext.profile AS profile,
cfg.game_id AS internal_game_id,
cfg.name AS name,
cfg.category AS category,
@ -470,8 +511,8 @@ func (s *BaishunService) getAdminGame(ctx context.Context, sysOrigin string, id
return nil, NewAppError(http.StatusNotFound, "game_not_found", "admin baishun game not found")
}
func (s *BaishunService) findCatalogForAdmin(ctx context.Context, sysOrigin string, vendorGameID int, gameID string) (model.BaishunGameCatalog, error) {
query := s.repo.DB.WithContext(ctx).Where("sys_origin = ?", sysOrigin)
func (s *BaishunService) findCatalogForAdmin(ctx context.Context, sysOrigin string, profile string, vendorGameID int, gameID string) (model.BaishunGameCatalog, error) {
query := s.repo.DB.WithContext(ctx).Where("sys_origin = ? AND profile = ?", sysOrigin, profile)
switch {
case vendorGameID > 0:
query = query.Where("vendor_game_id = ?", vendorGameID)
@ -491,19 +532,31 @@ func (s *BaishunService) findCatalogForAdmin(ctx context.Context, sysOrigin stri
return catalog, nil
}
func (s *BaishunService) findAdminConfigForSave(tx *gorm.DB, sysOrigin string, id int64, gameID string, vendorGameID int) (model.SysGameListConfig, error) {
func (s *BaishunService) findAdminConfigForSave(tx *gorm.DB, sysOrigin string, profile string, id int64, gameID string, vendorGameID int) (model.SysGameListConfig, error) {
var cfg model.SysGameListConfig
if id > 0 {
err := tx.Where("id = ? AND sys_origin = ? AND game_origin = ?", id, sysOrigin, baishunVendorType).First(&cfg).Error
if err == gorm.ErrRecordNotFound {
return model.SysGameListConfig{}, NewAppError(http.StatusNotFound, "game_not_found", "admin baishun game not found")
}
if err == nil {
var extCount int64
err = tx.Model(&model.SysGameListVendorExt{}).
Where("game_list_config_id = ? AND vendor_type = ? AND profile = ?", cfg.ID, baishunVendorType, profile).
Count(&extCount).Error
if err != nil {
return model.SysGameListConfig{}, err
}
if extCount == 0 {
return model.SysGameListConfig{}, NewAppError(http.StatusNotFound, "game_not_found", "admin baishun game not found")
}
}
return cfg, err
}
if vendorGameID > 0 {
var ext model.SysGameListVendorExt
err := tx.Where("sys_origin = ? AND vendor_type = ? AND vendor_game_id = ?", sysOrigin, baishunVendorType, strconv.Itoa(vendorGameID)).First(&ext).Error
err := tx.Where("sys_origin = ? AND profile = ? AND vendor_type = ? AND vendor_game_id = ?", sysOrigin, profile, baishunVendorType, strconv.Itoa(vendorGameID)).First(&ext).Error
if err != nil && err != gorm.ErrRecordNotFound {
return model.SysGameListConfig{}, err
}
@ -521,23 +574,31 @@ func (s *BaishunService) findAdminConfigForSave(tx *gorm.DB, sysOrigin string, i
if strings.TrimSpace(gameID) == "" {
return model.SysGameListConfig{}, nil
}
err := tx.Where("sys_origin = ? AND game_origin = ? AND game_id = ?", sysOrigin, baishunVendorType, strings.TrimSpace(gameID)).First(&cfg).Error
if err == gorm.ErrRecordNotFound {
err := tx.Table("sys_game_list_config AS cfg").
Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.vendor_type = ? AND ext.profile = ?", baishunVendorType, profile).
Where("cfg.sys_origin = ? AND cfg.game_origin = ? AND cfg.game_id = ?", sysOrigin, baishunVendorType, strings.TrimSpace(gameID)).
Select("cfg.*").
Limit(1).
Scan(&cfg).Error
if err != nil {
return model.SysGameListConfig{}, err
}
if cfg.ID == 0 {
return model.SysGameListConfig{}, nil
}
return cfg, err
return cfg, nil
}
func (s *BaishunService) importCatalogGamesTx(tx *gorm.DB, sysOrigin string, filter map[int]struct{}) (*ImportCatalogResponse, error) {
func (s *BaishunService) importCatalogGamesTx(tx *gorm.DB, sysOrigin string, profile string, filter map[int]struct{}, defaultShowcase bool) (*ImportCatalogResponse, error) {
ctx := tx.Statement.Context
if ctx == nil {
ctx = context.Background()
}
runtimeCfg, err := s.resolveRuntimeConfig(ctx, sysOrigin)
runtimeCfg, err := s.resolveRuntimeConfigForProfile(ctx, sysOrigin, profile)
if err != nil {
return nil, err
}
query := tx.Where("sys_origin = ? AND status = ?", sysOrigin, baishunCatalogEnabled)
query := tx.Where("sys_origin = ? AND profile = ? AND status = ?", sysOrigin, profile, baishunCatalogEnabled)
if len(filter) > 0 {
ids := make([]int, 0, len(filter))
for id := range filter {
@ -557,80 +618,123 @@ func (s *BaishunService) importCatalogGamesTx(tx *gorm.DB, sysOrigin string, fil
now := time.Now()
var ext model.SysGameListVendorExt
err := tx.Where("sys_origin = ? AND vendor_type = ? AND vendor_game_id = ?", sysOrigin, baishunVendorType, vendorGameID).First(&ext).Error
err := tx.Where("sys_origin = ? AND profile = ? AND vendor_type = ? AND vendor_game_id = ?", sysOrigin, profile, baishunVendorType, vendorGameID).First(&ext).Error
if err != nil && err != gorm.ErrRecordNotFound {
return nil, err
}
if err == nil && ext.GameListConfigID > 0 {
resp.Existing++
continue
var cfg model.SysGameListConfig
cfgErr := tx.Where("id = ? AND sys_origin = ? AND game_origin = ?", ext.GameListConfigID, sysOrigin, baishunVendorType).First(&cfg).Error
if cfgErr == nil {
resp.Existing++
continue
}
if cfgErr != gorm.ErrRecordNotFound {
return nil, cfgErr
}
}
var cfg model.SysGameListConfig
err = tx.Where("sys_origin = ? AND game_origin = ? AND game_id = ?", sysOrigin, baishunVendorType, catalog.InternalGameID).First(&cfg).Error
if err != nil && err != gorm.ErrRecordNotFound {
return nil, err
nextCfgID, idErr := utils.NextID()
if idErr != nil {
return nil, idErr
}
if err == gorm.ErrRecordNotFound {
nextID, idErr := utils.NextID()
if idErr != nil {
return nil, idErr
}
cfg = model.SysGameListConfig{
ID: nextID,
SysOrigin: sysOrigin,
GameOrigin: baishunVendorType,
GameID: defaultIfBlank(catalog.InternalGameID, fmt.Sprintf("bs_%d", catalog.VendorGameID)),
Name: catalog.Name,
Category: "OTHER",
GameCode: vendorGameID,
Cover: defaultIfBlank(catalog.Cover, catalog.PreviewURL),
Showcase: false,
Sort: int64(catalog.VendorGameID),
FullScreen: true,
ClientOrigin: "COMMON",
GameMode: formatAdminGameModeRaw(baishunFixedGameMode),
}
if err := tx.Create(&cfg).Error; err != nil {
return nil, err
}
cfg := model.SysGameListConfig{
ID: nextCfgID,
SysOrigin: sysOrigin,
GameOrigin: baishunVendorType,
GameID: defaultIfBlank(catalog.InternalGameID, fmt.Sprintf("bs_%d", catalog.VendorGameID)),
Name: catalog.Name,
Category: baishunRoomCategory,
GameCode: vendorGameID,
Cover: defaultIfBlank(catalog.Cover, catalog.PreviewURL),
Showcase: defaultShowcase,
Sort: int64(catalog.VendorGameID),
FullScreen: true,
ClientOrigin: "COMMON",
GameMode: formatAdminGameModeRaw(baishunFixedGameMode),
}
if err := tx.Create(&cfg).Error; err != nil {
return nil, err
}
nextID, idErr := utils.NextID()
if idErr != nil {
return nil, idErr
}
newExt := model.SysGameListVendorExt{
ID: nextID,
GameListConfigID: cfg.ID,
SysOrigin: sysOrigin,
VendorType: baishunVendorType,
VendorGameID: vendorGameID,
LaunchMode: baishunLaunchModeRemote,
PackageVersion: catalog.PackageVersion,
PackageURL: catalog.DownloadURL,
PreviewURL: catalog.PreviewURL,
Orientation: catalog.Orientation,
SafeHeight: catalog.SafeHeight,
GSP: defaultAdminGSP(catalog.GSP, runtimeCfg.gsp()),
BridgeSchemaVersion: "1.0",
ExtraJSON: updateExtExtraJSONWithGameType("", nil, baishunVendorType, false),
Enabled: true,
CreateTime: now,
UpdateTime: now,
extraJSON := updateExtExtraJSONWithGameType(ext.ExtraJSON, nil, baishunVendorType, ext.ID != 0)
extValues := map[string]any{
"game_list_config_id": cfg.ID,
"sys_origin": sysOrigin,
"profile": profile,
"vendor_type": baishunVendorType,
"vendor_game_id": vendorGameID,
"launch_mode": baishunLaunchModeRemote,
"package_version": catalog.PackageVersion,
"package_url": catalog.DownloadURL,
"preview_url": catalog.PreviewURL,
"orientation": catalog.Orientation,
"safe_height": catalog.SafeHeight,
"gsp": defaultAdminGSP(catalog.GSP, runtimeCfg.gsp()),
"bridge_schema_version": "1.0",
"extra_json": extraJSON,
"enabled": true,
"update_time": now,
}
if err := tx.Create(&newExt).Error; err != nil {
return nil, err
if ext.ID > 0 {
if err := tx.Model(&model.SysGameListVendorExt{}).
Where("id = ?", ext.ID).
Updates(extValues).Error; err != nil {
return nil, err
}
} else {
newExt := model.SysGameListVendorExt{
ID: nextID,
GameListConfigID: cfg.ID,
SysOrigin: sysOrigin,
Profile: profile,
VendorType: baishunVendorType,
VendorGameID: vendorGameID,
LaunchMode: baishunLaunchModeRemote,
PackageVersion: catalog.PackageVersion,
PackageURL: catalog.DownloadURL,
PreviewURL: catalog.PreviewURL,
Orientation: catalog.Orientation,
SafeHeight: catalog.SafeHeight,
GSP: defaultAdminGSP(catalog.GSP, runtimeCfg.gsp()),
BridgeSchemaVersion: "1.0",
ExtraJSON: extraJSON,
Enabled: true,
CreateTime: now,
UpdateTime: now,
}
if err := tx.Create(&newExt).Error; err != nil {
return nil, err
}
}
resp.Imported++
}
return resp, nil
}
func resolveImportDefaultShowcase(values ...bool) bool {
if len(values) == 0 {
return true
}
return values[0]
}
func resolveBoolPtrDefault(value *bool, fallback bool) bool {
if value == nil {
return fallback
}
return *value
}
func (r adminGameRow) toAdminItem() AdminBaishunGameItem {
return AdminBaishunGameItem{
ID: r.ID,
SysOrigin: r.SysOrigin,
Profile: normalizeBaishunProfile(r.Profile),
GameID: r.InternalGameID,
GameType: gameTypeFromExtraJSON(r.ExtExtraJSON, baishunVendorType),
VendorGameID: parseAdminVendorGameID(r.VendorGameID, r.InternalGameID),
@ -687,6 +791,16 @@ func normalizePageLimit(limit int) int {
return limit
}
func resolveAdminShowcase(showcase *bool, current bool, isCreate bool) bool {
if showcase != nil {
return *showcase
}
if isCreate {
return true
}
return current
}
func defaultAdminGSP(catalogGSP string, fallback int) string {
if strings.TrimSpace(catalogGSP) != "" {
return strings.TrimSpace(catalogGSP)

View File

@ -0,0 +1,313 @@
package baishun
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"chatapp3-golang/internal/model"
)
func TestResolveAdminShowcase(t *testing.T) {
t.Run("create defaults to enabled", func(t *testing.T) {
if got := resolveAdminShowcase(nil, false, true); !got {
t.Fatalf("resolveAdminShowcase(nil, false, true) = %v, want true", got)
}
})
t.Run("update keeps current when omitted", func(t *testing.T) {
if got := resolveAdminShowcase(nil, false, false); got {
t.Fatalf("resolveAdminShowcase(nil, false, false) = %v, want false", got)
}
})
t.Run("explicit false remains false", func(t *testing.T) {
showcase := false
if got := resolveAdminShowcase(&showcase, true, true); got {
t.Fatalf("resolveAdminShowcase(false, true, true) = %v, want false", got)
}
})
}
func TestFetchPlatformGamesUsesConfiguredGameListURL(t *testing.T) {
service, _ := newTestBaishunService(t)
ctx := context.Background()
var gotPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"message":"ok","data":[{"game_id":1021,"name":"LuckyGift","download_url":"https://cdn.example.com/lucky/index.html","preview_url":"https://cdn.example.com/lucky.png","game_version":"1.0.0","game_mode":[1],"game_orientation":1,"safe_height":0,"venue_level":[1]}]}`))
}))
defer server.Close()
service.httpClient = server.Client()
games, err := service.fetchPlatformGames(ctx, baishunRuntimeConfig{
PlatformBaseURL: "https://unused.example.com",
GameListURL: server.URL + "/custom/game-list",
AppID: 1001,
AppChannel: "test",
AppKey: "test-key",
})
if err != nil {
t.Fatalf("fetchPlatformGames() error = %v", err)
}
if gotPath != "/custom/game-list" {
t.Fatalf("request path = %q, want configured game list path", gotPath)
}
if len(games) != 1 || games[0].GameID != 1021 {
t.Fatalf("games = %+v, want fetched game 1021", games)
}
}
func TestImportAndListRoomGamesUseActiveProfile(t *testing.T) {
service, db := newTestBaishunService(t)
ctx := context.Background()
now := time.Now()
if err := db.Create(&[]model.BaishunProviderConfig{
{
ID: 1001,
SysOrigin: "LIKEI",
Profile: baishunProfileProd,
Active: true,
PlatformBaseURL: "https://prod.example.com",
AppID: 111,
AppChannel: "prod",
AppKey: "prod-key",
GSP: 101,
CreateTime: now,
UpdateTime: now,
},
{
ID: 1002,
SysOrigin: "LIKEI",
Profile: baishunProfileTest,
Active: false,
PlatformBaseURL: "https://test.example.com",
AppID: 222,
AppChannel: "test",
AppKey: "test-key",
GSP: 102,
CreateTime: now,
UpdateTime: now,
},
}).Error; err != nil {
t.Fatalf("create provider configs: %v", err)
}
if err := db.Create(&[]model.BaishunGameCatalog{
{
ID: 2001,
SysOrigin: "LIKEI",
Profile: baishunProfileProd,
InternalGameID: "bs_9001",
VendorGameID: 9001,
Name: "Prod Game",
Cover: "https://cdn.example.com/prod.png",
DownloadURL: "https://cdn.example.com/prod/index.html",
Status: baishunCatalogEnabled,
CreateTime: now,
UpdateTime: now,
},
{
ID: 2002,
SysOrigin: "LIKEI",
Profile: baishunProfileTest,
InternalGameID: "bs_9001",
VendorGameID: 9001,
Name: "Test Game",
Cover: "https://cdn.example.com/test.png",
DownloadURL: "https://cdn.example.com/test/index.html",
Status: baishunCatalogEnabled,
CreateTime: now,
UpdateTime: now,
},
}).Error; err != nil {
t.Fatalf("create catalogs: %v", err)
}
if resp, err := service.ImportCatalogGames(ctx, "LIKEI", baishunProfileProd, []int{9001}); err != nil {
t.Fatalf("import prod catalog: %v", err)
} else if resp.Imported != 1 {
t.Fatalf("prod imported = %d, want 1", resp.Imported)
}
var prodConfig model.SysGameListConfig
if err := db.Where("sys_origin = ? AND game_origin = ? AND game_id = ?", "LIKEI", baishunVendorType, "bs_9001").First(&prodConfig).Error; err != nil {
t.Fatalf("load imported prod config: %v", err)
}
if prodConfig.Category != baishunRoomCategory {
t.Fatalf("imported prod category = %q, want %q", prodConfig.Category, baishunRoomCategory)
}
if !prodConfig.Showcase {
t.Fatalf("imported prod showcase = false, want true by default")
}
if resp, err := service.ImportCatalogGames(ctx, "LIKEI", baishunProfileTest, []int{9001}); err != nil {
t.Fatalf("import test catalog: %v", err)
} else if resp.Imported != 1 {
t.Fatalf("test imported = %d, want 1", resp.Imported)
}
prodList, err := service.ListRoomGames(ctx, AuthUser{SysOrigin: "LIKEI"}, "room-1", "")
if err != nil {
t.Fatalf("list prod games: %v", err)
}
if len(prodList.Items) != 1 || prodList.Items[0].Name != "Prod Game" {
t.Fatalf("prod list = %+v, want only Prod Game", prodList.Items)
}
if _, err := service.ActivateProviderProfile(ctx, ActivateBaishunProviderProfileRequest{
SysOrigin: "LIKEI",
Profile: baishunProfileTest,
}); err != nil {
t.Fatalf("activate test profile: %v", err)
}
testList, err := service.ListRoomGames(ctx, AuthUser{SysOrigin: "LIKEI"}, "room-1", "")
if err != nil {
t.Fatalf("list test games: %v", err)
}
if len(testList.Items) != 1 || testList.Items[0].Name != "Test Game" {
t.Fatalf("test list = %+v, want only Test Game", testList.Items)
}
}
func TestImportCatalogGamesCanDefaultToHidden(t *testing.T) {
service, db := newTestBaishunService(t)
ctx := context.Background()
now := time.Now()
if err := db.Create(&model.BaishunProviderConfig{
ID: 1101,
SysOrigin: "LIKEI",
Profile: baishunProfileProd,
Active: true,
PlatformBaseURL: "https://prod.example.com",
AppID: 111,
AppChannel: "prod",
AppKey: "prod-key",
GSP: 101,
CreateTime: now,
UpdateTime: now,
}).Error; err != nil {
t.Fatalf("create provider config: %v", err)
}
if err := db.Create(&model.BaishunGameCatalog{
ID: 2101,
SysOrigin: "LIKEI",
Profile: baishunProfileProd,
InternalGameID: "bs_9101",
VendorGameID: 9101,
Name: "Hidden Game",
Status: baishunCatalogEnabled,
CreateTime: now,
UpdateTime: now,
}).Error; err != nil {
t.Fatalf("create catalog: %v", err)
}
resp, err := service.ImportCatalogGames(ctx, "LIKEI", baishunProfileProd, []int{9101}, false)
if err != nil {
t.Fatalf("import hidden catalog: %v", err)
}
if resp.Imported != 1 {
t.Fatalf("imported = %d, want 1", resp.Imported)
}
var cfg model.SysGameListConfig
if err := db.Where("sys_origin = ? AND game_origin = ? AND game_id = ?", "LIKEI", baishunVendorType, "bs_9101").First(&cfg).Error; err != nil {
t.Fatalf("load imported config: %v", err)
}
if cfg.Showcase {
t.Fatalf("imported showcase = true, want false")
}
}
func TestCatalogAddedRequiresExistingGameConfigAndImportRepairsDanglingExt(t *testing.T) {
service, db := newTestBaishunService(t)
ctx := context.Background()
now := time.Now()
if err := db.Create(&model.BaishunGameCatalog{
ID: 3001,
SysOrigin: "LIKEI",
Profile: baishunProfileProd,
InternalGameID: "bs_1021",
VendorGameID: 1021,
Name: "LuckyGift",
Cover: "https://cdn.example.com/lucky.png",
DownloadURL: "https://cdn.example.com/lucky/index.html",
Status: baishunCatalogEnabled,
CreateTime: now,
UpdateTime: now,
}).Error; err != nil {
t.Fatalf("create catalog: %v", err)
}
if err := db.Create(&model.SysGameListVendorExt{
ID: 4001,
GameListConfigID: 999999,
SysOrigin: "LIKEI",
Profile: baishunProfileProd,
VendorType: baishunVendorType,
VendorGameID: "1021",
Enabled: true,
CreateTime: now,
UpdateTime: now,
}).Error; err != nil {
t.Fatalf("create dangling ext: %v", err)
}
if catalogAddedForVendor(t, service, ctx, "LIKEI", baishunProfileProd, 1021) {
t.Fatalf("catalog added before import = true, want false for dangling ext")
}
importResp, err := service.ImportCatalogGames(ctx, "LIKEI", baishunProfileProd, []int{1021})
if err != nil {
t.Fatalf("import catalog with dangling ext: %v", err)
}
if importResp.Imported != 1 || importResp.Existing != 0 {
t.Fatalf("import response = %+v, want imported 1 existing 0", importResp)
}
var ext model.SysGameListVendorExt
if err := db.Where("id = ?", int64(4001)).First(&ext).Error; err != nil {
t.Fatalf("load repaired ext: %v", err)
}
if ext.GameListConfigID == 999999 || ext.GameListConfigID == 0 {
t.Fatalf("ext game_list_config_id = %d, want repaired config id", ext.GameListConfigID)
}
var cfg model.SysGameListConfig
if err := db.Where("id = ? AND sys_origin = ? AND game_origin = ?", ext.GameListConfigID, "LIKEI", baishunVendorType).First(&cfg).Error; err != nil {
t.Fatalf("load repaired config: %v", err)
}
if cfg.Name != "LuckyGift" {
t.Fatalf("repaired config name = %q, want LuckyGift", cfg.Name)
}
if !catalogAddedForVendor(t, service, ctx, "LIKEI", baishunProfileProd, 1021) {
t.Fatalf("catalog added after import = false, want true")
}
}
func catalogAddedForVendor(t *testing.T, service *BaishunService, ctx context.Context, sysOrigin string, profile string, vendorGameID int) bool {
t.Helper()
var rows []struct {
AddedConfigID *int64
}
err := service.buildAdminCatalogQuery(ctx, sysOrigin, profile, "").
Where("cat.vendor_game_id = ?", vendorGameID).
Select("cfg.id AS added_config_id").
Scan(&rows).Error
if err != nil {
t.Fatalf("query catalog added flag: %v", err)
}
if len(rows) != 1 {
t.Fatalf("catalog rows = %d, want 1", len(rows))
}
return rows[0].AddedConfigID != nil && *rows[0].AddedConfigID > 0
}

View File

@ -3,6 +3,7 @@ package baishun
import (
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/service/taskcenter"
"chatapp3-golang/internal/utils"
"context"
"crypto/md5"
@ -10,6 +11,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"log"
"strconv"
"strings"
"time"
@ -237,22 +239,29 @@ func (s *BaishunService) HandleChangeBalance(ctx context.Context, req BaishunCha
}
s.persistOrderRecord(ctx, session.SysOrigin, req, walletEventID, baishunOrderStatusSuccess, rawJSON, utils.MustJSONString(resp, ""), nil)
s.saveCallbackLog(ctx, "change-balance", rawJSON, utils.MustJSONString(resp, ""), session.SysOrigin, req.UserID, &req.OrderID, &req.GameRoundID, baishunCallbackStatusSuccess, 0, resp.Message)
s.reportGameConsumeTaskEvent(ctx, session, req)
return resp
}
// HandleReport 处理百顺上报类回调,目前仅记录日志并返回成功。
func (s *BaishunService) HandleReport(ctx context.Context, payload map[string]any, rawJSON string) baishunStandardResponse {
userID := toString(payload["user_id"])
orderID := toString(payload["order_id"])
gameRoundID := toString(payload["game_round_id"])
resp := baishunStandardResponse{
Code: 0,
Message: "succeed",
UniqueID: s.uniqueID(),
Data: map[string]any{},
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)
}
s.saveCallbackLog(ctx, "report", rawJSON, utils.MustJSONString(resp, ""), "", userID, stringPtr(orderID), stringPtr(gameRoundID), baishunCallbackStatusSuccess, 0, resp.Message)
return resp
}
// HandleBalanceInfo 处理百顺查询余额回调。

View File

@ -18,11 +18,12 @@ import (
// SyncCatalog 从百顺平台拉取游戏目录并同步到本地表。
func (s *BaishunService) SyncCatalog(ctx context.Context, req SyncCatalogRequest) (*SyncCatalogResponse, error) {
sysOrigin := defaultIfBlank(req.SysOrigin, "LIKEI")
if strings.TrimSpace(sysOrigin) == "" {
sysOrigin = "LIKEI"
sysOrigin := normalizeAdminSysOrigin(req.SysOrigin)
profile, err := s.requestProfile(ctx, sysOrigin, req.Profile)
if err != nil {
return nil, err
}
runtimeCfg, err := s.requireProviderConfig(ctx, sysOrigin)
runtimeCfg, err := s.requireProviderConfigForProfile(ctx, sysOrigin, profile)
if err != nil {
return nil, err
}
@ -51,6 +52,7 @@ func (s *BaishunService) SyncCatalog(ctx context.Context, req SyncCatalogRequest
record := model.BaishunGameCatalog{
ID: id,
SysOrigin: sysOrigin,
Profile: profile,
InternalGameID: fmt.Sprintf("bs_%d", game.GameID),
VendorGameID: game.GameID,
Name: game.Name,
@ -69,7 +71,7 @@ func (s *BaishunService) SyncCatalog(ctx context.Context, req SyncCatalogRequest
UpdateTime: time.Now(),
}
err = s.repo.DB.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "sys_origin"}, {Name: "vendor_game_id"}},
Columns: []clause.Column{{Name: "sys_origin"}, {Name: "profile"}, {Name: "vendor_game_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"internal_game_id", "name", "cover", "preview_url", "download_url",
"package_version", "game_mode_json", "orientation", "safe_height",
@ -104,7 +106,7 @@ func (s *BaishunService) fetchPlatformGames(ctx context.Context, runtimeCfg bais
payload["app_name"] = runtimeCfg.AppName
}
body, _ := json.Marshal(payload)
endpoint := strings.TrimRight(runtimeCfg.PlatformBaseURL, "/") + "/v1/api/gamelist"
endpoint := runtimeCfg.gameListURL()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, err

View File

@ -10,6 +10,7 @@ import (
type adminCatalogRow struct {
VendorGameID int
Profile string
InternalGameID string
Name string
Cover string
@ -28,24 +29,30 @@ type adminCatalogRow struct {
func (s *BaishunService) PageCatalog(
ctx context.Context,
sysOrigin string,
profile string,
keyword string,
cursor int,
limit int,
) (*AdminBaishunCatalogPageResponse, error) {
sysOrigin = normalizeAdminSysOrigin(sysOrigin)
profile, err := s.requestProfile(ctx, sysOrigin, profile)
if err != nil {
return nil, err
}
cursor = normalizePageCursor(cursor)
limit = normalizePageLimit(limit)
countQuery := s.buildAdminCatalogQuery(ctx, sysOrigin, keyword)
countQuery := s.buildAdminCatalogQuery(ctx, sysOrigin, profile, keyword)
var total int64
if err := countQuery.Count(&total).Error; err != nil {
return nil, err
}
var rows []adminCatalogRow
err := s.buildAdminCatalogQuery(ctx, sysOrigin, keyword).
err = s.buildAdminCatalogQuery(ctx, sysOrigin, profile, keyword).
Select(`
cat.vendor_game_id AS vendor_game_id,
cat.profile AS profile,
cat.internal_game_id AS internal_game_id,
cat.name AS name,
cat.cover AS cover,
@ -56,7 +63,7 @@ func (s *BaishunService) PageCatalog(
cat.status AS status,
cat.safe_height AS safe_height,
cat.orientation AS orientation,
ext.game_list_config_id AS added_config_id,
cfg.id AS added_config_id,
cfg.is_showcase AS showcase,
DATE_FORMAT(cat.update_time, '%Y-%m-%d %H:%i:%s') AS update_time
`).
@ -72,6 +79,7 @@ func (s *BaishunService) PageCatalog(
for _, row := range rows {
items = append(items, AdminBaishunCatalogItem{
VendorGameID: row.VendorGameID,
Profile: normalizeBaishunProfile(row.Profile),
GameID: row.InternalGameID,
Name: row.Name,
Cover: row.Cover,
@ -100,18 +108,24 @@ func (s *BaishunService) FetchAdminCatalog(ctx context.Context, req SyncCatalogR
return s.SyncCatalog(ctx, req)
}
func (s *BaishunService) buildAdminCatalogQuery(ctx context.Context, sysOrigin string, keyword string) *gorm.DB {
func (s *BaishunService) buildAdminCatalogQuery(ctx context.Context, sysOrigin string, profile string, keyword string) *gorm.DB {
query := s.repo.DB.WithContext(ctx).
Table("baishun_game_catalog AS cat").
Joins(`
LEFT JOIN sys_game_list_vendor_ext ext
ON ext.sys_origin = cat.sys_origin
AND ext.profile = cat.profile
AND ext.vendor_type = ?
AND ext.vendor_game_id = CAST(cat.vendor_game_id AS CHAR)
AND ext.enabled = 1
`, baishunVendorType).
Joins("LEFT JOIN sys_game_list_config cfg ON cfg.id = ext.game_list_config_id").
Where("cat.sys_origin = ?", sysOrigin)
Joins(`
LEFT JOIN sys_game_list_config cfg
ON cfg.id = ext.game_list_config_id
AND cfg.sys_origin = cat.sys_origin
AND cfg.game_origin = ?
`, baishunVendorType).
Where("cat.sys_origin = ? AND cat.profile = ?", sysOrigin, profile)
keyword = strings.TrimSpace(keyword)
if keyword != "" {
like := "%" + keyword + "%"
@ -134,3 +148,14 @@ func (s *BaishunService) requireProviderConfig(ctx context.Context, sysOrigin st
}
return runtimeCfg, nil
}
func (s *BaishunService) requireProviderConfigForProfile(ctx context.Context, sysOrigin string, profile string) (baishunRuntimeConfig, error) {
runtimeCfg, err := s.resolveRuntimeConfigForProfile(ctx, sysOrigin, profile)
if err != nil {
return baishunRuntimeConfig{}, err
}
if strings.TrimSpace(runtimeCfg.PlatformBaseURL) == "" || runtimeCfg.AppID <= 0 || strings.TrimSpace(runtimeCfg.AppKey) == "" {
return baishunRuntimeConfig{}, NewAppError(http.StatusBadRequest, "baishun_config_missing", "baishun provider config is missing")
}
return runtimeCfg, nil
}

View File

@ -0,0 +1,25 @@
package baishun
import "context"
const javaGameListCachePattern = "GAME_LIST*"
func (s *BaishunService) clearJavaGameListCache(ctx context.Context) {
if s.repo.Redis == nil {
return
}
var cursor uint64
for {
keys, nextCursor, err := s.repo.Redis.Scan(ctx, cursor, javaGameListCachePattern, 100).Result()
if err != nil {
return
}
if len(keys) > 0 {
_ = s.repo.Redis.Del(ctx, keys...).Err()
}
if nextCursor == 0 {
return
}
cursor = nextCursor
}
}

View File

@ -0,0 +1,122 @@
package baishun
import (
"context"
"strings"
"testing"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type stubGateway struct{}
func (stubGateway) GetUserProfile(context.Context, int64) (integration.UserProfile, error) {
return integration.UserProfile{}, nil
}
func (stubGateway) MapGoldBalance(context.Context, []int64) (map[int64]int64, error) {
return map[int64]int64{}, nil
}
func (stubGateway) GetUserLanguage(context.Context, int64) (string, error) {
return "en", nil
}
func (stubGateway) ExistsGoldEvent(context.Context, string) (bool, error) {
return false, nil
}
func (stubGateway) ChangeGoldBalance(context.Context, integration.GoldReceiptCommand) error {
return nil
}
func newTestBaishunService(t *testing.T) (*BaishunService, *gorm.DB) {
t.Helper()
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(
&model.SysGameListConfig{},
&model.SysGameListVendorExt{},
&model.BaishunProviderConfig{},
&model.BaishunGameCatalog{},
&model.BaishunLaunchSession{},
&model.BaishunCallbackLog{},
&model.BaishunRoomState{},
); err != nil {
t.Fatalf("auto migrate: %v", err)
}
service := NewBaishunService(config.Config{
Baishun: config.BaishunConfig{
PlatformBaseURL: "https://mesh-channels-online.jieyou.shop",
AppID: 7485318192,
AppName: "yumiparty_app",
AppChannel: "yumiparty",
AppKey: "test-app-key",
GSP: 111,
LaunchCodeTTLSeconds: 300,
SSTokenTTLSeconds: 86400,
},
}, db, nil, stubGateway{})
return service, db
}
func TestLaunchGameFallsBackToCatalogWhenConfigMissing(t *testing.T) {
service, db := newTestBaishunService(t)
if err := db.Create(&model.BaishunGameCatalog{
ID: 1,
SysOrigin: "LIKEI",
Profile: baishunProfileProd,
InternalGameID: "bs_1146",
VendorGameID: 1146,
Name: "LordOfOlympus",
Cover: "https://cdn.example.com/cover.png",
PreviewURL: "https://cdn.example.com/preview.png",
DownloadURL: "https://cdn.example.com/game/index.html",
PackageVersion: "1.0.0",
SafeHeight: 24,
GSP: "222",
Status: baishunCatalogEnabled,
}).Error; err != nil {
t.Fatalf("create catalog: %v", err)
}
resp, err := service.LaunchGame(context.Background(), AuthUser{
UserID: 1234567,
SysOrigin: "LIKEI",
}, BaishunLaunchAppRequest{
RoomID: "room-1",
GameID: "bs_1146",
SceneMode: 1,
ClientOrigin: "COMMON",
}, "127.0.0.1")
if err != nil {
t.Fatalf("LaunchGame() error = %v", err)
}
if resp.GameID != "bs_1146" {
t.Fatalf("gameId = %q, want bs_1146", resp.GameID)
}
if resp.VendorGameID != 1146 {
t.Fatalf("vendorGameId = %d, want 1146", resp.VendorGameID)
}
if resp.Entry.EntryURL != "https://cdn.example.com/game/index.html" {
t.Fatalf("entryUrl = %q, want catalog download url", resp.Entry.EntryURL)
}
if resp.BridgeConfig.GSP != 222 {
t.Fatalf("gsp = %d, want 222", resp.BridgeConfig.GSP)
}
if resp.RoomState.State != baishunRoomStatePlaying {
t.Fatalf("roomState.state = %q, want %q", resp.RoomState.State, baishunRoomStatePlaying)
}
}

View File

@ -0,0 +1,61 @@
package baishun
import (
"chatapp3-golang/internal/model"
"context"
"net/http"
"strings"
"gorm.io/gorm"
)
const (
baishunProfileProd = "PROD"
baishunProfileTest = "TEST"
)
func normalizeBaishunProfile(profile string) string {
switch strings.ToUpper(strings.TrimSpace(profile)) {
case "", "PROD", "PRODUCT", "PRODUCTION", "ONLINE", "OFFICIAL", "FORMAL":
return baishunProfileProd
case "TEST", "TESTING", "SANDBOX", "DEV":
return baishunProfileTest
default:
return strings.ToUpper(strings.TrimSpace(profile))
}
}
func validateBaishunProfile(profile string) (string, error) {
profile = normalizeBaishunProfile(profile)
switch profile {
case baishunProfileProd, baishunProfileTest:
return profile, nil
default:
return "", NewAppError(http.StatusBadRequest, "profile_invalid", "profile must be PROD or TEST")
}
}
func (s *BaishunService) activeProfile(ctx context.Context, sysOrigin string) (string, error) {
sysOrigin = normalizeAdminSysOrigin(sysOrigin)
var row model.BaishunProviderConfig
err := s.repo.DB.WithContext(ctx).
Where("sys_origin = ? AND active = ?", sysOrigin, true).
Order("update_time DESC").
Limit(1).
First(&row).Error
if err == nil {
return normalizeBaishunProfile(row.Profile), nil
}
if err == gorm.ErrRecordNotFound {
return baishunProfileProd, nil
}
return "", err
}
func (s *BaishunService) requestProfile(ctx context.Context, sysOrigin string, profile string) (string, error) {
if strings.TrimSpace(profile) != "" {
return validateBaishunProfile(profile)
}
return s.activeProfile(ctx, sysOrigin)
}

View File

@ -14,7 +14,10 @@ import (
type baishunRuntimeConfig struct {
SysOrigin string
Profile string
PlatformBaseURL string
GameListURL string
LuckyGiftURL string
AppID int64
AppName string
AppChannel string
@ -28,6 +31,14 @@ func (c baishunRuntimeConfig) appChannel() string {
return defaultIfBlank(strings.TrimSpace(c.AppChannel), "skychat")
}
func (c baishunRuntimeConfig) gameListURL() string {
return resolveBaishunGameListURL(c.PlatformBaseURL, c.GameListURL)
}
func (c baishunRuntimeConfig) luckyGiftURL() string {
return resolveBaishunLuckyGiftURL(c.PlatformBaseURL, c.LuckyGiftURL)
}
func (c baishunRuntimeConfig) gsp() int {
if c.GSP > 0 {
return c.GSP
@ -50,9 +61,13 @@ func (c baishunRuntimeConfig) ssTokenTTLSeconds() int {
}
func (s *BaishunService) defaultRuntimeConfig(sysOrigin string) baishunRuntimeConfig {
platformBaseURL := strings.TrimSpace(s.cfg.Baishun.PlatformBaseURL)
return baishunRuntimeConfig{
SysOrigin: normalizeAdminSysOrigin(sysOrigin),
PlatformBaseURL: strings.TrimSpace(s.cfg.Baishun.PlatformBaseURL),
Profile: baishunProfileProd,
PlatformBaseURL: platformBaseURL,
GameListURL: resolveBaishunGameListURL(platformBaseURL, s.cfg.Baishun.GameListURL),
LuckyGiftURL: resolveBaishunLuckyGiftURL(platformBaseURL, s.cfg.Baishun.LuckyGiftURL),
AppID: s.cfg.Baishun.AppID,
AppName: strings.TrimSpace(s.cfg.Baishun.AppName),
AppChannel: strings.TrimSpace(s.cfg.Baishun.AppChannel),
@ -65,9 +80,20 @@ func (s *BaishunService) defaultRuntimeConfig(sysOrigin string) baishunRuntimeCo
func applyProviderRow(base baishunRuntimeConfig, row model.BaishunProviderConfig) baishunRuntimeConfig {
base.SysOrigin = normalizeAdminSysOrigin(defaultIfBlank(row.SysOrigin, base.SysOrigin))
base.Profile = normalizeBaishunProfile(defaultIfBlank(row.Profile, base.Profile))
if strings.TrimSpace(row.PlatformBaseURL) != "" {
base.PlatformBaseURL = strings.TrimSpace(row.PlatformBaseURL)
}
if strings.TrimSpace(row.GameListURL) != "" {
base.GameListURL = strings.TrimSpace(row.GameListURL)
} else {
base.GameListURL = resolveBaishunGameListURL(base.PlatformBaseURL, "")
}
if strings.TrimSpace(row.LuckyGiftURL) != "" {
base.LuckyGiftURL = strings.TrimSpace(row.LuckyGiftURL)
} else {
base.LuckyGiftURL = resolveBaishunLuckyGiftURL(base.PlatformBaseURL, "")
}
if row.AppID > 0 {
base.AppID = row.AppID
}
@ -94,11 +120,25 @@ func applyProviderRow(base baishunRuntimeConfig, row model.BaishunProviderConfig
func (s *BaishunService) resolveRuntimeConfig(ctx context.Context, sysOrigin string) (baishunRuntimeConfig, error) {
sysOrigin = normalizeAdminSysOrigin(sysOrigin)
profile, err := s.activeProfile(ctx, sysOrigin)
if err != nil {
return baishunRuntimeConfig{}, err
}
return s.resolveRuntimeConfigForProfile(ctx, sysOrigin, profile)
}
func (s *BaishunService) resolveRuntimeConfigForProfile(ctx context.Context, sysOrigin string, profile string) (baishunRuntimeConfig, error) {
sysOrigin = normalizeAdminSysOrigin(sysOrigin)
profile, err := validateBaishunProfile(profile)
if err != nil {
return baishunRuntimeConfig{}, err
}
base := s.defaultRuntimeConfig(sysOrigin)
base.Profile = profile
var row model.BaishunProviderConfig
err := s.repo.DB.WithContext(ctx).
Where("sys_origin = ?", sysOrigin).
err = s.repo.DB.WithContext(ctx).
Where("sys_origin = ? AND profile = ?", sysOrigin, profile).
Limit(1).
First(&row).Error
if err == nil {
@ -133,7 +173,11 @@ func (s *BaishunService) resolveRuntimeConfigByApp(ctx context.Context, appID in
for _, build := range queries {
var row model.BaishunProviderConfig
err := build(s.repo.DB.WithContext(ctx)).Limit(1).First(&row).Error
err := build(s.repo.DB.WithContext(ctx)).
Order("active DESC").
Order("update_time DESC").
Limit(1).
First(&row).Error
if err == nil {
return applyProviderRow(s.defaultRuntimeConfig(row.SysOrigin), row), nil
}
@ -144,22 +188,36 @@ func (s *BaishunService) resolveRuntimeConfigByApp(ctx context.Context, appID in
return base, nil
}
func (s *BaishunService) GetProviderConfig(ctx context.Context, sysOrigin string) (*BaishunProviderConfigItem, error) {
runtimeCfg, err := s.resolveRuntimeConfig(ctx, sysOrigin)
func (s *BaishunService) GetProviderConfig(ctx context.Context, sysOrigin string, profile string) (*BaishunProviderConfigItem, error) {
sysOrigin = normalizeAdminSysOrigin(sysOrigin)
activeProfile, err := s.activeProfile(ctx, sysOrigin)
if err != nil {
return nil, err
}
profile, err = s.requestProfile(ctx, sysOrigin, profile)
if err != nil {
return nil, err
}
runtimeCfg, err := s.resolveRuntimeConfigForProfile(ctx, sysOrigin, profile)
if err != nil {
return nil, err
}
var row model.BaishunProviderConfig
_ = s.repo.DB.WithContext(ctx).
Where("sys_origin = ?", normalizeAdminSysOrigin(sysOrigin)).
Where("sys_origin = ? AND profile = ?", sysOrigin, profile).
Limit(1).
First(&row).Error
return &BaishunProviderConfigItem{
ID: row.ID,
SysOrigin: runtimeCfg.SysOrigin,
Profile: runtimeCfg.Profile,
ActiveProfile: activeProfile,
Active: runtimeCfg.Profile == activeProfile,
PlatformBaseURL: runtimeCfg.PlatformBaseURL,
GameListURL: runtimeCfg.gameListURL(),
LuckyGiftURL: runtimeCfg.luckyGiftURL(),
AppID: runtimeCfg.AppID,
AppName: runtimeCfg.AppName,
AppChannel: runtimeCfg.appChannel(),
@ -173,13 +231,27 @@ func (s *BaishunService) GetProviderConfig(ctx context.Context, sysOrigin string
func (s *BaishunService) SaveProviderConfig(ctx context.Context, req SaveBaishunProviderConfigRequest) (*BaishunProviderConfigItem, error) {
sysOrigin := normalizeAdminSysOrigin(req.SysOrigin)
profile, err := validateBaishunProfile(req.Profile)
if err != nil {
return nil, err
}
platformBaseURL := strings.TrimSpace(req.PlatformBaseURL)
gameListURL := strings.TrimSpace(req.GameListURL)
luckyGiftURL := strings.TrimSpace(req.LuckyGiftURL)
appChannel := strings.TrimSpace(req.AppChannel)
appKey := strings.TrimSpace(req.AppKey)
if gameListURL == "" {
gameListURL = resolveBaishunGameListURL(platformBaseURL, "")
}
if luckyGiftURL == "" {
luckyGiftURL = resolveBaishunLuckyGiftURL(platformBaseURL, "")
}
switch {
case platformBaseURL == "":
return nil, NewAppError(http.StatusBadRequest, "platform_base_url_required", "platformBaseUrl is required")
case luckyGiftURL == "":
return nil, NewAppError(http.StatusBadRequest, "lucky_gift_url_required", "luckyGiftUrl is required")
case req.AppID <= 0:
return nil, NewAppError(http.StatusBadRequest, "app_id_required", "appId is required")
case appChannel == "":
@ -196,7 +268,10 @@ func (s *BaishunService) SaveProviderConfig(ctx context.Context, req SaveBaishun
row := model.BaishunProviderConfig{
ID: id,
SysOrigin: sysOrigin,
Profile: profile,
PlatformBaseURL: platformBaseURL,
GameListURL: gameListURL,
LuckyGiftURL: luckyGiftURL,
AppID: req.AppID,
AppName: strings.TrimSpace(req.AppName),
AppChannel: appChannel,
@ -216,15 +291,91 @@ func (s *BaishunService) SaveProviderConfig(ctx context.Context, req SaveBaishun
if row.SSTokenTTLSeconds <= 0 {
row.SSTokenTTLSeconds = 86400
}
var activeCount int64
if err := s.repo.DB.WithContext(ctx).
Model(&model.BaishunProviderConfig{}).
Where("sys_origin = ? AND active = ?", sysOrigin, true).
Count(&activeCount).Error; err != nil {
return nil, err
}
row.Active = activeCount == 0
if err := s.repo.DB.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "sys_origin"}},
Columns: []clause.Column{{Name: "sys_origin"}, {Name: "profile"}},
DoUpdates: clause.AssignmentColumns([]string{
"platform_base_url", "app_id", "app_name", "app_channel", "app_key",
"platform_base_url", "game_list_url", "lucky_gift_url", "app_id", "app_name", "app_channel", "app_key",
"gsp", "launch_code_ttl_seconds", "ss_token_ttl_seconds", "update_time",
}),
}).Create(&row).Error; err != nil {
return nil, err
}
return s.GetProviderConfig(ctx, sysOrigin)
if row.Active {
s.clearJavaGameListCache(ctx)
}
return s.GetProviderConfig(ctx, sysOrigin, profile)
}
func resolveBaishunGameListURL(platformBaseURL string, gameListURL string) string {
gameListURL = strings.TrimSpace(gameListURL)
if gameListURL != "" {
return gameListURL
}
platformBaseURL = strings.TrimRight(strings.TrimSpace(platformBaseURL), "/")
if platformBaseURL == "" {
return ""
}
return platformBaseURL + "/v1/api/gamelist"
}
func resolveBaishunLuckyGiftURL(platformBaseURL string, luckyGiftURL string) string {
luckyGiftURL = strings.TrimSpace(luckyGiftURL)
if luckyGiftURL != "" {
return luckyGiftURL
}
platformBaseURL = strings.TrimRight(strings.TrimSpace(platformBaseURL), "/")
if platformBaseURL == "" {
return ""
}
return platformBaseURL + "/lucky_gift/start_game"
}
func (s *BaishunService) ActivateProviderProfile(ctx context.Context, req ActivateBaishunProviderProfileRequest) (*BaishunProviderConfigItem, error) {
sysOrigin := normalizeAdminSysOrigin(req.SysOrigin)
profile, err := validateBaishunProfile(req.Profile)
if err != nil {
return nil, err
}
var row model.BaishunProviderConfig
if err := s.repo.DB.WithContext(ctx).
Where("sys_origin = ? AND profile = ?", sysOrigin, profile).
Limit(1).
First(&row).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, NewAppError(http.StatusBadRequest, "profile_config_missing", "profile config is missing")
}
return nil, err
}
now := time.Now()
if err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.BaishunProviderConfig{}).
Where("sys_origin = ?", sysOrigin).
Updates(map[string]any{
"active": false,
"update_time": now,
}).Error; err != nil {
return err
}
return tx.Model(&model.BaishunProviderConfig{}).
Where("sys_origin = ? AND profile = ?", sysOrigin, profile).
Updates(map[string]any{
"active": true,
"update_time": now,
}).Error
}); err != nil {
return nil, err
}
s.clearJavaGameListCache(ctx)
return s.GetProviderConfig(ctx, sysOrigin, profile)
}

View File

@ -0,0 +1,291 @@
package baishun
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/service/highwin"
"chatapp3-golang/internal/utils"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
const (
baishunReportTypeStart = "game_start"
baishunReportTypeSettle = "game_settle"
baishunReportBetTTL = 24 * time.Hour
baishunReportBetRedisPrefix = "baishun:report:bet"
)
type baishunGameBetRecord struct {
SysOrigin string `json:"sysOrigin"`
RoomID string `json:"roomId"`
GameID int64 `json:"gameId"`
GameRoundID string `json:"gameRoundId"`
UserID int64 `json:"userId"`
Bet int64 `json:"bet"`
}
func (s *BaishunService) HandleReport(ctx context.Context, req BaishunReportRequest, rawJSON string) baishunStandardResponse {
resp := baishunStandardResponse{
Code: 0,
Message: "succeed",
UniqueID: s.uniqueID(),
Data: map[string]any{},
}
sysOrigin := ""
if session, ok := s.findSessionByToken(ctx, req.SSToken, req.UserID); ok {
sysOrigin = session.SysOrigin
if err := s.handleReportHighWins(ctx, session.SysOrigin, req); err != nil {
log.Printf("handle baishun report high win failed userId=%s reportType=%s: %v", req.UserID, req.ReportType, err)
}
}
s.saveCallbackLog(
ctx,
"report",
rawJSON,
utils.MustJSONString(resp, ""),
sysOrigin,
req.UserID,
nil,
stringPtr(reportGameRoundID(req.ReportMsg)),
baishunCallbackStatusSuccess,
0,
resp.Message,
)
return resp
}
func (s *BaishunService) handleReportHighWins(ctx context.Context, sysOrigin string, req BaishunReportRequest) error {
switch strings.ToLower(strings.TrimSpace(req.ReportType)) {
case baishunReportTypeStart:
return s.rememberBaishunGameBets(ctx, sysOrigin, req.ReportMsg)
case baishunReportTypeSettle:
return s.publishBaishunGameHighWins(ctx, sysOrigin, req.ReportMsg)
default:
return nil
}
}
func (s *BaishunService) rememberBaishunGameBets(ctx context.Context, sysOrigin string, payload map[string]any) error {
if s == nil || s.repo.Redis == nil {
return nil
}
roomID := reportText(payload, "room_id", "roomId")
gameRoundID := reportGameRoundID(payload)
if strings.TrimSpace(roomID) == "" || strings.TrimSpace(gameRoundID) == "" {
return nil
}
gameID := reportInt64(payload, "game_id", "gameId")
for _, player := range reportPlayers(payload) {
if reportInt64(player, "is_ai", "isAi") != 0 {
continue
}
userID := reportInt64(player, "user_id", "userId")
bet := reportInt64(player, "bet")
if userID <= 0 || bet <= 0 {
continue
}
record := baishunGameBetRecord{
SysOrigin: normalizeAdminSysOrigin(sysOrigin),
RoomID: roomID,
GameID: gameID,
GameRoundID: gameRoundID,
UserID: userID,
Bet: bet,
}
body, err := json.Marshal(record)
if err != nil {
return err
}
if err := s.repo.Redis.Set(ctx, baishunGameBetKey(record.SysOrigin, gameRoundID, userID), string(body), baishunReportBetTTL).Err(); err != nil {
return err
}
}
return nil
}
func (s *BaishunService) publishBaishunGameHighWins(ctx context.Context, sysOrigin string, payload map[string]any) error {
if s == nil || s.highWinBroadcaster == nil || s.repo.Redis == nil {
return nil
}
sysOrigin = normalizeAdminSysOrigin(sysOrigin)
roomID := reportText(payload, "room_id", "roomId")
gameRoundID := reportGameRoundID(payload)
if strings.TrimSpace(roomID) == "" || strings.TrimSpace(gameRoundID) == "" {
return nil
}
gameID := reportInt64(payload, "game_id", "gameId")
gameCover := s.resolveBaishunReportGameCover(ctx, sysOrigin, roomID, gameID)
for _, player := range reportPlayers(payload) {
if reportInt64(player, "is_ai", "isAi") != 0 {
continue
}
userID := reportInt64(player, "user_id", "userId")
reward := reportInt64(player, "reward")
if userID <= 0 || reward <= 0 {
continue
}
betRecord, ok, err := s.loadBaishunGameBet(ctx, sysOrigin, gameRoundID, userID)
if err != nil {
return err
}
if !ok || betRecord.Bet <= 0 {
continue
}
multiple := highwin.Multiple(reward, betRecord.Bet)
if !highwin.ShouldBroadcast(multiple, reward) {
continue
}
data := map[string]any{
"gameId": gameID,
"gameRoundId": gameRoundID,
"gameUrl": gameCover,
"betAmount": betRecord.Bet,
"currencyDiff": reward,
"rank": reportInt64(player, "rank"),
"score": reportInt64(player, "score"),
}
if err := highwin.SendRegionBroadcast(ctx, s.highWinBroadcaster, highwin.BroadcastEvent{
SysOrigin: sysOrigin,
Type: highwin.MessageTypeBaishunWin,
UserID: userID,
RoomID: roomID,
Multiple: multiple,
Amount: reward,
Data: data,
}); err != nil {
log.Printf("send baishun game high win region broadcast failed roundId=%s userId=%d: %v", gameRoundID, userID, err)
}
_ = s.repo.Redis.Del(ctx, baishunGameBetKey(sysOrigin, gameRoundID, userID)).Err()
}
return nil
}
func (s *BaishunService) loadBaishunGameBet(ctx context.Context, sysOrigin, gameRoundID string, userID int64) (baishunGameBetRecord, bool, error) {
raw, err := s.repo.Redis.Get(ctx, baishunGameBetKey(sysOrigin, gameRoundID, userID)).Result()
if errors.Is(err, redis.Nil) {
return baishunGameBetRecord{}, false, nil
}
if err != nil {
return baishunGameBetRecord{}, false, err
}
var record baishunGameBetRecord
if err := json.Unmarshal([]byte(raw), &record); err != nil {
return baishunGameBetRecord{}, false, err
}
return record, true, nil
}
func (s *BaishunService) resolveBaishunReportGameCover(ctx context.Context, sysOrigin, roomID string, gameID int64) string {
var state model.BaishunRoomState
if strings.TrimSpace(roomID) != "" {
err := s.repo.DB.WithContext(ctx).
Where("sys_origin = ? AND room_id = ?", normalizeAdminSysOrigin(sysOrigin), roomID).
First(&state).Error
if err == nil && strings.TrimSpace(state.CurrentGameCover) != "" {
return strings.TrimSpace(state.CurrentGameCover)
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return ""
}
}
if gameID <= 0 {
return ""
}
profile, err := s.activeProfile(ctx, sysOrigin)
if err != nil {
return ""
}
var catalog model.BaishunGameCatalog
if err := s.repo.DB.WithContext(ctx).
Where("sys_origin = ? AND profile = ? AND vendor_game_id = ?", normalizeAdminSysOrigin(sysOrigin), profile, gameID).
First(&catalog).Error; err != nil {
return ""
}
return strings.TrimSpace(catalog.Cover)
}
func reportGameRoundID(payload map[string]any) string {
return reportText(payload, "game_round_id", "gameRoundId")
}
func reportPlayers(payload map[string]any) []map[string]any {
raw, exists := payload["players"]
if !exists {
return nil
}
switch typed := raw.(type) {
case []map[string]any:
return typed
case []any:
players := make([]map[string]any, 0, len(typed))
for _, item := range typed {
if player, ok := item.(map[string]any); ok {
players = append(players, player)
}
}
return players
default:
return nil
}
}
func reportText(payload map[string]any, keys ...string) string {
for _, key := range keys {
if value, exists := payload[key]; exists {
text := strings.TrimSpace(toString(value))
if text != "" {
return text
}
}
}
return ""
}
func reportInt64(payload map[string]any, keys ...string) int64 {
for _, key := range keys {
value, exists := payload[key]
if !exists {
continue
}
switch typed := value.(type) {
case int:
return int64(typed)
case int64:
return 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
}
}
return 0
}
func baishunGameBetKey(sysOrigin, gameRoundID string, userID int64) string {
return strings.Join([]string{
baishunReportBetRedisPrefix,
normalizeAdminSysOrigin(sysOrigin),
strings.TrimSpace(gameRoundID),
strconv.FormatInt(userID, 10),
}, ":")
}

View File

@ -0,0 +1,115 @@
package baishun
import (
"context"
"testing"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/service/regionimgroup"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
func TestHandleReportPublishesRegionHighWinAfterStartAndSettle(t *testing.T) {
service, db := newTestBaishunService(t)
mr := miniredis.RunT(t)
redisClient := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = redisClient.Close() })
service.repo.Redis = redisClient
broadcaster := &fakeBaishunRegionBroadcaster{}
service.SetHighWinBroadcaster(broadcaster)
expireAt := time.Now().Add(time.Hour)
token := "ss-token-1001"
if err := db.Create(&model.BaishunLaunchSession{
ID: 1,
SysOrigin: "LIKEI",
RoomID: "9001",
UserID: 1001,
InternalGameID: "bs_1146",
VendorGameID: 1146,
GameSessionID: "session-1",
SSToken: &token,
SSTokenExpireTime: &expireAt,
Status: baishunSessionActive,
CreateTime: time.Now(),
UpdateTime: time.Now(),
}).Error; err != nil {
t.Fatalf("seed session: %v", err)
}
if err := db.Create(&model.BaishunRoomState{
ID: 2,
SysOrigin: "LIKEI",
RoomID: "9001",
HostUserID: 1001,
CurrentGameID: "bs_1146",
CurrentVendorGameID: intPtr(1146),
CurrentGameName: "LordOfOlympus",
CurrentGameCover: "https://cdn.example.com/game.png",
GameSessionID: "session-1",
State: baishunRoomStatePlaying,
CreateTime: time.Now(),
UpdateTime: time.Now(),
}).Error; err != nil {
t.Fatalf("seed room state: %v", err)
}
startResp := service.HandleReport(context.Background(), BaishunReportRequest{
ReportType: baishunReportTypeStart,
UserID: "1001",
SSToken: token,
ReportMsg: map[string]any{
"game_id": 1146,
"room_id": "9001",
"game_round_id": "round-1",
"players": []any{
map[string]any{"user_id": "1001", "is_ai": 0, "bet": 100},
},
},
}, `{"report_type":"game_start"}`)
if startResp.Code != 0 {
t.Fatalf("start response = %+v", startResp)
}
settleResp := service.HandleReport(context.Background(), BaishunReportRequest{
ReportType: baishunReportTypeSettle,
UserID: "1001",
SSToken: token,
ReportMsg: map[string]any{
"game_id": 1146,
"room_id": "9001",
"game_round_id": "round-1",
"players": []any{
map[string]any{"user_id": "1001", "is_ai": 0, "reward": 1000, "rank": 1, "score": 88},
},
},
}, `{"report_type":"game_settle"}`)
if settleResp.Code != 0 {
t.Fatalf("settle response = %+v", settleResp)
}
if len(broadcaster.requests) != 1 {
t.Fatalf("requests = %d, want 1", len(broadcaster.requests))
}
req := broadcaster.requests[0]
if req.Type != "GAME_BAISHUN_WIN" {
t.Fatalf("type = %q, want GAME_BAISHUN_WIN", req.Type)
}
if req.Data["userId"] != "1001" || req.Data["roomId"] != "9001" ||
req.Data["multiple"] != int64(10) || req.Data["winAmount"] != int64(1000) ||
req.Data["currencyDiff"] != int64(1000) ||
req.Data["gameUrl"] != "https://cdn.example.com/game.png" {
t.Fatalf("payload = %+v", req.Data)
}
}
type fakeBaishunRegionBroadcaster struct {
requests []regionimgroup.RegionBroadcastRequest
}
func (f *fakeBaishunRegionBroadcaster) SendRegionBroadcast(_ context.Context, req regionimgroup.RegionBroadcastRequest) (*regionimgroup.RegionBroadcastResponse, error) {
f.requests = append(f.requests, req)
return &regionimgroup.RegionBroadcastResponse{Type: req.Type}, nil
}

View File

@ -3,6 +3,7 @@ package baishun
import (
"chatapp3-golang/internal/model"
"context"
"fmt"
"net/http"
"strconv"
"strings"
@ -12,7 +13,7 @@ import (
// ListShortcutGames 返回房间里的快捷游戏列表,数量上限为 5 个。
func (s *BaishunService) ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) {
items, err := s.listRoomGames(ctx, user.SysOrigin, roomID, "")
items, err := s.listRoomGames(ctx, user.SysOrigin, user.RegionID, roomID, "")
if err != nil {
return nil, err
}
@ -24,7 +25,7 @@ func (s *BaishunService) ListShortcutGames(ctx context.Context, user AuthUser, r
// ListRoomGames 返回房间可启动的完整游戏列表。
func (s *BaishunService) ListRoomGames(ctx context.Context, user AuthUser, roomID string, category string) (*RoomGameListResponse, error) {
items, err := s.listRoomGames(ctx, user.SysOrigin, roomID, category)
items, err := s.listRoomGames(ctx, user.SysOrigin, user.RegionID, roomID, category)
if err != nil {
return nil, err
}
@ -58,13 +59,19 @@ func (s *BaishunService) GetRoomState(ctx context.Context, user AuthUser, roomID
}
// listRoomGames 联表读取房间可启动游戏,并按展示规则排序。
func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, roomID, category string) ([]RoomGameListItem, error) {
func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, regionID, roomID, category string) ([]RoomGameListItem, error) {
sysOrigin = normalizeAdminSysOrigin(sysOrigin)
profile, err := s.activeProfile(ctx, sysOrigin)
if err != nil {
return nil, err
}
var rows []gameListRow
query := s.repo.DB.WithContext(ctx).
Table("sys_game_list_config AS cfg").
Select(`
cfg.id AS config_id,
cfg.sys_origin AS sys_origin,
ext.profile AS profile,
cfg.game_id AS internal_game_id,
cfg.name AS name,
cfg.category AS category,
@ -92,9 +99,13 @@ func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, roomID, c
cat.safe_height AS catalog_safe_height,
cat.status AS catalog_status
`).
Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1").
Joins("LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id").
Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1 AND ext.profile = ?", profile).
Joins("LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND cat.profile = ext.profile AND CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id").
Where("cfg.sys_origin = ? AND cfg.is_showcase = 1 AND ext.vendor_type = ?", sysOrigin, baishunVendorType)
if strings.TrimSpace(regionID) != "" {
// regions 为空表示全区域;有值时按用户区域 ID 精确匹配,避免土耳其用户看到其他区域游戏。
query = query.Where("(COALESCE(TRIM(cfg.regions), '') = '' OR FIND_IN_SET(?, REPLACE(COALESCE(cfg.regions, ''), ' ', '')) > 0)", strings.TrimSpace(regionID))
}
if strings.TrimSpace(category) != "" && !strings.EqualFold(strings.TrimSpace(category), "CHAT_ROOM") {
query = query.Where("cfg.category = ?", category)
}
@ -111,12 +122,18 @@ func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, roomID, c
// findGameRow 按内部游戏 ID 查询游戏聚合信息。
func (s *BaishunService) findGameRow(ctx context.Context, sysOrigin, gameID string) (gameListRow, error) {
sysOrigin = normalizeAdminSysOrigin(sysOrigin)
profile, err := s.activeProfile(ctx, sysOrigin)
if err != nil {
return gameListRow{}, err
}
var row gameListRow
err := s.repo.DB.WithContext(ctx).
err = s.repo.DB.WithContext(ctx).
Table("sys_game_list_config AS cfg").
Select(`
cfg.id AS config_id,
cfg.sys_origin AS sys_origin,
ext.profile AS profile,
cfg.game_id AS internal_game_id,
cfg.name AS name,
cfg.category AS category,
@ -144,8 +161,8 @@ func (s *BaishunService) findGameRow(ctx context.Context, sysOrigin, gameID stri
cat.safe_height AS catalog_safe_height,
cat.status AS catalog_status
`).
Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1").
Joins("LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id").
Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1 AND ext.profile = ?", profile).
Joins("LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND cat.profile = ext.profile AND CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id").
Where("cfg.sys_origin = ? AND cfg.game_id = ? AND ext.vendor_type = ?", sysOrigin, gameID, baishunVendorType).
Limit(1).
Scan(&row).Error
@ -155,17 +172,51 @@ func (s *BaishunService) findGameRow(ctx context.Context, sysOrigin, gameID stri
if strings.TrimSpace(row.InternalGameID) != "" {
return row, nil
}
var catalog model.BaishunGameCatalog
if err := s.repo.DB.WithContext(ctx).
Where("sys_origin = ? AND profile = ? AND internal_game_id = ?", sysOrigin, profile, gameID).
First(&catalog).Error; err == nil {
return gameListRow{
SysOrigin: sysOrigin,
Profile: profile,
InternalGameID: defaultIfBlank(catalog.InternalGameID, fmt.Sprintf("bs_%d", catalog.VendorGameID)),
Name: catalog.Name,
Cover: catalog.Cover,
VendorType: baishunVendorType,
VendorGameID: strconv.Itoa(catalog.VendorGameID),
LaunchMode: baishunLaunchModeRemote,
ExtGSP: catalog.GSP,
CatalogCover: catalog.Cover,
CatalogPreviewURL: catalog.PreviewURL,
CatalogDownloadURL: catalog.DownloadURL,
CatalogPackageVersion: catalog.PackageVersion,
CatalogSafeHeight: catalog.SafeHeight,
CatalogOrientation: catalog.Orientation,
CatalogStatus: catalog.Status,
GameModeRaw: formatAdminGameModeRaw(baishunFixedGameMode),
}, nil
} else if err != gorm.ErrRecordNotFound {
return gameListRow{}, err
}
return gameListRow{}, NewAppError(http.StatusNotFound, "game_not_found", "room game config not found")
}
// findGameRowByConfigID 按系统游戏配置主键查询游戏聚合信息。
func (s *BaishunService) findGameRowByConfigID(ctx context.Context, sysOrigin string, configID int64) (gameListRow, error) {
sysOrigin = normalizeAdminSysOrigin(sysOrigin)
profile, err := s.activeProfile(ctx, sysOrigin)
if err != nil {
return gameListRow{}, err
}
var row gameListRow
err := s.repo.DB.WithContext(ctx).
err = s.repo.DB.WithContext(ctx).
Table("sys_game_list_config AS cfg").
Select(`
cfg.id AS config_id,
cfg.sys_origin AS sys_origin,
ext.profile AS profile,
cfg.game_id AS internal_game_id,
cfg.name AS name,
cfg.category AS category,
@ -193,8 +244,8 @@ func (s *BaishunService) findGameRowByConfigID(ctx context.Context, sysOrigin st
cat.safe_height AS catalog_safe_height,
cat.status AS catalog_status
`).
Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1").
Joins("LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id").
Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1 AND ext.profile = ?", profile).
Joins("LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND cat.profile = ext.profile AND CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id").
Where("cfg.sys_origin = ? AND cfg.id = ? AND ext.vendor_type = ?", sysOrigin, configID, baishunVendorType).
Limit(1).
Scan(&row).Error

View File

@ -3,6 +3,7 @@ package baishun
import (
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/service/highwin"
"context"
"net/http"
@ -50,6 +51,10 @@ type baishunGateway interface {
ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error
}
type taskEventReporter interface {
ReportSimpleEvent(ctx context.Context, sysOrigin, eventID, eventType string, userID, deltaValue int64, payload map[string]any) error
}
type baishunPorts struct {
DB baishunDB
Redis redis.Cmdable
@ -57,10 +62,12 @@ type baishunPorts struct {
// BaishunService 负责百顺目录同步、游戏启动、房间状态以及回调处理。
type BaishunService struct {
cfg config.Config
repo baishunPorts
java baishunGateway
httpClient *http.Client
cfg config.Config
repo baishunPorts
java baishunGateway
httpClient *http.Client
taskReporter taskEventReporter
highWinBroadcaster highwin.RegionBroadcaster
}
// NewBaishunService 创建百顺接入服务实例。
@ -75,6 +82,14 @@ func NewBaishunService(cfg config.Config, db baishunDB, cache redis.Cmdable, jav
}
}
func (s *BaishunService) SetTaskEventReporter(reporter taskEventReporter) {
s.taskReporter = reporter
}
func (s *BaishunService) SetHighWinBroadcaster(broadcaster highwin.RegionBroadcaster) {
s.highWinBroadcaster = broadcaster
}
// RoomGameListItem 是房间游戏列表中的单个游戏项。
type RoomGameListItem struct {
ID int64 `json:"id"`
@ -174,9 +189,11 @@ type BaishunLaunchAppResponse struct {
// SyncCatalogRequest 是目录同步入参。
type SyncCatalogRequest struct {
SysOrigin string `json:"sysOrigin"`
VendorGameIDs []int `json:"vendorGameIds"`
Force bool `json:"force"`
SysOrigin string `json:"sysOrigin"`
Profile string `json:"profile"`
VendorGameIDs []int `json:"vendorGameIds"`
Force bool `json:"force"`
DefaultShowcase *bool `json:"defaultShowcase"`
}
// SyncCatalogResponse 是目录同步结果统计。
@ -190,6 +207,7 @@ type SyncCatalogResponse struct {
type AdminBaishunGameItem struct {
ID int64 `json:"id"`
SysOrigin string `json:"sysOrigin"`
Profile string `json:"profile"`
GameID string `json:"gameId"`
GameType string `json:"gameType,omitempty"`
VendorGameID int `json:"vendorGameId"`
@ -232,6 +250,7 @@ type AdminBaishunGamePageResponse struct {
type SaveAdminBaishunGameRequest struct {
ID int64 `json:"id"`
SysOrigin string `json:"sysOrigin"`
Profile string `json:"profile"`
GameID string `json:"gameId"`
VendorGameID int `json:"vendorGameId"`
Name string `json:"name"`
@ -239,7 +258,7 @@ type SaveAdminBaishunGameRequest struct {
GameCode string `json:"gameCode"`
Cover string `json:"cover"`
Amounts string `json:"amounts"`
Showcase bool `json:"showcase"`
Showcase *bool `json:"showcase"`
Sort int64 `json:"sort"`
Height float64 `json:"height"`
Width float64 `json:"width"`
@ -277,7 +296,12 @@ type SyncAdminCatalogResponse struct {
type BaishunProviderConfigItem struct {
ID int64 `json:"id"`
SysOrigin string `json:"sysOrigin"`
Profile string `json:"profile"`
ActiveProfile string `json:"activeProfile"`
Active bool `json:"active"`
PlatformBaseURL string `json:"platformBaseUrl"`
GameListURL string `json:"gameListUrl"`
LuckyGiftURL string `json:"luckyGiftUrl"`
AppID int64 `json:"appId"`
AppName string `json:"appName"`
AppChannel string `json:"appChannel"`
@ -291,7 +315,10 @@ type BaishunProviderConfigItem struct {
// SaveBaishunProviderConfigRequest 是后台保存百顺配置的入参。
type SaveBaishunProviderConfigRequest struct {
SysOrigin string `json:"sysOrigin"`
Profile string `json:"profile"`
PlatformBaseURL string `json:"platformBaseUrl"`
GameListURL string `json:"gameListUrl"`
LuckyGiftURL string `json:"luckyGiftUrl"`
AppID int64 `json:"appId"`
AppName string `json:"appName"`
AppChannel string `json:"appChannel"`
@ -301,9 +328,16 @@ type SaveBaishunProviderConfigRequest struct {
SSTokenTTLSeconds int `json:"ssTokenTtlSeconds"`
}
// ActivateBaishunProviderProfileRequest 是切换百顺正式/测试配置档案的入参。
type ActivateBaishunProviderProfileRequest struct {
SysOrigin string `json:"sysOrigin"`
Profile string `json:"profile"`
}
// AdminBaishunCatalogItem 是后台目录页展示的单个百顺目录项。
type AdminBaishunCatalogItem struct {
VendorGameID int `json:"vendorGameId"`
Profile string `json:"profile"`
GameID string `json:"gameId"`
Name string `json:"name"`
Cover string `json:"cover"`
@ -390,6 +424,13 @@ type BaishunBalanceInfoRequest struct {
Timestamp int64 `json:"timestamp"`
}
type BaishunReportRequest struct {
ReportType string `json:"report_type"`
ReportMsg map[string]any `json:"report_msg"`
UserID string `json:"user_id"`
SSToken string `json:"ss_token"`
}
// baishunStandardResponse 是百顺标准回包结构。
type baishunStandardResponse struct {
Code int `json:"code"`
@ -410,6 +451,7 @@ type baishunBalanceInfoResponse struct {
type gameListRow struct {
ConfigID int64
SysOrigin string
Profile string
InternalGameID string
Name string
Category string

View File

@ -0,0 +1,78 @@
package binancerecharge
import (
"fmt"
"math/big"
"net/http"
"strings"
)
func parsePositiveDecimal(value string, field string) (*big.Rat, string, error) {
normalized, err := normalizeDecimal(value)
if err != nil {
return nil, "", NewAppError(http.StatusBadRequest, "invalid_"+field, field+" must be a decimal number")
}
rat, ok := new(big.Rat).SetString(normalized)
if !ok || rat.Sign() <= 0 {
return nil, "", NewAppError(http.StatusBadRequest, "invalid_"+field, field+" must be greater than 0")
}
return rat, normalized, nil
}
func parseNonNegativeDecimal(value string, field string) (*big.Rat, string, error) {
normalized, err := normalizeDecimal(value)
if err != nil {
return nil, "", NewAppError(http.StatusBadRequest, "invalid_"+field, field+" must be a decimal number")
}
rat, ok := new(big.Rat).SetString(normalized)
if !ok || rat.Sign() < 0 {
return nil, "", NewAppError(http.StatusBadRequest, "invalid_"+field, field+" must be greater than or equal to 0")
}
return rat, normalized, nil
}
func normalizeDecimal(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", fmt.Errorf("blank decimal")
}
rat, ok := new(big.Rat).SetString(value)
if !ok {
return "", fmt.Errorf("invalid decimal")
}
return trimDecimalZeros(rat.FloatString(8)), nil
}
func trimDecimalZeros(value string) string {
value = strings.TrimSpace(value)
if !strings.Contains(value, ".") {
return value
}
value = strings.TrimRight(value, "0")
value = strings.TrimRight(value, ".")
if value == "" || value == "-0" {
return "0"
}
return value
}
func decimalEqual(left string, right string) bool {
leftRat, ok := new(big.Rat).SetString(strings.TrimSpace(left))
if !ok {
return false
}
rightRat, ok := new(big.Rat).SetString(strings.TrimSpace(right))
if !ok {
return false
}
return leftRat.Cmp(rightRat) == 0
}
func calculateGold(amount *big.Rat, rate *big.Rat) (int64, error) {
product := new(big.Rat).Mul(amount, rate)
gold := new(big.Int).Quo(product.Num(), product.Denom())
if !gold.IsInt64() {
return 0, NewAppError(http.StatusBadRequest, "gold_amount_too_large", "gold amount is too large")
}
return gold.Int64(), nil
}

View File

@ -0,0 +1,282 @@
package binancerecharge
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const (
defaultBinanceBaseURL = "https://api.binance.com"
binanceUSDT = "USDT"
)
type HTTPBinanceVerifier struct {
baseURL string
httpClient *http.Client
}
func NewHTTPBinanceVerifier(baseURL string, timeout time.Duration) *HTTPBinanceVerifier {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
baseURL = defaultBinanceBaseURL
}
if timeout <= 0 {
timeout = 10 * time.Second
}
return &HTTPBinanceVerifier{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: timeout,
},
}
}
func (v *HTTPBinanceVerifier) Verify(ctx context.Context, credential BinanceCredential, query BinanceVerifyQuery) (VerifiedTransaction, error) {
if v == nil {
return VerifiedTransaction{}, NewAppError(http.StatusServiceUnavailable, "binance_verifier_unavailable", "binance verifier is unavailable")
}
switch query.TransferType {
case TransferTypeBinancePay:
return v.verifyPayTransaction(ctx, credential, query)
case TransferTypeChain:
return v.verifyChainDeposit(ctx, credential, query)
default:
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "invalid_transfer_type", "transferType must be BINANCE_PAY or CHAIN")
}
}
func (v *HTTPBinanceVerifier) verifyPayTransaction(ctx context.Context, credential BinanceCredential, query BinanceVerifyQuery) (VerifiedTransaction, error) {
now := time.Now()
values := url.Values{}
values.Set("startTime", fmt.Sprintf("%d", now.AddDate(0, 0, -90).UnixMilli()))
values.Set("endTime", fmt.Sprintf("%d", now.UnixMilli()))
values.Set("limit", "100")
values.Set("recvWindow", "5000")
raw, err := v.signedGet(ctx, credential, "/sapi/v1/pay/transactions", values)
if err != nil {
return VerifiedTransaction{}, err
}
var resp struct {
Code string `json:"code"`
Message string `json:"message"`
Data []json.RawMessage `json:"data"`
Success *bool `json:"success"`
}
if err := json.Unmarshal(raw, &resp); err != nil {
return VerifiedTransaction{}, NewAppError(http.StatusBadGateway, "binance_response_invalid", err.Error())
}
if resp.Success != nil && !*resp.Success {
message := strings.TrimSpace(resp.Message)
if message == "" {
message = "binance pay query failed"
}
return VerifiedTransaction{}, NewAppError(http.StatusBadGateway, "binance_query_failed", message)
}
foundBill := false
for _, item := range resp.Data {
var record map[string]any
if err := json.Unmarshal(item, &record); err != nil {
continue
}
if !binancePayRecordMatchesBill(record, query.BillNo) {
continue
}
foundBill = true
if !binancePayRecordMatchesAmount(record, query.Amount) {
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_amount_mismatch", "binance transaction amount does not match")
}
return VerifiedTransaction{
TransactionID: firstString(record, "transactionId", "orderId", "merchantTradeNo"),
OrderType: firstString(record, "orderType"),
Amount: query.Amount,
RawJSON: string(item),
}, nil
}
if foundBill {
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_amount_mismatch", "binance transaction amount does not match")
}
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_transaction_not_found", "binance transaction was not found")
}
func (v *HTTPBinanceVerifier) verifyChainDeposit(ctx context.Context, credential BinanceCredential, query BinanceVerifyQuery) (VerifiedTransaction, error) {
now := time.Now()
values := url.Values{}
values.Set("coin", binanceUSDT)
values.Set("status", "1")
values.Set("txId", query.BillNo)
values.Set("startTime", fmt.Sprintf("%d", now.AddDate(0, 0, -90).UnixMilli()))
values.Set("endTime", fmt.Sprintf("%d", now.UnixMilli()))
values.Set("limit", "1000")
values.Set("recvWindow", "5000")
raw, err := v.signedGet(ctx, credential, "/sapi/v1/capital/deposit/hisrec", values)
if err != nil {
return VerifiedTransaction{}, err
}
var rows []json.RawMessage
if err := json.Unmarshal(raw, &rows); err != nil {
return VerifiedTransaction{}, NewAppError(http.StatusBadGateway, "binance_response_invalid", err.Error())
}
foundBill := false
for _, item := range rows {
var record map[string]any
if err := json.Unmarshal(item, &record); err != nil {
continue
}
if !chainDepositRecordMatchesBill(record, query.BillNo) {
continue
}
foundBill = true
if !strings.EqualFold(firstString(record, "coin"), binanceUSDT) {
continue
}
if !chainDepositStatusSuccess(record) {
continue
}
if !decimalEqual(firstString(record, "amount"), query.Amount) {
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_amount_mismatch", "binance deposit amount does not match")
}
return VerifiedTransaction{
TransactionID: firstString(record, "txId", "id"),
OrderType: "CHAIN_DEPOSIT",
Amount: query.Amount,
RawJSON: string(item),
}, nil
}
if foundBill {
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_amount_mismatch", "binance deposit amount does not match")
}
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_transaction_not_found", "binance deposit was not found")
}
func (v *HTTPBinanceVerifier) signedGet(ctx context.Context, credential BinanceCredential, path string, values url.Values) ([]byte, error) {
apiKey := strings.TrimSpace(credential.APIKey)
apiSecret := strings.TrimSpace(credential.APISecret)
if apiKey == "" || apiSecret == "" {
return nil, NewAppError(http.StatusServiceUnavailable, "binance_credentials_missing", "binance api key and secret are required")
}
values.Set("timestamp", fmt.Sprintf("%d", time.Now().UnixMilli()))
query := values.Encode()
signature := signBinanceQuery(apiSecret, query)
endpoint := v.baseURL + path + "?" + query + "&signature=" + url.QueryEscape(signature)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, NewAppError(http.StatusBadGateway, "binance_request_failed", err.Error())
}
req.Header.Set("X-MBX-APIKEY", apiKey)
resp, err := v.httpClient.Do(req)
if err != nil {
return nil, NewAppError(http.StatusBadGateway, "binance_request_failed", err.Error())
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, NewAppError(http.StatusBadGateway, "binance_response_read_failed", err.Error())
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, NewAppError(http.StatusBadGateway, "binance_query_failed", strings.TrimSpace(string(body)))
}
return body, nil
}
func signBinanceQuery(secret string, query string) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(query))
return hex.EncodeToString(mac.Sum(nil))
}
func binancePayRecordMatchesBill(record map[string]any, billNo string) bool {
billNo = strings.TrimSpace(billNo)
for _, key := range []string{"transactionId", "orderId", "merchantTradeNo", "prepayId", "id"} {
if strings.EqualFold(firstString(record, key), billNo) {
return true
}
}
return false
}
func binancePayRecordMatchesAmount(record map[string]any, expected string) bool {
if strings.EqualFold(firstString(record, "currency"), binanceUSDT) && decimalEqual(firstString(record, "amount"), expected) {
return true
}
details, ok := record["fundsDetail"].([]any)
if !ok {
return false
}
for _, item := range details {
detail, ok := item.(map[string]any)
if !ok {
continue
}
if strings.EqualFold(firstString(detail, "currency"), binanceUSDT) && decimalEqual(firstString(detail, "amount"), expected) {
return true
}
}
return false
}
func chainDepositRecordMatchesBill(record map[string]any, billNo string) bool {
billNo = strings.TrimSpace(billNo)
for _, key := range []string{"txId", "id"} {
if strings.EqualFold(firstString(record, key), billNo) {
return true
}
}
return false
}
func chainDepositStatusSuccess(record map[string]any) bool {
switch value := record["status"].(type) {
case float64:
return int(value) == 1
case int:
return value == 1
case string:
return strings.TrimSpace(value) == "1"
default:
return false
}
}
func firstString(record map[string]any, keys ...string) string {
for _, key := range keys {
if value := stringValue(record[key]); strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func stringValue(value any) string {
switch typed := value.(type) {
case string:
return typed
case json.Number:
return typed.String()
case float64:
return trimDecimalZeros(fmt.Sprintf("%.8f", typed))
case int:
return fmt.Sprintf("%d", typed)
case int64:
return fmt.Sprintf("%d", typed)
default:
return ""
}
}

View File

@ -0,0 +1,220 @@
package binancerecharge
import (
"context"
"errors"
"math/big"
"net/http"
"sort"
"strings"
"time"
"chatapp3-golang/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*ConfigResponse, error) {
sysOrigin = normalizeSysOrigin(sysOrigin)
if sysOrigin == "" {
return nil, NewAppError(http.StatusBadRequest, "sys_origin_required", "sysOrigin is required")
}
if s.db == nil {
return nil, NewAppError(http.StatusServiceUnavailable, "database_unavailable", "database is unavailable")
}
var cfg model.BinanceRechargeConfig
err := s.db.WithContext(ctx).
Where("sys_origin = ?", sysOrigin).
First(&cfg).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return &ConfigResponse{
SysOrigin: sysOrigin,
Enabled: true,
Rates: []RateConfigResponse{},
}, nil
}
if err != nil {
return nil, NewAppError(http.StatusInternalServerError, "config_query_failed", err.Error())
}
rates, err := s.listRates(ctx, cfg.ID)
if err != nil {
return nil, err
}
return &ConfigResponse{
SysOrigin: cfg.SysOrigin,
APIKey: cfg.APIKey,
APISecretConfigured: strings.TrimSpace(cfg.APISecret) != "",
Enabled: cfg.Enabled,
Rates: rates,
}, nil
}
func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*ConfigResponse, error) {
sysOrigin := normalizeSysOrigin(req.SysOrigin)
if sysOrigin == "" {
return nil, NewAppError(http.StatusBadRequest, "sys_origin_required", "sysOrigin is required")
}
if s.db == nil {
return nil, NewAppError(http.StatusServiceUnavailable, "database_unavailable", "database is unavailable")
}
apiKey := strings.TrimSpace(req.APIKey)
apiSecret := strings.TrimSpace(req.APISecret)
rates, err := validateRateRequests(req.Rates)
if err != nil {
return nil, err
}
var cfg model.BinanceRechargeConfig
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
now := time.Now()
var existing model.BinanceRechargeConfig
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("sys_origin = ?", sysOrigin).
First(&existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if apiSecret == "" && existing.ID > 0 {
apiSecret = existing.APISecret
}
if apiKey == "" {
return NewAppError(http.StatusBadRequest, "api_key_required", "apiKey is required")
}
if apiSecret == "" {
return NewAppError(http.StatusBadRequest, "api_secret_required", "apiSecret is required")
}
cfg = model.BinanceRechargeConfig{
ID: existing.ID,
SysOrigin: sysOrigin,
APIKey: apiKey,
APISecret: apiSecret,
Enabled: req.Enabled,
CreateTime: now,
UpdateTime: now,
}
if existing.ID > 0 {
cfg.CreateTime = existing.CreateTime
if err := tx.Model(&model.BinanceRechargeConfig{}).
Where("id = ?", existing.ID).
Updates(map[string]any{
"api_key": apiKey,
"api_secret": apiSecret,
"enabled": req.Enabled,
"update_time": now,
}).Error; err != nil {
return err
}
} else if err := tx.Create(&cfg).Error; err != nil {
return err
}
if err := tx.Where("config_id = ?", cfg.ID).Delete(&model.BinanceRechargeRate{}).Error; err != nil {
return err
}
rows := make([]model.BinanceRechargeRate, 0, len(rates))
for index, item := range rates {
rows = append(rows, model.BinanceRechargeRate{
ConfigID: cfg.ID,
MinAmount: item.MinAmount,
MaxAmount: item.MaxAmount,
GoldPerUSD: item.GoldPerUSD,
SortOrder: index + 1,
CreateTime: now,
UpdateTime: now,
})
}
return tx.Create(&rows).Error
})
if err != nil {
var appErr *AppError
if errors.As(err, &appErr) {
return nil, err
}
return nil, NewAppError(http.StatusInternalServerError, "config_save_failed", err.Error())
}
return s.GetConfig(ctx, sysOrigin)
}
func (s *Service) listRates(ctx context.Context, configID int64) ([]RateConfigResponse, error) {
var rows []model.BinanceRechargeRate
if err := s.db.WithContext(ctx).
Where("config_id = ?", configID).
Order("sort_order asc, min_amount asc, id asc").
Find(&rows).Error; err != nil {
return nil, NewAppError(http.StatusInternalServerError, "rate_query_failed", err.Error())
}
resp := make([]RateConfigResponse, 0, len(rows))
for _, row := range rows {
resp = append(resp, RateConfigResponse{
ID: row.ID,
MinAmount: trimDecimalZeros(row.MinAmount),
MaxAmount: trimDecimalZeros(row.MaxAmount),
GoldPerUSD: trimDecimalZeros(row.GoldPerUSD),
})
}
return resp, nil
}
func validateRateRequests(input []RateConfigRequest) ([]RateConfigResponse, error) {
if len(input) == 0 {
return nil, NewAppError(http.StatusBadRequest, "rates_required", "at least one rate range is required")
}
rates := make([]RateConfigResponse, 0, len(input))
for _, item := range input {
minRat, minAmount, err := parseNonNegativeDecimal(item.MinAmount, "minAmount")
if err != nil {
return nil, err
}
maxRat, maxAmount, err := parseNonNegativeDecimal(defaultZero(item.MaxAmount), "maxAmount")
if err != nil {
return nil, err
}
_, goldPerUSD, err := parsePositiveDecimal(item.GoldPerUSD, "goldPerUsd")
if err != nil {
return nil, err
}
if maxRat.Sign() > 0 && maxRat.Cmp(minRat) <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_rate_range", "maxAmount must be greater than minAmount")
}
rates = append(rates, RateConfigResponse{
MinAmount: minAmount,
MaxAmount: maxAmount,
GoldPerUSD: goldPerUSD,
})
}
sort.SliceStable(rates, func(i, j int) bool {
left, _ := new(big.Rat).SetString(rates[i].MinAmount)
right, _ := new(big.Rat).SetString(rates[j].MinAmount)
return left.Cmp(right) < 0
})
for i := 0; i < len(rates); i++ {
currentMax, _ := new(big.Rat).SetString(rates[i].MaxAmount)
if currentMax.Sign() == 0 {
if i != len(rates)-1 {
return nil, NewAppError(http.StatusBadRequest, "invalid_rate_range", "open-ended rate range must be the last range")
}
continue
}
if i+1 < len(rates) {
nextMin, _ := new(big.Rat).SetString(rates[i+1].MinAmount)
if currentMax.Cmp(nextMin) > 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_rate_range", "rate ranges cannot overlap")
}
}
}
return rates, nil
}
func defaultZero(value string) string {
if strings.TrimSpace(value) == "" {
return "0"
}
return value
}

View File

@ -0,0 +1,201 @@
package binancerecharge
import (
"context"
"testing"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestSaveConfigKeepsExistingSecretWhenBlank(t *testing.T) {
db := newBinanceRechargeTestDB(t)
service := NewService(config.Config{}, db, &fakeBinanceJavaGateway{})
if _, err := service.SaveConfig(context.Background(), SaveConfigRequest{
SysOrigin: "LIKEI",
APIKey: "key-1",
APISecret: "secret-1",
Enabled: true,
Rates: []RateConfigRequest{
{MinAmount: "0", MaxAmount: "100", GoldPerUSD: "80000"},
},
}); err != nil {
t.Fatalf("initial save config: %v", err)
}
resp, err := service.SaveConfig(context.Background(), SaveConfigRequest{
SysOrigin: "LIKEI",
APIKey: "key-2",
Enabled: true,
Rates: []RateConfigRequest{
{MinAmount: "0", MaxAmount: "0", GoldPerUSD: "82000"},
},
})
if err != nil {
t.Fatalf("update config: %v", err)
}
if resp.APIKey != "key-2" {
t.Fatalf("APIKey = %q, want key-2", resp.APIKey)
}
if !resp.APISecretConfigured {
t.Fatalf("APISecretConfigured = false, want true")
}
var row model.BinanceRechargeConfig
if err := db.Where("sys_origin = ?", "LIKEI").First(&row).Error; err != nil {
t.Fatalf("query config: %v", err)
}
if row.APISecret != "secret-1" {
t.Fatalf("APISecret = %q, want secret-1", row.APISecret)
}
}
func TestVerifyAndRechargeIsIdempotent(t *testing.T) {
db := newBinanceRechargeTestDB(t)
java := &fakeBinanceJavaGateway{
profile: integration.UserProfile{
ID: integration.Int64Value(200),
OriginSys: "LIKEI",
},
balanceExists: true,
}
service := NewService(config.Config{}, db, java)
service.SetBinanceVerifier(&fakeBinanceVerifier{})
if _, err := service.SaveConfig(context.Background(), SaveConfigRequest{
SysOrigin: "LIKEI",
APIKey: "key",
APISecret: "secret",
Enabled: true,
Rates: []RateConfigRequest{
{MinAmount: "0", MaxAmount: "100", GoldPerUSD: "80000"},
{MinAmount: "100", MaxAmount: "0", GoldPerUSD: "82000"},
},
}); err != nil {
t.Fatalf("save config: %v", err)
}
req := VerifyRechargeRequest{
TargetUserID: 200,
OrderNo: "order-1",
TransferAmount: "12.5",
TransferType: TransferTypeBinancePay,
BillNo: "bill-1",
}
resp, err := service.VerifyAndRecharge(context.Background(), AuthUser{
UserID: 100,
SysOrigin: "LIKEI",
}, req)
if err != nil {
t.Fatalf("verify recharge: %v", err)
}
if resp.Idempotent {
t.Fatalf("first response idempotent = true, want false")
}
if resp.GoldAmount != 1_000_000 {
t.Fatalf("GoldAmount = %d, want 1000000", resp.GoldAmount)
}
if java.changeCalls != 1 {
t.Fatalf("changeCalls = %d, want 1", java.changeCalls)
}
if java.lastChange.RechargeType != rechargeTypeUSDT {
t.Fatalf("RechargeType = %q, want %q", java.lastChange.RechargeType, rechargeTypeUSDT)
}
if java.lastChange.AcceptAmount != "1000000" {
t.Fatalf("AcceptAmount = %q, want 1000000", java.lastChange.AcceptAmount)
}
if java.lastChange.OrderAmount != "12.5" {
t.Fatalf("OrderAmount = %q, want 12.5", java.lastChange.OrderAmount)
}
resp, err = service.VerifyAndRecharge(context.Background(), AuthUser{
UserID: 100,
SysOrigin: "LIKEI",
}, req)
if err != nil {
t.Fatalf("verify idempotent recharge: %v", err)
}
if !resp.Idempotent {
t.Fatalf("second response idempotent = false, want true")
}
if java.changeCalls != 1 {
t.Fatalf("changeCalls after idempotent call = %d, want 1", java.changeCalls)
}
var count int64
if err := db.Model(&model.BinanceRechargeVerifyRecord{}).Where("status = ?", verifyStatusSuccess).Count(&count).Error; err != nil {
t.Fatalf("count success records: %v", err)
}
if count != 1 {
t.Fatalf("success record count = %d, want 1", count)
}
}
func newBinanceRechargeTestDB(t *testing.T) *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.BinanceRechargeConfig{},
&model.BinanceRechargeRate{},
&model.BinanceRechargeVerifyRecord{},
); err != nil {
t.Fatalf("auto migrate: %v", err)
}
return db
}
type fakeBinanceVerifier struct{}
func (f *fakeBinanceVerifier) Verify(_ context.Context, _ BinanceCredential, query BinanceVerifyQuery) (VerifiedTransaction, error) {
return VerifiedTransaction{
TransactionID: query.BillNo,
OrderType: query.TransferType,
Amount: query.Amount,
RawJSON: `{"transactionId":"` + query.BillNo + `"}`,
}, nil
}
type fakeBinanceJavaGateway struct {
profile integration.UserProfile
balanceExists bool
sellerExists bool
changeCalls int
lastChange integration.FreightBalanceChangeRequest
}
func (f *fakeBinanceJavaGateway) GetUserProfile(_ context.Context, userID int64) (integration.UserProfile, error) {
if int64(f.profile.ID) == 0 {
f.profile.ID = integration.Int64Value(userID)
}
return f.profile, nil
}
func (f *fakeBinanceJavaGateway) ExistsFreightBalance(context.Context, int64) (bool, error) {
return f.balanceExists, nil
}
func (f *fakeBinanceJavaGateway) ExistsFreightSeller(context.Context, int64) (bool, error) {
return f.sellerExists, nil
}
func (f *fakeBinanceJavaGateway) ChangeFreightBalance(_ context.Context, req integration.FreightBalanceChangeRequest) error {
f.changeCalls++
f.lastChange = req
return nil
}
func (f *fakeBinanceJavaGateway) AddFreightAgentReview(context.Context, integration.FreightAgentReviewRequest) error {
return nil
}
func (f *fakeBinanceJavaGateway) IncreaseFreightRechargeRecord(context.Context, int64, string) error {
return nil
}

View File

@ -0,0 +1,150 @@
package binancerecharge
import (
"context"
"net/http"
"strings"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
"gorm.io/gorm"
)
const (
TransferTypeBinancePay = "BINANCE_PAY"
TransferTypeChain = "CHAIN"
verifyStatusPending = "PENDING"
verifyStatusSuccess = "SUCCESS"
verifyStatusFailed = "FAILED"
rechargeTypeUSDT = "USDT-进货"
)
type AppError = common.AppError
type AuthUser = common.AuthUser
var NewAppError = common.NewAppError
type binanceRechargeDB interface {
WithContext(ctx context.Context) *gorm.DB
}
type javaGateway interface {
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error)
ExistsFreightBalance(ctx context.Context, userID int64) (bool, error)
ExistsFreightSeller(ctx context.Context, userID int64) (bool, error)
ChangeFreightBalance(ctx context.Context, req integration.FreightBalanceChangeRequest) error
AddFreightAgentReview(ctx context.Context, req integration.FreightAgentReviewRequest) error
IncreaseFreightRechargeRecord(ctx context.Context, userID int64, amount string) error
}
type binanceVerifier interface {
Verify(ctx context.Context, credential BinanceCredential, query BinanceVerifyQuery) (VerifiedTransaction, error)
}
type Service struct {
cfg config.Config
db binanceRechargeDB
java javaGateway
verifier binanceVerifier
}
func NewService(cfg config.Config, db binanceRechargeDB, java javaGateway) *Service {
return &Service{
cfg: cfg,
db: db,
java: java,
verifier: NewHTTPBinanceVerifier(cfg.BinanceRecharge.BaseURL, cfg.HTTP.Timeout),
}
}
func (s *Service) SetBinanceVerifier(verifier binanceVerifier) {
s.verifier = verifier
}
type RateConfigRequest struct {
MinAmount string `json:"minAmount"`
MaxAmount string `json:"maxAmount"`
GoldPerUSD string `json:"goldPerUsd"`
}
type SaveConfigRequest struct {
SysOrigin string `json:"sysOrigin"`
APIKey string `json:"apiKey"`
APISecret string `json:"apiSecret"`
Enabled bool `json:"enabled"`
Rates []RateConfigRequest `json:"rates"`
}
type ConfigResponse struct {
SysOrigin string `json:"sysOrigin"`
APIKey string `json:"apiKey"`
APISecretConfigured bool `json:"apiSecretConfigured"`
Enabled bool `json:"enabled"`
Rates []RateConfigResponse `json:"rates"`
}
type RateConfigResponse struct {
ID int64 `json:"id,string,omitempty"`
MinAmount string `json:"minAmount"`
MaxAmount string `json:"maxAmount"`
GoldPerUSD string `json:"goldPerUsd"`
}
type VerifyRechargeRequest struct {
TargetUserID int64 `json:"targetUserId,string"`
OrderNo string `json:"orderNo"`
TransferAmount string `json:"transferAmount"`
TransferType string `json:"transferType"`
BillNo string `json:"billNo"`
}
type VerifyRechargeResponse struct {
ID int64 `json:"id,string"`
SysOrigin string `json:"sysOrigin"`
DealerUserID int64 `json:"dealerUserId,string"`
TargetUserID int64 `json:"targetUserId,string"`
OrderNo string `json:"orderNo"`
TransferType string `json:"transferType"`
BillNo string `json:"billNo"`
TransferAmount string `json:"transferAmount"`
GoldAmount int64 `json:"goldAmount,string"`
RateGoldPerUSD string `json:"rateGoldPerUsd"`
Status string `json:"status"`
Idempotent bool `json:"idempotent"`
}
type BinanceCredential struct {
APIKey string
APISecret string
}
type BinanceVerifyQuery struct {
TransferType string
BillNo string
Amount string
}
type VerifiedTransaction struct {
TransactionID string
OrderType string
Amount string
RawJSON string
}
func normalizeSysOrigin(value string) string {
return strings.ToUpper(strings.TrimSpace(value))
}
func validateTransferType(value string) (string, error) {
normalized := strings.ToUpper(strings.TrimSpace(value))
switch normalized {
case TransferTypeBinancePay, TransferTypeChain:
return normalized, nil
default:
return "", NewAppError(http.StatusBadRequest, "invalid_transfer_type", "transferType must be BINANCE_PAY or CHAIN")
}
}

View File

@ -0,0 +1,432 @@
package binancerecharge
import (
"context"
"errors"
"fmt"
"math/big"
"net/http"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const pendingRetryAfter = 10 * time.Minute
var errDuplicateRechargeRecord = errors.New("duplicate binance recharge record")
type preparedRecharge struct {
SysOrigin string
DealerUserID int64
TargetUserID int64
OrderNo string
TransferType string
BillNo string
AmountUSDT string
GoldAmount int64
RateGoldPerUSD string
Verified VerifiedTransaction
}
func (s *Service) VerifyAndRecharge(ctx context.Context, user AuthUser, req VerifyRechargeRequest) (*VerifyRechargeResponse, error) {
if s.db == nil {
return nil, NewAppError(http.StatusServiceUnavailable, "database_unavailable", "database is unavailable")
}
if s.java == nil {
return nil, NewAppError(http.StatusServiceUnavailable, "java_gateway_unavailable", "java gateway is unavailable")
}
if s.verifier == nil {
return nil, NewAppError(http.StatusServiceUnavailable, "binance_verifier_unavailable", "binance verifier is unavailable")
}
sysOrigin := normalizeSysOrigin(user.SysOrigin)
if sysOrigin == "" {
return nil, NewAppError(http.StatusBadRequest, "sys_origin_required", "sysOrigin is required")
}
if user.UserID <= 0 {
return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "user is invalid")
}
if req.TargetUserID <= 0 {
return nil, NewAppError(http.StatusBadRequest, "target_user_required", "targetUserId is required")
}
orderNo := strings.TrimSpace(req.OrderNo)
if orderNo == "" {
return nil, NewAppError(http.StatusBadRequest, "order_no_required", "orderNo is required")
}
billNo := strings.TrimSpace(req.BillNo)
if billNo == "" {
return nil, NewAppError(http.StatusBadRequest, "bill_no_required", "billNo is required")
}
transferType, err := validateTransferType(req.TransferType)
if err != nil {
return nil, err
}
amountRat, amount, err := parsePositiveDecimal(req.TransferAmount, "transferAmount")
if err != nil {
return nil, err
}
cfg, rates, err := s.loadConfigAndRates(ctx, sysOrigin)
if err != nil {
return nil, err
}
rateRat, rateGoldPerUSD, err := matchRate(amountRat, rates)
if err != nil {
return nil, err
}
goldAmount, err := calculateGold(amountRat, rateRat)
if err != nil {
return nil, err
}
if goldAmount <= 0 {
return nil, NewAppError(http.StatusBadRequest, "gold_amount_zero", "calculated gold amount must be greater than 0")
}
profile, err := s.java.GetUserProfile(ctx, req.TargetUserID)
if err != nil {
return nil, NewAppError(http.StatusBadGateway, "target_user_query_failed", err.Error())
}
if normalizeSysOrigin(profile.OriginSys) != sysOrigin {
return nil, NewAppError(http.StatusBadRequest, "target_user_origin_mismatch", "target user does not belong to current sysOrigin")
}
verified, err := s.verifier.Verify(ctx, BinanceCredential{
APIKey: cfg.APIKey,
APISecret: cfg.APISecret,
}, BinanceVerifyQuery{
TransferType: transferType,
BillNo: billNo,
Amount: amount,
})
if err != nil {
return nil, err
}
if strings.TrimSpace(verified.TransactionID) == "" {
verified.TransactionID = billNo
}
if strings.TrimSpace(verified.Amount) == "" {
verified.Amount = amount
}
prepared := preparedRecharge{
SysOrigin: sysOrigin,
DealerUserID: user.UserID,
TargetUserID: req.TargetUserID,
OrderNo: orderNo,
TransferType: transferType,
BillNo: billNo,
AmountUSDT: amount,
GoldAmount: goldAmount,
RateGoldPerUSD: rateGoldPerUSD,
Verified: verified,
}
record, idempotent, err := s.prepareRechargeRecord(ctx, prepared)
if err != nil {
return nil, err
}
if idempotent {
resp := responseFromRecord(record, true)
return &resp, nil
}
if err := s.ensureFreightAccount(ctx, sysOrigin, req.TargetUserID); err != nil {
_ = s.markRecordFailed(ctx, record.ID, err.Error())
return nil, err
}
if err := s.java.ChangeFreightBalance(ctx, integration.FreightBalanceChangeRequest{
SysOrigin: sysOrigin,
UserID: req.TargetUserID,
AcceptUserID: req.TargetUserID,
Type: "INCOME",
AcceptAmount: strconv.FormatInt(goldAmount, 10),
OrderAmount: amount,
USDAmount: "0",
Origin: "PURCHASE",
Remark: fmt.Sprintf("Binance auto recharge order:%s bill:%s", orderNo, billNo),
RechargeType: rechargeTypeUSDT,
CreateUser: user.UserID,
}); err != nil {
_ = s.markRecordPendingIssue(ctx, record.ID, "freight balance change result is unknown: "+err.Error())
return nil, NewAppError(http.StatusBadGateway, "freight_balance_change_failed", err.Error())
}
remark := ""
failReason := ""
if err := s.java.IncreaseFreightRechargeRecord(ctx, req.TargetUserID, amount); err != nil {
failReason = truncateString("monthly freight recharge record update failed: "+err.Error(), 500)
remark = failReason
}
if err := s.markRecordSuccess(ctx, record.ID, remark, failReason); err != nil {
return nil, err
}
record.Status = verifyStatusSuccess
record.Remark = remark
record.FailReason = failReason
resp := responseFromRecord(record, false)
return &resp, nil
}
func (s *Service) loadConfigAndRates(ctx context.Context, sysOrigin string) (model.BinanceRechargeConfig, []model.BinanceRechargeRate, error) {
var cfg model.BinanceRechargeConfig
if err := s.db.WithContext(ctx).
Where("sys_origin = ?", sysOrigin).
First(&cfg).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return cfg, nil, NewAppError(http.StatusBadRequest, "binance_config_missing", "binance recharge config is missing")
}
return cfg, nil, NewAppError(http.StatusInternalServerError, "binance_config_query_failed", err.Error())
}
if !cfg.Enabled {
return cfg, nil, NewAppError(http.StatusBadRequest, "binance_config_disabled", "binance recharge verification is disabled")
}
if strings.TrimSpace(cfg.APIKey) == "" || strings.TrimSpace(cfg.APISecret) == "" {
return cfg, nil, NewAppError(http.StatusBadRequest, "binance_credentials_missing", "binance api key and secret are required")
}
var rates []model.BinanceRechargeRate
if err := s.db.WithContext(ctx).
Where("config_id = ?", cfg.ID).
Order("sort_order asc, min_amount asc, id asc").
Find(&rates).Error; err != nil {
return cfg, nil, NewAppError(http.StatusInternalServerError, "binance_rate_query_failed", err.Error())
}
if len(rates) == 0 {
return cfg, nil, NewAppError(http.StatusBadRequest, "binance_rates_missing", "binance recharge rates are missing")
}
return cfg, rates, nil
}
func matchRate(amount *big.Rat, rates []model.BinanceRechargeRate) (*big.Rat, string, error) {
for _, row := range rates {
minRat, _, err := parseNonNegativeDecimal(row.MinAmount, "minAmount")
if err != nil {
return nil, "", err
}
maxRat, _, err := parseNonNegativeDecimal(defaultZero(row.MaxAmount), "maxAmount")
if err != nil {
return nil, "", err
}
rateRat, rateGoldPerUSD, err := parsePositiveDecimal(row.GoldPerUSD, "goldPerUsd")
if err != nil {
return nil, "", err
}
if amount.Cmp(minRat) < 0 {
continue
}
if maxRat.Sign() > 0 && amount.Cmp(maxRat) >= 0 {
continue
}
return rateRat, rateGoldPerUSD, nil
}
return nil, "", NewAppError(http.StatusBadRequest, "binance_rate_not_matched", "transfer amount does not match any configured rate range")
}
func (s *Service) prepareRechargeRecord(ctx context.Context, prepared preparedRecharge) (model.BinanceRechargeVerifyRecord, bool, error) {
var record model.BinanceRechargeVerifyRecord
var idempotent bool
var err error
for attempt := 0; attempt < 2; attempt++ {
record, idempotent, err = s.prepareRechargeRecordOnce(ctx, prepared)
if errors.Is(err, errDuplicateRechargeRecord) {
continue
}
return record, idempotent, err
}
return record, idempotent, err
}
func (s *Service) prepareRechargeRecordOnce(ctx context.Context, prepared preparedRecharge) (model.BinanceRechargeVerifyRecord, bool, error) {
var out model.BinanceRechargeVerifyRecord
var idempotent bool
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var existing []model.BinanceRechargeVerifyRecord
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("sys_origin = ? AND (order_no = ? OR (transfer_type = ? AND bill_no = ?))",
prepared.SysOrigin,
prepared.OrderNo,
prepared.TransferType,
prepared.BillNo,
).
Limit(2).
Find(&existing).Error; err != nil {
return err
}
if len(existing) > 1 {
return NewAppError(http.StatusConflict, "recharge_order_or_bill_used", "orderNo or billNo has already been used")
}
now := time.Now()
if len(existing) == 1 {
record := existing[0]
switch record.Status {
case verifyStatusSuccess:
out = record
idempotent = true
return nil
case verifyStatusPending:
if now.Sub(record.UpdateTime) < pendingRetryAfter {
return NewAppError(http.StatusConflict, "recharge_verification_processing", "recharge verification is processing")
}
}
updates := recordValues(prepared, now, verifyStatusPending)
if err := tx.Model(&model.BinanceRechargeVerifyRecord{}).
Where("id = ?", record.ID).
Updates(updates).Error; err != nil {
return err
}
if err := tx.Where("id = ?", record.ID).First(&out).Error; err != nil {
return err
}
return nil
}
record := model.BinanceRechargeVerifyRecord{
SysOrigin: prepared.SysOrigin,
DealerUserID: prepared.DealerUserID,
TargetUserID: prepared.TargetUserID,
OrderNo: prepared.OrderNo,
TransferType: prepared.TransferType,
BillNo: prepared.BillNo,
AmountUSDT: prepared.AmountUSDT,
GoldAmount: prepared.GoldAmount,
RateGoldPerUSD: prepared.RateGoldPerUSD,
BinanceTransactionID: prepared.Verified.TransactionID,
BinanceOrderType: prepared.Verified.OrderType,
BinanceRawJSON: prepared.Verified.RawJSON,
Status: verifyStatusPending,
CreateTime: now,
UpdateTime: now,
}
if err := tx.Create(&record).Error; err != nil {
if isDuplicateRecordError(err) {
return errDuplicateRechargeRecord
}
return err
}
out = record
return nil
})
if err != nil {
var appErr *AppError
if errors.As(err, &appErr) {
return out, idempotent, err
}
return out, idempotent, NewAppError(http.StatusInternalServerError, "recharge_record_prepare_failed", err.Error())
}
return out, idempotent, nil
}
func recordValues(prepared preparedRecharge, now time.Time, status string) map[string]any {
return map[string]any{
"dealer_user_id": prepared.DealerUserID,
"target_user_id": prepared.TargetUserID,
"order_no": prepared.OrderNo,
"transfer_type": prepared.TransferType,
"bill_no": prepared.BillNo,
"amount_usdt": prepared.AmountUSDT,
"gold_amount": prepared.GoldAmount,
"rate_gold_per_usd": prepared.RateGoldPerUSD,
"binance_transaction_id": prepared.Verified.TransactionID,
"binance_order_type": prepared.Verified.OrderType,
"binance_raw_json": prepared.Verified.RawJSON,
"status": status,
"fail_reason": "",
"remark": "",
"update_time": now,
}
}
func (s *Service) ensureFreightAccount(ctx context.Context, sysOrigin string, targetUserID int64) error {
exists, err := s.java.ExistsFreightBalance(ctx, targetUserID)
if err != nil {
return NewAppError(http.StatusBadGateway, "freight_balance_query_failed", err.Error())
}
if exists {
return nil
}
existsSeller, err := s.java.ExistsFreightSeller(ctx, targetUserID)
if err != nil {
return NewAppError(http.StatusBadGateway, "freight_seller_query_failed", err.Error())
}
if existsSeller {
return NewAppError(http.StatusConflict, "freight_seller_exists_without_balance", "target user is already a freight seller without balance account")
}
if err := s.java.AddFreightAgentReview(ctx, integration.FreightAgentReviewRequest{
SysOrigin: sysOrigin,
UserID: targetUserID,
}); err != nil {
return NewAppError(http.StatusBadGateway, "freight_agent_create_failed", err.Error())
}
return nil
}
func (s *Service) markRecordFailed(ctx context.Context, id int64, reason string) error {
return s.updateRecordStatus(ctx, id, verifyStatusFailed, "", truncateString(reason, 500))
}
func (s *Service) markRecordPendingIssue(ctx context.Context, id int64, reason string) error {
return s.updateRecordStatus(ctx, id, verifyStatusPending, "", truncateString(reason, 500))
}
func (s *Service) markRecordSuccess(ctx context.Context, id int64, remark string, failReason string) error {
return s.updateRecordStatus(ctx, id, verifyStatusSuccess, truncateString(remark, 500), truncateString(failReason, 500))
}
func (s *Service) updateRecordStatus(ctx context.Context, id int64, status string, remark string, failReason string) error {
if id <= 0 {
return nil
}
if err := s.db.WithContext(ctx).Model(&model.BinanceRechargeVerifyRecord{}).
Where("id = ?", id).
Updates(map[string]any{
"status": status,
"remark": remark,
"fail_reason": failReason,
"update_time": time.Now(),
}).Error; err != nil {
return NewAppError(http.StatusInternalServerError, "recharge_record_update_failed", err.Error())
}
return nil
}
func responseFromRecord(record model.BinanceRechargeVerifyRecord, idempotent bool) VerifyRechargeResponse {
return VerifyRechargeResponse{
ID: record.ID,
SysOrigin: record.SysOrigin,
DealerUserID: record.DealerUserID,
TargetUserID: record.TargetUserID,
OrderNo: record.OrderNo,
TransferType: record.TransferType,
BillNo: record.BillNo,
TransferAmount: trimDecimalZeros(record.AmountUSDT),
GoldAmount: record.GoldAmount,
RateGoldPerUSD: trimDecimalZeros(record.RateGoldPerUSD),
Status: record.Status,
Idempotent: idempotent,
}
}
func isDuplicateRecordError(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "duplicate") ||
strings.Contains(message, "unique constraint") ||
strings.Contains(message, "duplicated key")
}
func truncateString(value string, limit int) string {
value = strings.TrimSpace(value)
if limit <= 0 || len(value) <= limit {
return value
}
return value[:limit]
}

View File

@ -0,0 +1,327 @@
package cprelationbroadcast
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/service/regionimgroup"
"gorm.io/gorm"
)
// ProcessPayload 解码 Java MQ 消息并发送区域飘屏。
func (s *Service) ProcessPayload(ctx context.Context, payload string) (*regionimgroup.RegionBroadcastResponse, error) {
req, err := decodeEventPayload(payload, s.cfg.CPRelationBroadcast.MQ.Tag)
if err != nil {
return nil, err
}
if req.isEmpty() {
return nil, nil
}
return s.ProcessEvent(ctx, req)
}
// ProcessEvent 处理标准化后的 CP 关系事件。
func (s *Service) ProcessEvent(ctx context.Context, req EventRequest) (*regionimgroup.RegionBroadcastResponse, error) {
if s == nil || s.broadcaster == nil {
return nil, common.NewAppError(http.StatusInternalServerError, "cp_relation_broadcaster_missing", "cp relation broadcaster is missing")
}
if s.db == nil {
return nil, common.NewAppError(http.StatusInternalServerError, "cp_relation_db_missing", "cp relation db is missing")
}
req = normalizeEventRequest(req)
userID := req.UserID.Int64()
cpUserID := req.CpUserID.Int64()
if userID <= 0 || cpUserID <= 0 {
return nil, common.NewAppError(http.StatusBadRequest, "invalid_cp_relation_users", "userId and cpUserId are required")
}
relationType, err := normalizeRelationType(req.RelationType)
if err != nil {
return nil, err
}
sysOrigin := normalizeSysOrigin(req.SysOrigin, s.cfg.CPRelationBroadcast.DefaultSysOrigin)
user, err := s.loadEventUser(ctx, userID, req.User)
if err != nil {
return nil, err
}
cpUser, err := s.loadEventUser(ctx, cpUserID, req.CpUser)
if err != nil {
return nil, err
}
msg := relationMessage(relationType, user.displayName(), cpUser.displayName())
data := buildBroadcastData(req, sysOrigin, relationType, user, cpUser, msg)
return s.broadcaster.SendRegionBroadcast(ctx, regionimgroup.RegionBroadcastRequest{
SysOrigin: sysOrigin,
Type: MessageTypeCPRelationBroadcast,
Data: data,
})
}
func (s *Service) loadEventUser(ctx context.Context, userID int64, provided *EventUser) (EventUser, 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)
user = model.UserBaseInfo{
ID: userID,
Account: account,
UserNickname: account,
}
} else if err != nil {
return EventUser{}, err
}
if strings.TrimSpace(user.Account) == "" {
user.Account = strconv.FormatInt(userID, 10)
}
if strings.TrimSpace(user.UserNickname) == "" {
user.UserNickname = user.Account
}
return mergeEventUser(userID, user, provided), nil
}
func buildBroadcastData(req EventRequest, sysOrigin, relationType string, user EventUser, cpUser EventUser, msg string) map[string]any {
userID := user.normalizedID()
cpUserID := cpUser.normalizedID()
userIDText := strconv.FormatInt(userID, 10)
cpUserIDText := strconv.FormatInt(cpUserID, 10)
data := map[string]any{
"sysOrigin": sysOrigin,
"type": MessageTypeCPRelationBroadcast,
"relationType": relationType,
"relationLabel": relationLabel(relationType),
"msg": msg,
"userId": userIDText,
"sendUserId": userIDText,
"senderUserId": userIDText,
"cpUserId": cpUserIDText,
"acceptUserId": cpUserIDText,
"toUserId": cpUserIDText,
"user": user.toMap(),
"cpUser": cpUser.toMap(),
"account": user.Account,
"actualAccount": user.Account,
"userNickname": user.displayName(),
"nickname": user.displayName(),
"userAvatar": firstNonBlank(user.UserAvatar, user.Avatar),
"countryCode": user.CountryCode,
"countryName": user.CountryName,
"cpUserAccount": cpUser.Account,
"cpUserNickname": cpUser.displayName(),
"cpUserAvatar": firstNonBlank(cpUser.UserAvatar, cpUser.Avatar),
"cpUserCountryCode": cpUser.CountryCode,
"cpUserCountryName": cpUser.CountryName,
"acceptAccount": cpUser.Account,
"acceptNickname": cpUser.displayName(),
"acceptUserAvatar": firstNonBlank(cpUser.UserAvatar, cpUser.Avatar),
"toUserName": cpUser.displayName(),
"toUserAvatarUrl": firstNonBlank(cpUser.UserAvatar, cpUser.Avatar),
}
if eventID := strings.TrimSpace(req.EventID); eventID != "" {
data["eventId"] = eventID
}
if applyID := req.ApplyID.Int64(); applyID > 0 {
data["applyId"] = strconv.FormatInt(applyID, 10)
}
if occurredAt := strings.TrimSpace(req.OccurredAt); occurredAt != "" {
data["occurredAt"] = occurredAt
}
if len(req.Payload) > 0 {
data["payload"] = req.Payload
}
return data
}
func normalizeEventRequest(req EventRequest) EventRequest {
if req.UserID.Int64() <= 0 {
req.UserID = req.SendUserID
}
if req.CpUserID.Int64() <= 0 {
req.CpUserID = req.AcceptUserID
}
if req.UserID.Int64() <= 0 && req.User != nil {
req.UserID = flexibleInt64(req.User.normalizedID())
}
if req.CpUserID.Int64() <= 0 && req.CpUser != nil {
req.CpUserID = flexibleInt64(req.CpUser.normalizedID())
}
return req
}
func (req EventRequest) isEmpty() bool {
return strings.TrimSpace(req.EventID) == "" &&
strings.TrimSpace(req.RelationType) == "" &&
req.UserID.Int64() <= 0 &&
req.CpUserID.Int64() <= 0 &&
req.SendUserID.Int64() <= 0 &&
req.AcceptUserID.Int64() <= 0
}
func normalizeRelationType(value string) (string, error) {
normalized := strings.ToUpper(strings.TrimSpace(value))
if normalized == "" {
return RelationTypeCP, nil
}
switch normalized {
case RelationTypeCP:
return RelationTypeCP, nil
case RelationTypeBrother, "BROTHERS":
return RelationTypeBrother, nil
case RelationTypeSisters, "SISTER":
return RelationTypeSisters, nil
default:
return "", common.NewAppError(http.StatusBadRequest, "invalid_cp_relation_type", fmt.Sprintf("invalid cp relationType: %s", value))
}
}
func relationLabel(relationType string) string {
switch relationType {
case RelationTypeBrother:
return "brothers"
case RelationTypeSisters:
return "sisters"
default:
return "cp_relation"
}
}
func relationMessage(relationType, userName, cpUserName string) string {
switch relationType {
case RelationTypeBrother:
return fmt.Sprintf("%s与%s结为兄弟", userName, cpUserName)
case RelationTypeSisters:
return fmt.Sprintf("%s与%s结为姐妹", userName, cpUserName)
default:
return fmt.Sprintf("%s与%s get cp relation", userName, cpUserName)
}
}
func normalizeSysOrigin(values ...string) string {
for _, value := range values {
if text := strings.ToUpper(strings.TrimSpace(value)); text != "" {
return text
}
}
return defaultSysOrigin
}
func decodeEventPayload(payload string, expectedTag string) (EventRequest, error) {
payload = strings.TrimSpace(payload)
if payload == "" {
return EventRequest{}, nil
}
req, err := decodeEventObject([]byte(payload))
if err != nil {
return EventRequest{}, err
}
if !req.isEmpty() {
return req, nil
}
var envelope messageEventEnvelope
if err := json.Unmarshal([]byte(payload), &envelope); err != nil {
return EventRequest{}, err
}
if shouldSkipEnvelope(envelope.Tag, expectedTag) {
return EventRequest{}, nil
}
bodyPayload, ok, err := envelope.bodyPayload()
if err != nil || !ok {
return EventRequest{}, err
}
return decodeEventObject([]byte(bodyPayload))
}
type rawEventRequest struct {
EventRequest
SysOriginSnake string `json:"sys_origin"`
EventIDSnake string `json:"event_id"`
ApplyIDSnake flexibleInt64 `json:"apply_id"`
RelationTypeSnake string `json:"relation_type"`
UserIDSnake flexibleInt64 `json:"user_id"`
CpUserIDSnake flexibleInt64 `json:"cp_user_id"`
SendUserIDSnake flexibleInt64 `json:"send_user_id"`
AcceptUserIDSnake flexibleInt64 `json:"accept_user_id"`
OccurredAtSnake string `json:"occurred_at"`
TargetUserID flexibleInt64 `json:"targetUserId"`
TargetUserIDSnake flexibleInt64 `json:"target_user_id"`
ToUserIDSnake flexibleInt64 `json:"to_user_id"`
}
func decodeEventObject(data []byte) (EventRequest, error) {
var raw rawEventRequest
if err := json.Unmarshal(data, &raw); err != nil {
return EventRequest{}, err
}
req := raw.EventRequest
req.SysOrigin = firstNonBlank(req.SysOrigin, raw.SysOriginSnake)
req.EventID = firstNonBlank(req.EventID, raw.EventIDSnake)
req.RelationType = firstNonBlank(req.RelationType, raw.RelationTypeSnake)
req.OccurredAt = firstNonBlank(req.OccurredAt, raw.OccurredAtSnake)
if req.ApplyID.Int64() <= 0 {
req.ApplyID = raw.ApplyIDSnake
}
if req.UserID.Int64() <= 0 {
req.UserID = firstFlexible(raw.UserIDSnake, req.SendUserID, raw.SendUserIDSnake)
}
if req.CpUserID.Int64() <= 0 {
req.CpUserID = firstFlexible(raw.CpUserIDSnake, req.AcceptUserID, raw.AcceptUserIDSnake, raw.TargetUserID, raw.TargetUserIDSnake, raw.ToUserIDSnake)
}
if req.SendUserID.Int64() <= 0 {
req.SendUserID = firstFlexible(req.UserID, raw.SendUserIDSnake)
}
if req.AcceptUserID.Int64() <= 0 {
req.AcceptUserID = firstFlexible(req.CpUserID, raw.AcceptUserIDSnake, raw.TargetUserID, raw.TargetUserIDSnake, raw.ToUserIDSnake)
}
return req, nil
}
func firstFlexible(values ...flexibleInt64) flexibleInt64 {
for _, value := range values {
if value.Int64() > 0 {
return value
}
}
return 0
}
type messageEventEnvelope struct {
Tag string `json:"tag"`
Body json.RawMessage `json:"body"`
}
func (e messageEventEnvelope) bodyPayload() (string, bool, error) {
raw := strings.TrimSpace(string(e.Body))
if raw == "" || raw == "null" {
return "", false, nil
}
if strings.HasPrefix(raw, "\"") {
var body string
if err := json.Unmarshal(e.Body, &body); err != nil {
return "", false, err
}
body = strings.TrimSpace(body)
return body, body != "", nil
}
return raw, true, nil
}
func shouldSkipEnvelope(actualTag, expectedTag string) bool {
actualTag = strings.TrimSpace(actualTag)
expectedTag = strings.TrimSpace(expectedTag)
if actualTag == "" || expectedTag == "" || expectedTag == "*" {
return false
}
return actualTag != expectedTag
}

View File

@ -0,0 +1,142 @@
package cprelationbroadcast
import (
"context"
"testing"
"time"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/service/regionimgroup"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestProcessEventBuildsCPRelationBroadcastPayload(t *testing.T) {
service, broadcaster := newCPRelationBroadcastTestService(t)
_, err := service.ProcessEvent(context.Background(), EventRequest{
SysOrigin: "LIKEI",
EventID: "CP_RELATION:9001",
ApplyID: flexibleInt64(9001),
RelationType: RelationTypeCP,
UserID: flexibleInt64(1001),
CpUserID: flexibleInt64(1002),
OccurredAt: "2026-05-22T12:00:00Z",
})
if err != nil {
t.Fatalf("ProcessEvent() error = %v", err)
}
if len(broadcaster.requests) != 1 {
t.Fatalf("requests = %d, want 1", len(broadcaster.requests))
}
req := broadcaster.requests[0]
if req.Type != MessageTypeCPRelationBroadcast {
t.Fatalf("type = %q", req.Type)
}
data := req.Data
if data["msg"] != "Alice与Bob get cp relation" ||
data["relationType"] != RelationTypeCP ||
data["userId"] != "1001" ||
data["cpUserId"] != "1002" {
t.Fatalf("payload = %+v", data)
}
user, ok := data["user"].(map[string]any)
if !ok || user["userNickname"] != "Alice" || user["account"] != "alice" {
t.Fatalf("user payload = %+v", data["user"])
}
cpUser, ok := data["cpUser"].(map[string]any)
if !ok || cpUser["userNickname"] != "Bob" || cpUser["account"] != "bob" {
t.Fatalf("cpUser payload = %+v", data["cpUser"])
}
}
func TestProcessEventBuildsBrotherAndSistersMessages(t *testing.T) {
service, broadcaster := newCPRelationBroadcastTestService(t)
cases := []struct {
relationType string
wantMsg string
}{
{RelationTypeBrother, "Alice与Bob结为兄弟"},
{RelationTypeSisters, "Alice与Bob结为姐妹"},
}
for _, tc := range cases {
broadcaster.requests = nil
_, err := service.ProcessEvent(context.Background(), EventRequest{
RelationType: tc.relationType,
UserID: flexibleInt64(1001),
CpUserID: flexibleInt64(1002),
})
if err != nil {
t.Fatalf("ProcessEvent(%s) error = %v", tc.relationType, err)
}
if len(broadcaster.requests) != 1 {
t.Fatalf("requests = %d, want 1", len(broadcaster.requests))
}
if got := broadcaster.requests[0].Data["msg"]; got != tc.wantMsg {
t.Fatalf("msg = %v, want %s", got, tc.wantMsg)
}
}
}
func TestProcessPayloadDecodesMessageEventEnvelopeAndAliases(t *testing.T) {
service, broadcaster := newCPRelationBroadcastTestService(t)
service.cfg.CPRelationBroadcast.MQ.Tag = "cp_relation_broadcast"
payload := `{"tag":"cp_relation_broadcast","body":"{\"sys_origin\":\"likei\",\"event_id\":\"CP_RELATION:9002\",\"apply_id\":\"9002\",\"relation_type\":\"BROTHERS\",\"send_user_id\":\"1001\",\"accept_user_id\":\"1002\"}"}`
_, err := service.ProcessPayload(context.Background(), payload)
if err != nil {
t.Fatalf("ProcessPayload() error = %v", err)
}
if len(broadcaster.requests) != 1 {
t.Fatalf("requests = %d, want 1", len(broadcaster.requests))
}
req := broadcaster.requests[0]
if req.SysOrigin != "LIKEI" {
t.Fatalf("sysOrigin = %q", req.SysOrigin)
}
if req.Data["eventId"] != "CP_RELATION:9002" ||
req.Data["applyId"] != "9002" ||
req.Data["relationType"] != RelationTypeBrother ||
req.Data["msg"] != "Alice与Bob结为兄弟" {
t.Fatalf("payload = %+v", req.Data)
}
}
func newCPRelationBroadcastTestService(t *testing.T) (*Service, *fakeCPRegionBroadcaster) {
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{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
now := time.Now()
if err := db.Create(&[]model.UserBaseInfo{
{ID: 1001, Account: "alice", UserNickname: "Alice", UserAvatar: "alice.png", OriginSys: "LIKEI", CountryCode: "SA", CountryName: "Saudi Arabia", CreateTime: now},
{ID: 1002, Account: "bob", UserNickname: "Bob", UserAvatar: "bob.png", OriginSys: "LIKEI", CountryCode: "SA", CountryName: "Saudi Arabia", CreateTime: now},
}).Error; err != nil {
t.Fatalf("seed users: %v", err)
}
broadcaster := &fakeCPRegionBroadcaster{}
return NewService(config.Config{
CPRelationBroadcast: config.CPRelationBroadcastConfig{DefaultSysOrigin: "LIKEI"},
}, db, broadcaster), broadcaster
}
type fakeCPRegionBroadcaster struct {
requests []regionimgroup.RegionBroadcastRequest
}
func (f *fakeCPRegionBroadcaster) SendRegionBroadcast(_ context.Context, req regionimgroup.RegionBroadcastRequest) (*regionimgroup.RegionBroadcastResponse, error) {
f.requests = append(f.requests, req)
return &regionimgroup.RegionBroadcastResponse{
SysOrigin: req.SysOrigin,
RegionCode: "AR",
GroupID: "@region-ar",
Type: req.Type,
}, nil
}

View File

@ -0,0 +1,139 @@
package cprelationbroadcast
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/common"
rmq "github.com/apache/rocketmq-clients/golang/v5"
"github.com/apache/rocketmq-clients/golang/v5/credentials"
)
// StartMessageConsumer 启动 CP 关系成功 MQ 消费链路。
func (s *Service) StartMessageConsumer(ctx context.Context) error {
if !s.cfg.CPRelationBroadcast.MQ.Enabled {
log.Printf("cp relation broadcast rocketmq consumer disabled")
return nil
}
if strings.TrimSpace(s.cfg.CPRelationBroadcast.MQ.Endpoint) == "" ||
strings.TrimSpace(s.cfg.CPRelationBroadcast.MQ.AccessKey) == "" ||
strings.TrimSpace(s.cfg.CPRelationBroadcast.MQ.AccessSecret) == "" ||
strings.TrimSpace(s.cfg.CPRelationBroadcast.MQ.Topic) == "" ||
strings.TrimSpace(s.cfg.CPRelationBroadcast.MQ.ConsumerGroup) == "" {
log.Printf("cp relation broadcast rocketmq consumer skipped: endpoint, credentials, topic or consumer group missing")
return nil
}
go s.startRocketMQConsumerWithRetry(ctx)
return nil
}
func (s *Service) startRocketMQConsumerWithRetry(ctx context.Context) {
retryDelay := 5 * time.Second
for {
if ctx.Err() != nil {
return
}
if err := s.startRocketMQConsumer(ctx); err != nil {
log.Printf("start cp relation broadcast rocketmq consumer failed: %v; retry in %s", err, retryDelay)
timer := time.NewTimer(retryDelay)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
if retryDelay < time.Minute {
retryDelay *= 2
if retryDelay > time.Minute {
retryDelay = time.Minute
}
}
continue
}
return
}
}
func (s *Service) startRocketMQConsumer(ctx context.Context) error {
filter := rmq.SUB_ALL
if tag := strings.TrimSpace(s.cfg.CPRelationBroadcast.MQ.Tag); tag != "" && tag != "*" {
filter = rmq.NewFilterExpression(tag)
}
consumer, err := rmq.NewPushConsumer(&rmq.Config{
Endpoint: s.cfg.CPRelationBroadcast.MQ.Endpoint,
NameSpace: s.cfg.CPRelationBroadcast.MQ.Namespace,
ConsumerGroup: s.cfg.CPRelationBroadcast.MQ.ConsumerGroup,
Credentials: &credentials.SessionCredentials{
AccessKey: s.cfg.CPRelationBroadcast.MQ.AccessKey,
AccessSecret: s.cfg.CPRelationBroadcast.MQ.AccessSecret,
SecurityToken: s.cfg.CPRelationBroadcast.MQ.SecurityToken,
},
},
rmq.WithPushSubscriptionExpressions(map[string]*rmq.FilterExpression{
s.cfg.CPRelationBroadcast.MQ.Topic: filter,
}),
rmq.WithPushConsumptionThreadCount(4),
rmq.WithPushMaxCacheMessageCount(256),
rmq.WithPushMessageListener(&rmq.FuncMessageListener{
Consume: func(messageView *rmq.MessageView) rmq.ConsumerResult {
if messageView == nil {
return rmq.SUCCESS
}
if err := s.processRocketMQMessage(context.Background(), messageView); err != nil {
log.Printf("cp relation broadcast mq process failed. messageId=%s err=%v", messageView.GetMessageId(), err)
return rmq.FAILURE
}
return rmq.SUCCESS
},
}),
)
if err != nil {
return err
}
if err := consumer.Start(); err != nil {
return err
}
s.rocketConsumer = consumer
log.Printf("cp relation broadcast rocketmq consumer started. topic=%s group=%s tag=%s",
s.cfg.CPRelationBroadcast.MQ.Topic,
s.cfg.CPRelationBroadcast.MQ.ConsumerGroup,
s.cfg.CPRelationBroadcast.MQ.Tag,
)
go func() {
<-ctx.Done()
if s.rocketConsumer != nil {
_ = s.rocketConsumer.GracefulStop()
}
}()
return nil
}
func (s *Service) processRocketMQMessage(ctx context.Context, messageView *rmq.MessageView) error {
payload := strings.TrimSpace(string(messageView.GetBody()))
if payload == "" {
return nil
}
if _, err := s.ProcessPayload(ctx, payload); err != nil {
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
if errors.As(err, &syntaxErr) || errors.As(err, &typeErr) {
log.Printf("drop malformed cp relation broadcast message. messageId=%s err=%v", messageView.GetMessageId(), err)
return nil
}
var appErr *common.AppError
if errors.As(err, &appErr) && appErr.Status >= http.StatusBadRequest && appErr.Status < http.StatusInternalServerError {
log.Printf("drop invalid cp relation broadcast message. messageId=%s code=%s err=%v", messageView.GetMessageId(), appErr.Code, err)
return nil
}
return err
}
return nil
}

View File

@ -0,0 +1,199 @@
package cprelationbroadcast
import (
"context"
"fmt"
"strconv"
"strings"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/service/regionimgroup"
rmq "github.com/apache/rocketmq-clients/golang/v5"
"gorm.io/gorm"
)
const (
MessageTypeCPRelationBroadcast = "CP_RELATION_BROADCAST"
RelationTypeCP = "CP"
RelationTypeBrother = "BROTHER"
RelationTypeSisters = "SISTERS"
defaultSysOrigin = "LIKEI"
)
type dbHandle interface {
WithContext(ctx context.Context) *gorm.DB
}
type regionBroadcaster interface {
SendRegionBroadcast(ctx context.Context, req regionimgroup.RegionBroadcastRequest) (*regionimgroup.RegionBroadcastResponse, error)
}
// Service 消费 Java CP 关系成功 MQ并复用区域 IM 广播发送飘屏。
type Service struct {
cfg config.Config
db dbHandle
broadcaster regionBroadcaster
rocketConsumer rmq.PushConsumer
}
func NewService(cfg config.Config, db dbHandle, broadcaster regionBroadcaster) *Service {
return &Service{cfg: cfg, db: db, broadcaster: broadcaster}
}
// EventRequest 是 Java 在 CP/兄弟/姐妹关系达成后投递给 Go 的 MQ 事件。
type EventRequest struct {
SysOrigin string `json:"sysOrigin"`
EventID string `json:"eventId"`
ApplyID flexibleInt64 `json:"applyId"`
RelationType string `json:"relationType"`
UserID flexibleInt64 `json:"userId"`
CpUserID flexibleInt64 `json:"cpUserId"`
SendUserID flexibleInt64 `json:"sendUserId"`
AcceptUserID flexibleInt64 `json:"acceptUserId"`
OccurredAt string `json:"occurredAt"`
User *EventUser `json:"user"`
CpUser *EventUser `json:"cpUser"`
Payload map[string]any `json:"payload,omitempty"`
}
type EventUser struct {
ID flexibleInt64 `json:"id"`
UserID flexibleInt64 `json:"userId"`
Account string `json:"account"`
Nickname string `json:"nickname"`
UserNickname string `json:"userNickname"`
Avatar string `json:"avatar"`
UserAvatar string `json:"userAvatar"`
CountryCode string `json:"countryCode"`
CountryName string `json:"countryName"`
OriginSys string `json:"originSys"`
}
func (u EventUser) normalizedID() int64 {
if id := u.UserID.Int64(); id > 0 {
return id
}
return u.ID.Int64()
}
func (u EventUser) displayName() string {
for _, value := range []string{u.UserNickname, u.Nickname, u.Account} {
if text := strings.TrimSpace(value); text != "" {
return text
}
}
if id := u.normalizedID(); id > 0 {
return strconv.FormatInt(id, 10)
}
return ""
}
func (u EventUser) toMap() map[string]any {
id := u.normalizedID()
data := map[string]any{}
if id > 0 {
data["userId"] = strconv.FormatInt(id, 10)
data["id"] = strconv.FormatInt(id, 10)
}
if account := strings.TrimSpace(u.Account); account != "" {
data["account"] = account
}
if nickname := strings.TrimSpace(firstNonBlank(u.UserNickname, u.Nickname)); nickname != "" {
data["userNickname"] = nickname
data["nickname"] = nickname
}
if avatar := strings.TrimSpace(firstNonBlank(u.UserAvatar, u.Avatar)); avatar != "" {
data["userAvatar"] = avatar
data["avatar"] = avatar
}
if countryCode := strings.TrimSpace(u.CountryCode); countryCode != "" {
data["countryCode"] = countryCode
}
if countryName := strings.TrimSpace(u.CountryName); countryName != "" {
data["countryName"] = countryName
}
if originSys := strings.TrimSpace(u.OriginSys); originSys != "" {
data["originSys"] = originSys
}
return data
}
func eventUserFromModel(user model.UserBaseInfo) EventUser {
return EventUser{
ID: flexibleInt64(user.ID),
UserID: flexibleInt64(user.ID),
Account: user.Account,
Nickname: user.UserNickname,
UserNickname: user.UserNickname,
Avatar: user.UserAvatar,
UserAvatar: user.UserAvatar,
CountryCode: user.CountryCode,
CountryName: user.CountryName,
OriginSys: user.OriginSys,
}
}
func mergeEventUser(id int64, dbUser model.UserBaseInfo, provided *EventUser) EventUser {
merged := eventUserFromModel(dbUser)
if id > 0 {
merged.ID = flexibleInt64(id)
merged.UserID = flexibleInt64(id)
}
if provided == nil {
return merged
}
if provided.normalizedID() > 0 {
merged.ID = flexibleInt64(provided.normalizedID())
merged.UserID = flexibleInt64(provided.normalizedID())
}
merged.Account = firstNonBlank(provided.Account, merged.Account)
merged.UserNickname = firstNonBlank(provided.UserNickname, provided.Nickname, merged.UserNickname)
merged.Nickname = merged.UserNickname
merged.UserAvatar = firstNonBlank(provided.UserAvatar, provided.Avatar, merged.UserAvatar)
merged.Avatar = merged.UserAvatar
merged.CountryCode = firstNonBlank(provided.CountryCode, merged.CountryCode)
merged.CountryName = firstNonBlank(provided.CountryName, merged.CountryName)
merged.OriginSys = firstNonBlank(provided.OriginSys, merged.OriginSys)
return merged
}
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 firstNonBlank(values ...string) string {
for _, value := range values {
if text := strings.TrimSpace(value); text != "" {
return text
}
}
return ""
}

View File

@ -0,0 +1,173 @@
package errorlog
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
)
// ReportAgoraError persists one client-reported Agora error log.
func (s *Service) ReportAgoraError(ctx context.Context, user AuthUser, req AgoraErrorReportRequest, meta AgoraErrorReportContext) (*AgoraErrorReportResponse, error) {
if s == nil || s.db == nil {
return nil, NewAppError(http.StatusServiceUnavailable, "error_log_unavailable", "error log service is unavailable")
}
roomID := req.RoomID.String()
if roomID == "" {
return nil, NewAppError(http.StatusBadRequest, "room_id_required", "roomId is required")
}
reasonType := firstNonBlank(req.ConnectionChangedReasonType, req.ReasonType, req.Reason)
bannedByServer := isBannedByServer(reasonType)
id, err := utils.NextID()
if err != nil {
return nil, err
}
now := time.Now()
row := model.AgoraErrorLog{
ID: id,
SysOrigin: normalizeSysOrigin(user.SysOrigin),
UserID: user.UserID,
RoomID: trimMax(roomID, 64),
AgoraUID: trimMax(req.AgoraUID.String(), 64),
ErrorCodeType: trimMax(req.ErrorCodeType, 128),
ConnectionChangedReasonType: trimMax(reasonType, 128),
BannedByServer: bannedByServer,
IsTimeout: boolValue(req.Timeout, req.IsTimeout),
TokenRequestSuccess: boolPtrValue(req.TokenRequestSuccess, req.TokenSuccess),
NetworkType: trimMax(req.NetworkType, 64),
ReqClient: trimMax(meta.ReqClient, 32),
ReqAppIntel: trimMax(meta.ReqAppIntel, 255),
ClientIP: trimMax(meta.ClientIP, 64),
UserAgent: trimMax(meta.UserAgent, 255),
RawPayload: strings.TrimSpace(meta.RawPayload),
CreateTime: now,
UpdateTime: now,
}
if err := s.db.WithContext(ctx).Create(&row).Error; err != nil {
return nil, err
}
return &AgoraErrorReportResponse{
ID: strconv.FormatInt(id, 10),
BannedByServer: bannedByServer,
}, nil
}
// PageAgoraErrorLogs returns admin Agora error logs.
func (s *Service) PageAgoraErrorLogs(ctx context.Context, query AgoraErrorLogQuery) (*AgoraErrorLogPageResponse, error) {
if s == nil || s.db == nil {
return nil, NewAppError(http.StatusServiceUnavailable, "error_log_unavailable", "error log service is unavailable")
}
cursor, limit := normalizePage(query.Cursor, query.Limit)
startTime, endTime, err := parseAdminTimeRange(query.StartTime, query.EndTime)
if err != nil {
return nil, err
}
filter := query
filter.Cursor = cursor
filter.Limit = limit
filter.StartAt = startTime
filter.EndAt = endTime
base := s.agoraErrorLogQuery(ctx, filter)
var total int64
if err := base.Count(&total).Error; err != nil {
return nil, err
}
var rows []model.AgoraErrorLog
if err := s.agoraErrorLogQuery(ctx, filter).
Order("create_time desc, id desc").
Offset((cursor - 1) * limit).
Limit(limit).
Find(&rows).Error; err != nil {
return nil, err
}
return &AgoraErrorLogPageResponse{
Records: agoraErrorLogItems(rows),
Total: total,
Current: cursor,
Size: limit,
}, nil
}
// AgoraErrorLogQuery is the admin filter for Agora error logs.
type AgoraErrorLogQuery struct {
SysOrigin string
UserID int64
RoomID string
AgoraUID string
ErrorCodeType string
ReasonType string
BannedOnly bool
NetworkType string
StartTime string
EndTime string
Cursor int
Limit int
StartAt time.Time
EndAt time.Time
}
func (s *Service) agoraErrorLogQuery(ctx context.Context, filter AgoraErrorLogQuery) *gorm.DB {
query := s.db.WithContext(ctx).Model(&model.AgoraErrorLog{}).
Where("create_time >= ? AND create_time <= ?", filter.StartAt, filter.EndAt)
if sysOrigin := normalizeSysOrigin(filter.SysOrigin); sysOrigin != "" {
query = query.Where("sys_origin = ?", sysOrigin)
}
if filter.UserID > 0 {
query = query.Where("user_id = ?", filter.UserID)
}
if roomID := strings.TrimSpace(filter.RoomID); roomID != "" {
query = query.Where("room_id = ?", roomID)
}
if agoraUID := strings.TrimSpace(filter.AgoraUID); agoraUID != "" {
query = query.Where("agora_uid = ?", agoraUID)
}
if codeType := strings.TrimSpace(filter.ErrorCodeType); codeType != "" {
query = query.Where("error_code_type LIKE ?", "%"+codeType+"%")
}
if reasonType := strings.TrimSpace(filter.ReasonType); reasonType != "" {
query = query.Where("connection_changed_reason_type LIKE ?", "%"+reasonType+"%")
}
if filter.BannedOnly {
query = query.Where("banned_by_server = ?", true)
}
if networkType := strings.TrimSpace(filter.NetworkType); networkType != "" {
query = query.Where("network_type = ?", networkType)
}
return query
}
func agoraErrorLogItems(rows []model.AgoraErrorLog) []AgoraErrorLogItem {
items := make([]AgoraErrorLogItem, 0, len(rows))
for _, row := range rows {
items = append(items, AgoraErrorLogItem{
ID: strconv.FormatInt(row.ID, 10),
SysOrigin: row.SysOrigin,
UserID: strconv.FormatInt(row.UserID, 10),
RoomID: row.RoomID,
AgoraUID: row.AgoraUID,
ErrorCodeType: row.ErrorCodeType,
ConnectionChangedReasonType: row.ConnectionChangedReasonType,
BannedByServer: row.BannedByServer,
IsTimeout: row.IsTimeout,
TokenRequestSuccess: row.TokenRequestSuccess,
NetworkType: row.NetworkType,
ReqClient: row.ReqClient,
ReqAppIntel: row.ReqAppIntel,
ClientIP: row.ClientIP,
UserAgent: row.UserAgent,
RawPayload: row.RawPayload,
CreateTime: formatDateTime(row.CreateTime),
UpdateTime: formatDateTime(row.UpdateTime),
})
}
return items
}

View File

@ -0,0 +1,84 @@
package errorlog
import (
"context"
"testing"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestReportAgoraErrorDetectsBannedByServer(t *testing.T) {
service := newTestService(t)
tokenOK := false
timeout := true
resp, err := service.ReportAgoraError(context.Background(), common.AuthUser{
UserID: 1234567,
SysOrigin: "LIKEI",
}, AgoraErrorReportRequest{
RoomID: StringValue("9001"),
AgoraUID: StringValue("8888"),
ErrorCodeType: "ERR_LEAVE_CHANNEL_REJECTED",
ConnectionChangedReasonType: "BannedByServer(3)",
Timeout: &timeout,
TokenRequestSuccess: &tokenOK,
NetworkType: "wifi",
}, AgoraErrorReportContext{
ReqClient: "Android",
ReqAppIntel: "version=1.0.0;build=100",
ClientIP: "127.0.0.1",
RawPayload: `{"roomId":"9001","connectionChangedReasonType":"BannedByServer(3)"}`,
})
if err != nil {
t.Fatalf("ReportAgoraError returned error: %v", err)
}
if !resp.BannedByServer {
t.Fatalf("BannedByServer = false, want true")
}
page, err := service.PageAgoraErrorLogs(context.Background(), AgoraErrorLogQuery{
SysOrigin: "LIKEI",
BannedOnly: true,
Cursor: 1,
Limit: 20,
})
if err != nil {
t.Fatalf("PageAgoraErrorLogs returned error: %v", err)
}
if page.Total != 1 || len(page.Records) != 1 {
t.Fatalf("page total=%d records=%d, want 1", page.Total, len(page.Records))
}
record := page.Records[0]
if record.RoomID != "9001" || record.AgoraUID != "8888" || !record.IsTimeout {
t.Fatalf("unexpected record: %+v", record)
}
if record.TokenRequestSuccess == nil || *record.TokenRequestSuccess {
t.Fatalf("TokenRequestSuccess = %v, want false", record.TokenRequestSuccess)
}
}
func TestStringValueAcceptsNumericJSON(t *testing.T) {
var value StringValue
if err := value.UnmarshalJSON([]byte(`1838887943867809794`)); err != nil {
t.Fatalf("UnmarshalJSON returned error: %v", err)
}
if value.String() != "1838887943867809794" {
t.Fatalf("value = %q, want numeric string", value.String())
}
}
func newTestService(t *testing.T) *Service {
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.AgoraErrorLog{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
return NewService(db)
}

View File

@ -0,0 +1,263 @@
package errorlog
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/common"
"gorm.io/gorm"
)
const adminTimeLayout = "2006-01-02 15:04:05"
type AppError = common.AppError
type AuthUser = common.AuthUser
var NewAppError = common.NewAppError
type dbHandle interface {
WithContext(ctx context.Context) *gorm.DB
}
// Service handles app error logs.
type Service struct {
db dbHandle
}
// NewService creates an app error log service.
func NewService(db dbHandle) *Service {
return &Service{db: db}
}
// StringValue accepts JSON string or number and stores it as a string.
type StringValue string
func (v StringValue) String() string {
return strings.TrimSpace(string(v))
}
// UnmarshalJSON accepts quoted strings and numeric IDs from clients.
func (v *StringValue) UnmarshalJSON(data []byte) error {
raw := strings.TrimSpace(string(data))
if raw == "" || raw == "null" {
*v = ""
return nil
}
if strings.HasPrefix(raw, "\"") {
unquoted, err := strconv.Unquote(raw)
if err != nil {
return err
}
*v = StringValue(strings.TrimSpace(unquoted))
return nil
}
*v = StringValue(raw)
return nil
}
// AgoraErrorReportRequest is the client report payload for Agora errors.
type AgoraErrorReportRequest struct {
RoomID StringValue `json:"roomId"`
AgoraUID StringValue `json:"agoraUid"`
ErrorCodeType string `json:"errorCodeType"`
ConnectionChangedReasonType string `json:"connectionChangedReasonType"`
ReasonType string `json:"reasonType"`
Reason string `json:"reason"`
Timeout *bool `json:"timeout"`
IsTimeout *bool `json:"isTimeout"`
TokenRequestSuccess *bool `json:"tokenRequestSuccess"`
TokenSuccess *bool `json:"tokenSuccess"`
NetworkType string `json:"networkType"`
}
// AgoraErrorReportContext carries request metadata captured at the router layer.
type AgoraErrorReportContext struct {
ReqClient string
ReqAppIntel string
ClientIP string
UserAgent string
RawPayload string
}
// AgoraErrorReportResponse is returned after a report is persisted.
type AgoraErrorReportResponse struct {
ID string `json:"id"`
BannedByServer bool `json:"bannedByServer"`
}
// AgoraErrorLogPageResponse is the admin page response.
type AgoraErrorLogPageResponse struct {
Records []AgoraErrorLogItem `json:"records"`
Total int64 `json:"total"`
Current int `json:"current"`
Size int `json:"size"`
}
// AgoraErrorLogItem is an admin-readable Agora error log.
type AgoraErrorLogItem struct {
ID string `json:"id"`
SysOrigin string `json:"sysOrigin"`
UserID string `json:"userId"`
RoomID string `json:"roomId"`
AgoraUID string `json:"agoraUid"`
ErrorCodeType string `json:"errorCodeType"`
ConnectionChangedReasonType string `json:"connectionChangedReasonType"`
BannedByServer bool `json:"bannedByServer"`
IsTimeout bool `json:"isTimeout"`
TokenRequestSuccess *bool `json:"tokenRequestSuccess"`
NetworkType string `json:"networkType"`
ReqClient string `json:"reqClient"`
ReqAppIntel string `json:"reqAppIntel"`
ClientIP string `json:"clientIp"`
UserAgent string `json:"userAgent"`
RawPayload string `json:"rawPayload"`
CreateTime string `json:"createTime"`
UpdateTime string `json:"updateTime"`
}
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 normalizeSysOrigin(sysOrigin string) string {
return strings.ToUpper(strings.TrimSpace(sysOrigin))
}
func trimMax(value string, max int) string {
value = strings.TrimSpace(value)
if max > 0 && len(value) > max {
return value[:max]
}
return value
}
func boolValue(primary *bool, aliases ...*bool) bool {
if primary != nil {
return *primary
}
for _, alias := range aliases {
if alias != nil {
return *alias
}
}
return false
}
func boolPtrValue(primary *bool, aliases ...*bool) *bool {
if primary != nil {
return primary
}
for _, alias := range aliases {
if alias != nil {
return alias
}
}
return nil
}
func firstNonBlank(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func isBannedByServer(reason string) bool {
normalized := strings.ToLower(strings.TrimSpace(reason))
if normalized == "" {
return false
}
return normalized == "3" ||
strings.Contains(normalized, "bannedbyserver") ||
strings.Contains(normalized, "banned_by_server") ||
strings.Contains(normalized, "banned by server")
}
func formatDateTime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.Format(adminTimeLayout)
}
func parseAdminTimeRange(startRaw string, endRaw string) (time.Time, time.Time, error) {
startRaw = strings.TrimSpace(startRaw)
endRaw = strings.TrimSpace(endRaw)
now := time.Now()
if startRaw == "" && endRaw == "" {
return now.AddDate(0, 0, -7), now, nil
}
var start time.Time
var end time.Time
var err error
if startRaw != "" {
start, err = parseAdminTime(startRaw, false)
if err != nil {
return time.Time{}, time.Time{}, err
}
}
if endRaw != "" {
end, err = parseAdminTime(endRaw, true)
if err != nil {
return time.Time{}, time.Time{}, err
}
}
if startRaw == "" {
start = end.AddDate(0, 0, -7)
}
if endRaw == "" {
end = start.AddDate(0, 0, 7)
}
if end.Before(start) {
return time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "bad_time_range", "endTime must be greater than startTime")
}
return start, end, nil
}
func parseAdminTime(raw string, endOfDay bool) (time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, nil
}
if millis, err := strconv.ParseInt(raw, 10, 64); err == nil {
return time.UnixMilli(millis), nil
}
for _, layout := range []string{
adminTimeLayout,
time.RFC3339,
"2006-01-02T15:04:05.000Z07:00",
"2006-01-02",
} {
var parsed time.Time
var err error
if strings.Contains(layout, "Z07:00") || layout == time.RFC3339 {
parsed, err = time.Parse(layout, raw)
} else {
parsed, err = time.ParseInLocation(layout, raw, time.Local)
}
if err == nil {
if layout == "2006-01-02" && endOfDay {
parsed = parsed.Add(24*time.Hour - time.Nanosecond)
}
return parsed, nil
}
}
return time.Time{}, NewAppError(http.StatusBadRequest, "bad_time", fmt.Sprintf("invalid time: %s", raw))
}

View File

@ -0,0 +1,249 @@
package firstrechargereward
import (
"context"
"errors"
"net/http"
"sort"
"strings"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
)
func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*ConfigResponse, error) {
sysOrigin = s.normalizeSysOrigin(sysOrigin)
var configRow model.FirstRechargeRewardConfig
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,
LevelConfigs: []LevelPayload{},
}, nil
}
if err != nil {
return nil, err
}
levels, err := s.loadLevels(ctx, configRow.ID)
if err != nil {
return nil, err
}
rewardItems := s.loadRewardItemsMap(ctx, levels)
return &ConfigResponse{
Configured: true,
ID: configRow.ID,
SysOrigin: configRow.SysOrigin,
Enabled: configRow.Enabled,
MinAppVersion: strings.TrimSpace(configRow.MinAppVersion),
UpdateTime: formatDateTime(configRow.UpdateTime),
LevelConfigs: buildLevelPayloads(levels, rewardItems, 0, 0, true),
}, nil
}
func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*ConfigResponse, error) {
requestID := req.ID.Int64()
sysOrigin := s.normalizeSysOrigin(req.SysOrigin)
levels, err := s.normalizeLevelInputs(req.LevelConfigs)
if err != nil {
return nil, err
}
if req.Enabled && !hasEnabledLevel(levels) {
return nil, NewAppError(http.StatusBadRequest, "invalid_level_configs", "enabled config must contain at least one enabled level")
}
minAppVersion := strings.TrimSpace(req.MinAppVersion)
if req.Enabled && minAppVersion == "" {
return nil, NewAppError(http.StatusBadRequest, "invalid_min_app_version", "minAppVersion is required when enabled")
}
var savedConfigID int64
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
now := time.Now()
var configRow model.FirstRechargeRewardConfig
if requestID > 0 {
err := tx.Where("id = ?", requestID).First(&configRow).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return NewAppError(http.StatusNotFound, "first_recharge_reward_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.FirstRechargeRewardConfig{
ID: configID,
SysOrigin: sysOrigin,
CreateTime: now,
}
} else if err != nil {
return err
}
}
configRow.SysOrigin = sysOrigin
configRow.Enabled = req.Enabled
configRow.MinAppVersion = minAppVersion
configRow.UpdateTime = now
if configRow.CreateTime.IsZero() {
configRow.CreateTime = now
}
if err := tx.Save(&configRow).Error; err != nil {
return err
}
if err := tx.Where("config_id = ?", configRow.ID).Delete(&model.FirstRechargeRewardLevel{}).Error; err != nil {
return err
}
for index := range levels {
levels[index].ConfigID = configRow.ID
levels[index].CreateTime = now
levels[index].UpdateTime = now
}
if len(levels) > 0 {
if err := tx.Create(&levels).Error; err != nil {
return err
}
}
savedConfigID = configRow.ID
return nil
}); err != nil {
return nil, err
}
return s.GetConfig(ctx, savedSysOrigin(ctx, s.db, savedConfigID, sysOrigin))
}
func (s *Service) normalizeLevelInputs(inputs []LevelInput) ([]model.FirstRechargeRewardLevel, error) {
if len(inputs) == 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_level_configs", "levelConfigs is required")
}
seenLevels := make(map[int]struct{}, len(inputs))
seenAmounts := make(map[int64]struct{}, len(inputs))
seenGoogleProductIDs := make(map[string]struct{}, len(inputs))
rows := make([]model.FirstRechargeRewardLevel, 0, len(inputs))
for _, item := range inputs {
if item.Level <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_level", "level must be greater than 0")
}
amountCents := item.RechargeAmount.Cents()
if amountCents == 0 && item.RechargeAmountCents > 0 {
amountCents = item.RechargeAmountCents
}
if amountCents <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_recharge_amount", "rechargeAmount must be greater than 0")
}
rewardGroupID := item.RewardGroupID.Int64()
if rewardGroupID <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_reward_group", "rewardGroupId is required")
}
if _, exists := seenLevels[item.Level]; exists {
return nil, NewAppError(http.StatusBadRequest, "duplicate_level", "levelConfigs must not contain duplicate levels")
}
if _, exists := seenAmounts[amountCents]; exists {
return nil, NewAppError(http.StatusBadRequest, "duplicate_recharge_amount", "levelConfigs must not contain duplicate rechargeAmount")
}
googleProductID := normalizeGoogleProductID(item.GoogleProductID)
if googleProductID != "" {
if _, exists := seenGoogleProductIDs[googleProductID]; exists {
return nil, NewAppError(http.StatusBadRequest, "duplicate_google_product_id", "levelConfigs must not contain duplicate googleProductId")
}
seenGoogleProductIDs[googleProductID] = struct{}{}
}
seenLevels[item.Level] = struct{}{}
seenAmounts[amountCents] = struct{}{}
id, err := utils.NextID()
if err != nil {
return nil, err
}
rows = append(rows, model.FirstRechargeRewardLevel{
ID: id,
Level: item.Level,
RechargeAmountCents: amountCents,
GoogleProductID: googleProductID,
RewardGroupID: rewardGroupID,
RewardGroupName: strings.TrimSpace(item.RewardGroupName),
Enabled: item.Enabled,
})
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].RechargeAmountCents == rows[j].RechargeAmountCents {
return rows[i].Level < rows[j].Level
}
return rows[i].RechargeAmountCents < rows[j].RechargeAmountCents
})
return rows, nil
}
func (s *Service) loadConfigBundle(ctx context.Context, sysOrigin string) (*configBundle, error) {
sysOrigin = s.normalizeSysOrigin(sysOrigin)
var configRow model.FirstRechargeRewardConfig
err := s.db.WithContext(ctx).
Where("sys_origin = ?", sysOrigin).
First(&configRow).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return &configBundle{}, nil
}
if err != nil {
return nil, err
}
levels, err := s.loadLevels(ctx, configRow.ID)
if err != nil {
return nil, err
}
return &configBundle{
Config: refConfigSnapshot(configSnapshotFromModel(configRow)),
Levels: levels,
}, nil
}
func (s *Service) loadLevels(ctx context.Context, configID int64) ([]levelSnapshot, error) {
if configID <= 0 {
return []levelSnapshot{}, nil
}
var rows []model.FirstRechargeRewardLevel
if err := s.db.WithContext(ctx).
Where("config_id = ?", configID).
Order("recharge_amount_cents asc").
Order("level asc").
Find(&rows).Error; err != nil {
return nil, err
}
levels := make([]levelSnapshot, 0, len(rows))
for _, row := range rows {
levels = append(levels, levelSnapshotFromModel(row))
}
sortLevels(levels)
return levels, nil
}
func hasEnabledLevel(levels []model.FirstRechargeRewardLevel) bool {
for _, level := range levels {
if level.Enabled {
return true
}
}
return false
}
func savedSysOrigin(ctx context.Context, db dbHandle, id int64, fallback string) string {
var row model.FirstRechargeRewardConfig
if id > 0 && db != nil {
if err := db.WithContext(ctx).Where("id = ?", id).First(&row).Error; err == nil {
return row.SysOrigin
}
}
return fallback
}

View File

@ -0,0 +1,456 @@
package firstrechargereward
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
)
func (s *Service) ProcessRechargePayload(ctx context.Context, payload string) (*ProcessRechargeResponse, error) {
payload = strings.TrimSpace(payload)
if payload == "" {
return &ProcessRechargeResponse{Processed: false, Reason: "empty_payload"}, nil
}
event, skip, err := decodeRechargeEventPayload(payload, s.cfg.FirstRechargeReward.MQ.Tag)
if err != nil {
return nil, err
}
if skip {
return &ProcessRechargeResponse{Processed: false, Reason: "tag_skipped"}, nil
}
return s.ProcessRechargeEvent(ctx, event)
}
func (s *Service) ProcessRechargeEvent(ctx context.Context, event RechargeEvent) (*ProcessRechargeResponse, error) {
normalized, err := s.normalizeRechargeEvent(event)
if err != nil {
var appErr *AppError
if errors.As(err, &appErr) && appErr.Status >= http.StatusBadRequest && appErr.Status < http.StatusInternalServerError {
return &ProcessRechargeResponse{Processed: false, Reason: appErr.Code}, nil
}
return nil, err
}
if !isAcceptedPaymentMethod(normalized.PaymentMethod, normalized.PayPlatform) {
return &ProcessRechargeResponse{Processed: false, Reason: "unsupported_payment_method"}, nil
}
if s.java == nil {
return nil, NewAppError(http.StatusServiceUnavailable, "java_gateway_unavailable", "java gateway is unavailable")
}
bundle, err := s.loadConfigBundle(ctx, normalized.SysOrigin)
if err != nil {
return nil, err
}
if bundle.Config == nil {
return &ProcessRechargeResponse{Processed: false, Reason: "config_missing"}, nil
}
if !bundle.Config.Enabled {
return &ProcessRechargeResponse{Processed: false, Reason: "config_disabled"}, nil
}
if !isFirstRechargeRewardAppVersionSupported(normalized.AppVersion, bundle.Config.MinAppVersion) {
return &ProcessRechargeResponse{Processed: false, Reason: "app_version_not_supported"}, nil
}
matched, ok := pickMatchedLevel(bundle.Levels, normalized.AmountCents, normalized.GoogleProductID)
if !ok {
return &ProcessRechargeResponse{Processed: false, Reason: "level_not_matched"}, nil
}
record, terminal, err := s.getOrCreateGrantRecord(ctx, normalized, bundle.Config, matched)
if err != nil {
return nil, err
}
if terminal {
return &ProcessRechargeResponse{
Processed: true,
RecordID: record.ID,
Status: record.Status,
Level: record.Level,
}, nil
}
if err := s.dispatchRewardGroup(ctx, record); err != nil {
_ = s.updateGrantRecordFailed(ctx, record.ID, err)
return nil, err
}
if err := s.updateGrantRecordSuccess(ctx, record.ID); err != nil {
return nil, err
}
record.Status = statusSuccess
now := time.Now()
record.SentAt = &now
if err := s.pushRewardNotice(ctx, record); err != nil {
log.Printf("push first recharge reward notice failed. recordId=%d userId=%d eventId=%s err=%v",
record.ID, record.UserID, record.EventID, err)
}
_ = s.java.RemoveUserProfileCacheAll(ctx, record.UserID)
return &ProcessRechargeResponse{
Processed: true,
RecordID: record.ID,
Status: statusSuccess,
Level: record.Level,
}, nil
}
func (s *Service) normalizeRechargeEvent(event RechargeEvent) (normalizedRechargeEvent, error) {
sysOrigin := s.normalizeSysOrigin(event.SysOrigin)
userID := event.UserID.Int64()
if userID <= 0 {
return normalizedRechargeEvent{}, NewAppError(http.StatusBadRequest, "invalid_user_id", "userId is required")
}
amountCents := event.AmountCents.Int64()
if amountCents <= 0 {
amountCents = firstPositiveAmountCents(event.Amount.String(), event.AmountUSD.String(), event.USDAmount.String())
}
payPlatform := strings.ToUpper(strings.TrimSpace(event.PayPlatform))
paymentMethod := strings.ToUpper(strings.TrimSpace(event.PaymentMethod))
appVersion := normalizeRechargeAppVersion(event)
googleProductID := normalizeRechargeGoogleProductID(event)
if amountCents <= 0 && googleProductID == "" {
return normalizedRechargeEvent{}, NewAppError(http.StatusBadRequest, "invalid_recharge_identity", "amountCents, amount, or googleProductId is required")
}
sourceOrderID := firstNonEmpty(event.SourceOrderID.String(), event.OrderID.String())
eventID := strings.TrimSpace(event.EventID)
if eventID == "" && sourceOrderID != "" {
eventID = "RECHARGE_SUCCESS:" + firstNonEmpty(paymentMethod, payPlatform, "UNKNOWN") + ":" + sourceOrderID
}
if eventID == "" {
return normalizedRechargeEvent{}, NewAppError(http.StatusBadRequest, "invalid_event_id", "eventId is required")
}
occurredAt := parseEventTime(event.OccurredAt.String())
if occurredAt.IsZero() {
occurredAt = time.Now()
}
return normalizedRechargeEvent{
EventID: eventID,
SysOrigin: sysOrigin,
UserID: userID,
AmountCents: amountCents,
Currency: firstNonEmpty(strings.ToUpper(strings.TrimSpace(event.Currency)), "USD"),
PayPlatform: payPlatform,
PaymentMethod: paymentMethod,
AppVersion: appVersion,
GoogleProductID: googleProductID,
SourceOrderID: sourceOrderID,
OccurredAt: occurredAt,
}, nil
}
func (s *Service) getOrCreateGrantRecord(
ctx context.Context,
event normalizedRechargeEvent,
config *configSnapshot,
level levelSnapshot,
) (*model.FirstRechargeRewardGrantRecord, bool, error) {
var existing model.FirstRechargeRewardGrantRecord
err := s.db.WithContext(ctx).Where("event_id = ?", event.EventID).First(&existing).Error
if err == nil {
return &existing, existing.Status == statusSuccess, nil
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, err
}
err = s.db.WithContext(ctx).
Where("sys_origin = ? AND user_id = ?", event.SysOrigin, event.UserID).
First(&existing).Error
if err == nil {
return &existing, existing.Status == statusSuccess, nil
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, err
}
id, err := utils.NextID()
if err != nil {
return nil, false, err
}
now := time.Now()
record := model.FirstRechargeRewardGrantRecord{
ID: id,
EventID: event.EventID,
ConfigID: config.ID,
LevelID: level.ID,
SysOrigin: config.SysOrigin,
UserID: event.UserID,
RechargeAmountCents: event.AmountCents,
RechargeThresholdCents: level.RechargeAmountCents,
Level: level.Level,
PayPlatform: event.PayPlatform,
PaymentMethod: event.PaymentMethod,
GoogleProductID: event.GoogleProductID,
SourceOrderID: event.SourceOrderID,
RewardGroupID: level.RewardGroupID,
RewardGroupName: level.RewardGroupName,
RewardGroupTrackID: id,
Status: statusPending,
RewardGroupStatus: statusPending,
EventTime: event.OccurredAt,
CreateTime: now,
UpdateTime: now,
}
if profile, err := s.java.GetUserProfile(ctx, event.UserID); err == nil {
record.Account = strings.TrimSpace(profile.Account)
record.UserAvatar = strings.TrimSpace(profile.UserAvatar)
record.UserNickname = strings.TrimSpace(profile.UserNickname)
record.CountryCode = strings.TrimSpace(profile.CountryCode)
record.CountryName = strings.TrimSpace(profile.CountryName)
}
if err := s.db.WithContext(ctx).Create(&record).Error; err != nil {
if isDuplicateError(err) {
if reload, ok, reloadErr := s.loadExistingGrantRecord(ctx, event); reloadErr != nil {
return nil, false, reloadErr
} else if ok {
return &reload, reload.Status == statusSuccess, nil
}
}
return nil, false, err
}
return &record, false, nil
}
func (s *Service) loadExistingGrantRecord(ctx context.Context, event normalizedRechargeEvent) (model.FirstRechargeRewardGrantRecord, bool, error) {
var existing model.FirstRechargeRewardGrantRecord
err := s.db.WithContext(ctx).
Where("event_id = ? OR (sys_origin = ? AND user_id = ?)", event.EventID, event.SysOrigin, event.UserID).
First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.FirstRechargeRewardGrantRecord{}, false, nil
}
if err != nil {
return model.FirstRechargeRewardGrantRecord{}, false, err
}
return existing, true, nil
}
func (s *Service) dispatchRewardGroup(ctx context.Context, record *model.FirstRechargeRewardGrantRecord) error {
if record.RewardGroupID <= 0 {
return NewAppError(http.StatusBadRequest, "reward_group_missing", "reward group is missing")
}
trackID := record.RewardGroupTrackID
if trackID <= 0 {
trackID = record.ID
}
return s.java.SendActivityReward(ctx, integration.SendActivityRewardRequest{
TrackID: trackID,
Origin: firstRechargeRewardOrigin,
SysOrigin: record.SysOrigin,
SourceGroupID: record.RewardGroupID,
AcceptUserID: record.UserID,
})
}
func (s *Service) updateGrantRecordSuccess(ctx context.Context, recordID int64) error {
now := time.Now()
return s.db.WithContext(ctx).
Model(&model.FirstRechargeRewardGrantRecord{}).
Where("id = ?", recordID).
Updates(map[string]any{
"status": statusSuccess,
"reward_group_status": statusSuccess,
"last_error": "",
"sent_at": now,
"update_time": now,
}).Error
}
func (s *Service) updateGrantRecordFailed(ctx context.Context, recordID int64, cause error) error {
return s.db.WithContext(ctx).
Model(&model.FirstRechargeRewardGrantRecord{}).
Where("id = ?", recordID).
Updates(map[string]any{
"status": statusFailed,
"reward_group_status": statusFailed,
"retry_count": gorm.Expr("retry_count + 1"),
"last_error": truncateMessage(cause.Error(), 1024),
"update_time": time.Now(),
}).Error
}
func (s *Service) pushRewardNotice(ctx context.Context, record *model.FirstRechargeRewardGrantRecord) error {
expand := map[string]any{
"scene": "FIRST_RECHARGE_REWARD",
"showRewardPopup": true,
"userId": record.UserID,
"sysOrigin": record.SysOrigin,
"eventId": record.EventID,
"recordId": fmt.Sprintf("%d", record.ID),
"level": record.Level,
"rechargeAmount": formatAmountCents(record.RechargeAmountCents),
"rechargeAmountCents": record.RechargeAmountCents,
"rewardGroupId": fmt.Sprintf("%d", record.RewardGroupID),
"rewardGroupName": record.RewardGroupName,
"payPlatform": record.PayPlatform,
"paymentMethod": record.PaymentMethod,
"googleProductId": record.GoogleProductID,
"noticeType": firstRechargeRewardNoticeType,
}
return s.java.SendOfficialNoticeCustomize(ctx, integration.OfficialNoticeCustomizeRequest{
ToAccounts: []int64{record.UserID},
NoticeType: firstRechargeRewardNoticeType,
Title: firstRechargeRewardNoticeTitle,
Content: firstRechargeRewardNoticeContent,
Expand: expand,
})
}
func decodeRechargeEventPayload(payload string, expectedTag string) (RechargeEvent, bool, error) {
var event RechargeEvent
if err := json.Unmarshal([]byte(payload), &event); err != nil {
return event, false, err
}
if strings.TrimSpace(event.EventID) != "" || event.UserID.Int64() > 0 {
return event, false, nil
}
var envelope rechargeMessageEnvelope
if err := json.Unmarshal([]byte(payload), &envelope); err != nil {
return RechargeEvent{}, false, err
}
if shouldSkipEnvelope(envelope.Tag, expectedTag) {
return RechargeEvent{}, true, nil
}
bodyPayload, ok, err := envelope.bodyPayload()
if err != nil || !ok {
return RechargeEvent{}, false, err
}
if err := json.Unmarshal([]byte(bodyPayload), &event); err != nil {
return RechargeEvent{}, false, err
}
return event, false, nil
}
type rechargeMessageEnvelope struct {
Tag string `json:"tag"`
Body json.RawMessage `json:"body"`
}
func (e rechargeMessageEnvelope) bodyPayload() (string, bool, error) {
raw := strings.TrimSpace(string(e.Body))
if raw == "" || raw == "null" {
return "", false, nil
}
if strings.HasPrefix(raw, "\"") {
var body string
if err := json.Unmarshal(e.Body, &body); err != nil {
return "", false, err
}
body = strings.TrimSpace(body)
return body, body != "", nil
}
return raw, true, nil
}
func shouldSkipEnvelope(actualTag, expectedTag string) bool {
actualTag = strings.TrimSpace(actualTag)
expectedTag = strings.TrimSpace(expectedTag)
if actualTag == "" || expectedTag == "" || expectedTag == "*" {
return false
}
return actualTag != expectedTag
}
func isAcceptedPaymentMethod(paymentMethod string, _ string) bool {
paymentMethod = strings.ToUpper(strings.TrimSpace(paymentMethod))
return paymentMethod == paymentMethodGoogle || paymentMethod == paymentMethodThirdParty
}
func normalizeRechargeAppVersion(event RechargeEvent) string {
return firstNonEmpty(
event.AppVersion.String(),
event.ClientVersion.String(),
event.Version.String(),
event.ReqVersion.String(),
rechargePayloadString(event.Payload, "appVersion", "clientVersion", "version", "reqVersion"),
)
}
func normalizeRechargeGoogleProductID(event RechargeEvent) string {
return normalizeGoogleProductID(firstNonEmpty(
event.GoogleProductID,
event.ProductID.String(),
rechargePayloadString(event.Payload, "googleProductId", "productId", "product_id"),
))
}
func normalizeGoogleProductID(value string) string {
return strings.TrimSpace(value)
}
func rechargePayloadString(payload json.RawMessage, keys ...string) string {
if len(payload) == 0 {
return ""
}
var values map[string]any
if err := json.Unmarshal(payload, &values); err != nil {
return ""
}
for _, key := range keys {
if value, ok := values[key]; ok {
if text := strings.TrimSpace(fmt.Sprint(value)); text != "" && text != "<nil>" {
return text
}
}
}
return ""
}
func firstPositiveAmountCents(values ...string) int64 {
for _, value := range values {
cents, err := parseAmountCents(value)
if err == nil && cents > 0 {
return cents
}
}
return 0
}
func parseEventTime(raw string) time.Time {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}
}
if t, err := time.Parse(time.RFC3339Nano, raw); err == nil {
return t
}
if t, err := time.Parse("2006-01-02 15:04:05", raw); err == nil {
return t
}
return time.Time{}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func isDuplicateError(err error) bool {
if err == nil {
return false
}
text := strings.ToLower(err.Error())
return strings.Contains(text, "duplicate") || strings.Contains(text, "unique constraint")
}
func truncateMessage(message string, limit int) string {
message = strings.TrimSpace(message)
if limit <= 0 || len(message) <= limit {
return message
}
return message[:limit]
}

View File

@ -0,0 +1,112 @@
package firstrechargereward
import (
"context"
"errors"
"net/http"
"chatapp3-golang/internal/model"
"gorm.io/gorm"
)
const (
activityStatusNotConfigured = "NOT_CONFIGURED"
activityStatusDisabled = "DISABLED"
activityStatusOngoing = "ONGOING"
activityStatusPending = "PENDING"
activityStatusRewarded = "REWARDED"
activityStatusFailed = "FAILED"
)
// GetHome returns app-facing first recharge reward config and the user's grant state.
func (s *Service) GetHome(ctx context.Context, user AuthUser, appVersion string) (*HomeResponse, error) {
sysOrigin, err := requireUser(user)
if err != nil {
return nil, err
}
if s.java == nil {
return nil, NewAppError(http.StatusServiceUnavailable, "java_gateway_unavailable", "java gateway is unavailable")
}
bundle, err := s.loadConfigBundle(ctx, sysOrigin)
if err != nil {
return nil, err
}
if bundle.Config == nil {
return &HomeResponse{
Configured: false,
Enabled: false,
ActivityStatus: activityStatusNotConfigured,
UserID: user.UserID,
SysOrigin: sysOrigin,
LevelConfigs: []LevelPayload{},
HasRewardRecord: false,
}, nil
}
rewardItems := s.loadRewardItemsMap(ctx, bundle.Levels)
resp := &HomeResponse{
Configured: true,
Enabled: bundle.Config.Enabled,
ActivityStatus: activityStatusOngoing,
UserID: user.UserID,
SysOrigin: bundle.Config.SysOrigin,
MinAppVersion: bundle.Config.MinAppVersion,
LevelConfigs: buildLevelPayloads(bundle.Levels, rewardItems, 0, 0, false),
HasRewardRecord: false,
}
if !bundle.Config.Enabled {
resp.ActivityStatus = activityStatusDisabled
}
if bundle.Config.Enabled && !isFirstRechargeRewardAppVersionSupported(appVersion, bundle.Config.MinAppVersion) {
resp.Enabled = false
resp.ActivityStatus = activityStatusDisabled
}
record, ok, err := s.loadUserGrantRecord(ctx, bundle.Config.SysOrigin, user.UserID)
if err != nil {
return nil, err
}
if !ok {
return resp, nil
}
resp.HasRewardRecord = true
resp.RewardStatus = record.Status
resp.Rewarded = record.Status == statusSuccess
resp.MatchedLevel = record.Level
resp.RechargeAmount = formatAmountCents(record.RechargeAmountCents)
resp.RechargeAmountCents = record.RechargeAmountCents
resp.RewardGroupID = record.RewardGroupID
resp.RewardGroupName = record.RewardGroupName
resp.RewardItems = rewardItems[record.RewardGroupID]
resp.LevelConfigs = buildLevelPayloads(bundle.Levels, rewardItems, record.RechargeAmountCents, record.Level, false)
switch record.Status {
case statusSuccess:
resp.ActivityStatus = activityStatusRewarded
case statusPending:
resp.ActivityStatus = activityStatusPending
case statusFailed:
resp.ActivityStatus = activityStatusFailed
}
return resp, nil
}
func (s *Service) loadUserGrantRecord(ctx context.Context, sysOrigin string, userID int64) (model.FirstRechargeRewardGrantRecord, bool, error) {
if userID <= 0 {
return model.FirstRechargeRewardGrantRecord{}, false, nil
}
var record model.FirstRechargeRewardGrantRecord
err := s.db.WithContext(ctx).
Where("sys_origin = ? AND user_id = ?", s.normalizeSysOrigin(sysOrigin), userID).
First(&record).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.FirstRechargeRewardGrantRecord{}, false, nil
}
if err != nil {
return model.FirstRechargeRewardGrantRecord{}, false, err
}
return record, true, nil
}

View File

@ -0,0 +1,139 @@
package firstrechargereward
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/common"
rmq "github.com/apache/rocketmq-clients/golang/v5"
"github.com/apache/rocketmq-clients/golang/v5/credentials"
)
// StartMessageConsumer starts the first recharge reward RocketMQ consumer.
func (s *Service) StartMessageConsumer(ctx context.Context) error {
if !s.cfg.FirstRechargeReward.MQ.Enabled {
log.Printf("first recharge reward rocketmq consumer disabled")
return nil
}
if strings.TrimSpace(s.cfg.FirstRechargeReward.MQ.Endpoint) == "" ||
strings.TrimSpace(s.cfg.FirstRechargeReward.MQ.AccessKey) == "" ||
strings.TrimSpace(s.cfg.FirstRechargeReward.MQ.AccessSecret) == "" ||
strings.TrimSpace(s.cfg.FirstRechargeReward.MQ.Topic) == "" ||
strings.TrimSpace(s.cfg.FirstRechargeReward.MQ.ConsumerGroup) == "" {
log.Printf("first recharge reward rocketmq consumer skipped: endpoint, credentials, topic or consumer group missing")
return nil
}
go s.startRocketMQConsumerWithRetry(ctx)
return nil
}
func (s *Service) startRocketMQConsumerWithRetry(ctx context.Context) {
retryDelay := 5 * time.Second
for {
if ctx.Err() != nil {
return
}
if err := s.startRocketMQConsumer(ctx); err != nil {
log.Printf("start first recharge reward rocketmq consumer failed: %v; retry in %s", err, retryDelay)
timer := time.NewTimer(retryDelay)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
if retryDelay < time.Minute {
retryDelay *= 2
if retryDelay > time.Minute {
retryDelay = time.Minute
}
}
continue
}
return
}
}
func (s *Service) startRocketMQConsumer(ctx context.Context) error {
filter := rmq.SUB_ALL
if tag := strings.TrimSpace(s.cfg.FirstRechargeReward.MQ.Tag); tag != "" && tag != "*" {
filter = rmq.NewFilterExpression(tag)
}
consumer, err := rmq.NewPushConsumer(&rmq.Config{
Endpoint: s.cfg.FirstRechargeReward.MQ.Endpoint,
NameSpace: s.cfg.FirstRechargeReward.MQ.Namespace,
ConsumerGroup: s.cfg.FirstRechargeReward.MQ.ConsumerGroup,
Credentials: &credentials.SessionCredentials{
AccessKey: s.cfg.FirstRechargeReward.MQ.AccessKey,
AccessSecret: s.cfg.FirstRechargeReward.MQ.AccessSecret,
SecurityToken: s.cfg.FirstRechargeReward.MQ.SecurityToken,
},
},
rmq.WithPushSubscriptionExpressions(map[string]*rmq.FilterExpression{
s.cfg.FirstRechargeReward.MQ.Topic: filter,
}),
rmq.WithPushConsumptionThreadCount(4),
rmq.WithPushMaxCacheMessageCount(256),
rmq.WithPushMessageListener(&rmq.FuncMessageListener{
Consume: func(messageView *rmq.MessageView) rmq.ConsumerResult {
if messageView == nil {
return rmq.SUCCESS
}
if err := s.processRocketMQMessage(context.Background(), messageView); err != nil {
log.Printf("first recharge reward mq process failed. messageId=%s err=%v", messageView.GetMessageId(), err)
return rmq.FAILURE
}
return rmq.SUCCESS
},
}),
)
if err != nil {
return err
}
if err := consumer.Start(); err != nil {
return err
}
s.rocketConsumer = consumer
log.Printf("first recharge reward rocketmq consumer started. topic=%s group=%s tag=%s",
s.cfg.FirstRechargeReward.MQ.Topic,
s.cfg.FirstRechargeReward.MQ.ConsumerGroup,
s.cfg.FirstRechargeReward.MQ.Tag,
)
go func() {
<-ctx.Done()
if s.rocketConsumer != nil {
_ = s.rocketConsumer.GracefulStop()
}
}()
return nil
}
func (s *Service) processRocketMQMessage(ctx context.Context, messageView *rmq.MessageView) error {
payload := strings.TrimSpace(string(messageView.GetBody()))
if payload == "" {
return nil
}
if _, err := s.ProcessRechargePayload(ctx, payload); err != nil {
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
if errors.As(err, &syntaxErr) || errors.As(err, &typeErr) {
log.Printf("drop malformed first recharge reward message. messageId=%s err=%v", messageView.GetMessageId(), err)
return nil
}
var appErr *common.AppError
if errors.As(err, &appErr) && appErr.Status >= http.StatusBadRequest && appErr.Status < http.StatusInternalServerError {
log.Printf("drop invalid first recharge reward message. messageId=%s code=%s err=%v", messageView.GetMessageId(), appErr.Code, err)
return nil
}
return err
}
return nil
}

View File

@ -0,0 +1,180 @@
package firstrechargereward
import (
"context"
"sort"
"strings"
"time"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
)
func configSnapshotFromModel(row model.FirstRechargeRewardConfig) configSnapshot {
return configSnapshot{
ID: row.ID,
SysOrigin: strings.ToUpper(strings.TrimSpace(row.SysOrigin)),
Enabled: row.Enabled,
MinAppVersion: strings.TrimSpace(row.MinAppVersion),
UpdateTime: row.UpdateTime,
}
}
func levelSnapshotFromModel(row model.FirstRechargeRewardLevel) levelSnapshot {
return levelSnapshot{
ID: row.ID,
Level: row.Level,
RechargeAmountCents: row.RechargeAmountCents,
GoogleProductID: normalizeGoogleProductID(row.GoogleProductID),
RewardGroupID: row.RewardGroupID,
RewardGroupName: strings.TrimSpace(row.RewardGroupName),
Enabled: row.Enabled,
}
}
func refConfigSnapshot(value configSnapshot) *configSnapshot {
return &value
}
func buildLevelPayloads(
levels []levelSnapshot,
rewardItems map[int64][]RewardItem,
currentRechargeCents int64,
matchedLevel int,
admin bool,
) []LevelPayload {
payloads := make([]LevelPayload, 0, len(levels))
for _, level := range levels {
if !admin && !level.Enabled {
continue
}
payloads = append(payloads, LevelPayload{
ID: level.ID,
Level: level.Level,
RechargeAmount: formatAmountCents(level.RechargeAmountCents),
RechargeAmountCents: level.RechargeAmountCents,
GoogleProductID: level.GoogleProductID,
RewardGroupID: level.RewardGroupID,
RewardGroupName: level.RewardGroupName,
RewardItems: rewardItems[level.RewardGroupID],
Enabled: level.Enabled,
Reached: currentRechargeCents >= level.RechargeAmountCents,
Current: matchedLevel > 0 && matchedLevel == level.Level,
})
}
return payloads
}
func pickMatchedLevel(levels []levelSnapshot, rechargeAmountCents int64, googleProductID string) (levelSnapshot, bool) {
googleProductID = normalizeGoogleProductID(googleProductID)
if googleProductID != "" {
for _, level := range levels {
if !level.Enabled || level.RewardGroupID <= 0 || level.GoogleProductID == "" {
continue
}
if level.GoogleProductID == googleProductID {
return level, true
}
}
}
for _, level := range levels {
if !level.Enabled || level.RechargeAmountCents <= 0 || level.RewardGroupID <= 0 {
continue
}
if rechargeAmountCents == level.RechargeAmountCents {
return level, true
}
}
return levelSnapshot{}, false
}
func sortLevels(levels []levelSnapshot) {
sort.Slice(levels, func(i, j int) bool {
if levels[i].RechargeAmountCents == levels[j].RechargeAmountCents {
return levels[i].Level < levels[j].Level
}
return levels[i].RechargeAmountCents < levels[j].RechargeAmountCents
})
}
func (s *Service) loadRewardItemsMap(ctx context.Context, levels []levelSnapshot) map[int64][]RewardItem {
if s.java == nil {
return map[int64][]RewardItem{}
}
groupIDs := make([]int64, 0)
seen := make(map[int64]struct{})
for _, level := range levels {
if level.RewardGroupID <= 0 {
continue
}
if _, exists := seen[level.RewardGroupID]; exists {
continue
}
seen[level.RewardGroupID] = struct{}{}
groupIDs = append(groupIDs, level.RewardGroupID)
}
sort.Slice(groupIDs, func(i, j int) bool { return groupIDs[i] < groupIDs[j] })
result := make(map[int64][]RewardItem, len(groupIDs))
for _, groupID := range groupIDs {
detail, err := s.java.GetRewardGroupDetail(ctx, groupID)
if err != nil {
continue
}
result[groupID] = rewardItemsFromGroup(detail)
}
return result
}
func rewardItemsFromGroup(detail integration.RewardGroupDetail) []RewardItem {
items := make([]RewardItem, 0, len(detail.RewardConfigList))
for _, item := range detail.RewardConfigList {
items = append(items, RewardItem{
ID: int64(item.ID),
Type: rewardItemDisplayType(item),
Name: strings.TrimSpace(item.Name),
Content: strings.TrimSpace(item.Content),
Quantity: int64(item.Quantity),
Cover: firstNonEmpty(item.Cover, item.SourceURL),
Remark: strings.TrimSpace(item.Remark),
})
}
return items
}
func rewardItemDisplayType(item integration.RewardGroupItem) string {
itemType := strings.ToUpper(strings.TrimSpace(item.Type))
detailType := strings.ToUpper(strings.TrimSpace(item.DetailType))
if itemType == "PROPS" {
if detailType != "" && detailType != "PROPS" {
return detailType
}
if isVipRewardItem(item) {
return "NOBLE_VIP"
}
}
return itemType
}
func isVipRewardItem(item integration.RewardGroupItem) bool {
name := strings.ToUpper(strings.TrimSpace(item.Name))
remark := strings.ToUpper(strings.TrimSpace(item.Remark))
return strings.Contains(name, "VIP") ||
strings.Contains(name, "NOBLE") ||
strings.Contains(remark, "VIP") ||
strings.Contains(remark, "NOBLE")
}
func formatDateTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("2006-01-02 15:04:05")
}
func formatPtrDateTime(t *time.Time) string {
if t == nil {
return ""
}
return formatDateTime(*t)
}

Some files were not shown because too many files have changed in this diff Show More