Compare commits

..

No commits in common. "main" and "release/redis-memory-fix-20260521" have entirely different histories.

129 changed files with 322 additions and 19078 deletions

View File

@ -15,14 +15,10 @@ import (
"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/gameking"
"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"
@ -42,7 +38,6 @@ import (
"chatapp3-golang/internal/service/voiceroomrocket"
"chatapp3-golang/internal/service/weekstar"
"chatapp3-golang/internal/service/wheel"
"chatapp3-golang/internal/service/yumigiftchallenge"
)
func main() {
@ -60,30 +55,23 @@ func main() {
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)
gameKingService := gameking.NewService(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)
yumiGiftChallengeService := yumigiftchallenge.NewService(app.Config, app.Repository.DB, &app.Gateways)
roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways)
hostCenterService := hostcenter.NewService(app.Config, &app.Gateways)
managerCenterService := managercenter.NewService(app.Repository.DB, &app.Gateways)
@ -92,8 +80,6 @@ func main() {
baishunService.SetTaskEventReporter(taskCenterService)
gameOpenService.SetTaskEventReporter(taskCenterService)
weekStarService.SetTaskEventReporter(taskCenterService)
// GAME_CONSUME_GOLD 在任务中心完成统一校验后扇出HTTP 与 MQ 重投由活动账本幂等。
taskCenterService.SetGameConsumeSink(gameKingService)
workerCtx, workerCancel := context.WithCancel(context.Background())
defer workerCancel()
@ -103,18 +89,9 @@ func main() {
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start week star message consumer failed: %v", err)
}
if err := yumiGiftChallengeService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start yumi gift challenge 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)
}
@ -126,26 +103,20 @@ func main() {
gameProviders := gameprovider.NewRegistry(
baishun.NewAppProvider(baishunService),
lingxian.NewAppProvider(lingxianService),
hotgame.NewAppProvider(hotgameService),
)
gameVisibility := gameprovider.NewVisibilityPolicy(&app.Gateways)
engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{
AppPopup: appPopupService,
ErrorLog: errorLogService,
Invite: inviteService,
Baishun: baishunService,
BinanceRecharge: binanceRechargeService,
FirstRechargeReward: firstRechargeRewardService,
Lingxian: lingxianService,
GameOpen: gameOpenService,
GameKing: gameKingService,
GameProviders: gameProviders,
GameVisibility: gameVisibility,
LuckyGift: luckyGiftService,
ManagerCenter: managerCenterService,
PropsStore: propsStoreService,
HostCenter: hostCenterService,
Hotgame: hotgameService,
RechargeAgency: rechargeAgencyService,
RechargeReward: rechargeRewardService,
RegionIMGroup: regionIMGroupService,
@ -160,7 +131,6 @@ func main() {
VoiceRoomRedPacket: voiceRoomRedPacketService,
VoiceRoomRocket: voiceRoomRocketService,
WeekStar: weekStarService,
YumiGiftChallenge: yumiGiftChallengeService,
})
server := &http.Server{
Addr: app.Config.HTTP.ListenAddr,

View File

@ -2,16 +2,12 @@ package main
import (
"chatapp3-golang/internal/bootstrap"
"chatapp3-golang/internal/service/cprelationbroadcast"
"chatapp3-golang/internal/service/firstrechargereward"
"chatapp3-golang/internal/service/gameking"
"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"
"chatapp3-golang/internal/service/yumigiftchallenge"
"context"
"log"
"os/signal"
@ -30,14 +26,8 @@ func main() {
registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
yumiGiftChallengeService := yumigiftchallenge.NewService(app.Config, app.Repository.DB, &app.Gateways)
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
gameKingService := gameking.NewService(app.Repository.DB, &app.Gateways)
// consumer 是生产 MQ 的主要入口,必须与 API 直报共用同一个活动幂等账本。
taskCenterService.SetGameConsumeSink(gameKingService)
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)
@ -52,18 +42,9 @@ func main() {
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start week star message consumer failed: %v", err)
}
if err := yumiGiftChallengeService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start yumi gift challenge 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)
}

View File

@ -1,303 +0,0 @@
# Yumi 游戏王活动接口
本文对应 `chatapp3-golang` 的 Yumi Game King 实现。业务只接受 `LIKEI` 租户的权威游戏金币消耗事件,不提供事件白名单输入框,也不会统计通用钱包支出。
## 1. 通用约定
- H5 外部网关地址:`/go/app/h5/game-king/yumi/*`Go 服务内路由不带 `/go`
- 后台外部网关地址:`/go/resident-activity/game-king/*`Go 服务内路由不带 `/go`
- H5 和后台均传 `Authorization`。H5 支持 `Bearer <token>`;后台沿用 Console 完整 Authorization。
- 内部任务传 `X-Internal-Token: <HTTP_INTERNAL_CALLBACK_SECRET>`
- 时间字段均为毫秒时间戳;`timeZone` 使用 IANA 时区,默认 `Asia/Riyadh`
- 所有业务 ID 在 JSON 响应中均为字符串,避免 JavaScript 丢失 Snowflake 精度。请求中的 ID 可传字符串或数字,建议统一传字符串。
- 成功响应的数据同时位于 `data``body`
```json
{
"status": true,
"errorCode": 0,
"errorMsg": "success",
"code": "success",
"message": "success",
"data": {},
"body": {},
"time": 1784300000000
}
```
- 错误时 HTTP 状态码与 `errorCode` 一致,稳定业务码位于 `code` / `errorCodeName`
## 2. H5 接口
### 2.1 活动详情
`GET /go/app/h5/game-king/yumi/detail?activityId={activityId}`
`activityId` 可不传;不传时按登录用户 `sysOrigin=LIKEI` 解析当前可见活动。返回活动、固定七个奖项、六个排名奖励档、服务器时间和活动状态。
关键返回字段:
```json
{
"activity": {
"id": "190000000000000001",
"activityCode": "YUMI_GAME_KING_S1",
"sysOrigin": "LIKEI",
"startTime": 1784300000000,
"endTime": 1784900000000,
"coinPerDraw": 1000,
"eventTypes": ["GAME_CONSUME_GOLD"],
"rankingType": "TYCOON",
"rankingPeriod": "OVERALL",
"settlementStatus": "NOT_STARTED"
},
"prizes": [],
"rankRewards": [],
"supportedRankingTypes": ["TYCOON"],
"supportedRankingPeriods": ["DAILY", "OVERALL"],
"activityStatus": "UPCOMING",
"serverTime": 1784299900000
}
```
`activityStatus``UPCOMING``ACTIVE``ENDED``DISABLED`
### 2.2 我的进度
`GET /go/app/h5/game-king/yumi/me?activityId={activityId}`
返回:
- `totalConsumed`:活动时间窗内累计游戏消耗金币。
- `earnedChances``floor(totalConsumed / coinPerDraw)`
- `usedChances` / `availableChances`:已用和可用抽奖次数。
- `nextChanceRemaining`:距离下一次机会仍需消耗的金币。
- `rank`:总榜名次;无消费时为 `null`
### 2.3 抽奖
`POST /go/app/h5/game-king/yumi/draw`
```json
{
"activityId": "190000000000000001",
"requestId": "9af73ee9-65ca-4cf5-b23c-f4c222c3ee6e"
}
```
- `activityId``requestId` 均必填;`requestId` 最长 64 字符。客户端一次点击生成一个稳定值;超时重试必须复用原值。
- 幂等范围是 `activityId + userId + requestId`。已经提交的结果即使活动随后结束,也会原样返回,且不会再次扣次数或发奖。
- 权重仅表示相对概率;库存 `-1` 为无限,`0` 为耗尽,正数为剩余库存。有限库存使用条件更新防止超发。
- 返回 `record``prize``remainingChances``idempotentReplay`
### 2.4 排行榜
`GET /go/app/h5/game-king/yumi/ranking?activityId={activityId}&rankingType=TYCOON&period=OVERALL&limit=30`
- `rankingType`:正式口径仅 `TYCOON`。旧 H5 请求 `VICTORIOUS` 时返回空榜,避免兼容页面报错。
- `period``DAILY``OVERALL`;配置页固定保存 `OVERALL`H5 仍可切日榜。
- `limit` 默认 30最大 100。
- 排序固定为:消费金币降序、达到当前消费值的事件时间升序、用户 ID 升序。
- `DAILY` 按活动时区自然日统计;活动结束结算只使用 `OVERALL` Top30。
- 用户资料通过 Java 批量接口补齐;资料服务异常时榜单仍返回 `userId` 和分数。
### 2.5 我的抽奖记录
`GET /go/app/h5/game-king/yumi/draw-records?activityId={activityId}&limit=20`
`limit` 最大 100。返回奖品快照及 `PENDING / PROCESSING / SUCCESS / FAILED / UNKNOWN` 发放状态。
## 3. 后台管理接口
所有接口要求菜单别名 `ResidentGameKingActivity`。写接口还要求对应按钮权限:
| 权限别名 | 接口用途 |
| --- | --- |
| `resident-activity:yumi-game-king:edit` | 保存活动、奖项、排名奖励 |
| `resident-activity:yumi-game-king:enable` | 启停活动 |
| `resident-activity:yumi-game-king:settle` | 人工触发到期结算 |
| `resident-activity:yumi-game-king:reconcile` | FAILED 重试、UNKNOWN 核账 |
| `resident-activity:yumi-game-king:backfill` | 权威事件补数 |
### 3.1 活动配置
| 方法 | 地址 | 说明 |
| --- | --- | --- |
| GET | `/go/resident-activity/game-king/list` | 活动列表 |
| GET | `/go/resident-activity/game-king/detail/{id}` | 完整详情 |
| POST | `/go/resident-activity/game-king/save` | 新增或修改 |
| PUT | `/go/resident-activity/game-king/enable/{id}?enabled=true` | 启停;布尔值只接受 `true/false/1/0` |
| GET | `/go/resident-activity/game-king/prizes/{id}` | 七个奖项 |
| PUT | `/go/resident-activity/game-king/prizes/{id}` | 保存七个奖项 |
| GET | `/go/resident-activity/game-king/rank-rewards/{id}` | 六个排名奖励档 |
| PUT | `/go/resident-activity/game-king/rank-rewards/{id}` | 保存六个排名奖励档 |
`POST /save` 示例:
```json
{
"id": "0",
"activityCode": "YUMI_GAME_KING_S1",
"activityName": "Yumi Game King",
"activityDesc": "",
"sysOrigin": "LIKEI",
"timeZone": "Asia/Riyadh",
"startTime": 1784300000000,
"endTime": 1784900000000,
"settlementDelayMinutes": 30,
"coinPerDraw": 1000,
"rankingType": "TYCOON",
"rankingPeriod": "OVERALL",
"enabled": false,
"prizes": [
{
"id": "0",
"prizeName": "Prize 1",
"prizeImage": "https://example.com/prize-1.png",
"weight": 100,
"stock": -1,
"resourceGroupId": "190000000000000101",
"enabled": true,
"sortOrder": 1
}
],
"rankRewards": [
{
"id": "0",
"startRank": 1,
"endRank": 1,
"resourceGroupId": "190000000000000201",
"rewardName": "Top 1"
}
]
}
```
示例数组为节省篇幅只展示一项,真实启用配置必须满足:
- 恰好 7 个奖项,全部启用,`sortOrder` 完整覆盖 1..7 且不重复;权重必须大于 0。
- 恰好 6 个排名档:`1``2``3``4-7``8-10``11-30`
- `sysOrigin` 固定 `LIKEI``rankingType` 固定 `TYCOON`;配置 `rankingPeriod` 固定 `OVERALL`
- 首次启用时 `startTime` 必须严格晚于当前时间;同租户启用活动时间窗不能重叠。
- 计数窗口严格为 `[startTime, endTime)`。活动开始后锁定时间、口径、奖项、排名奖励和启停状态,只允许修改名称、说明等展示信息。
- `settlementDelayMinutes` 默认 30可配置 0..1440。
- 每次保存/启用及每次真实发奖前都会重新校验奖励资源组:必须存在、已上架、明确属于 `LIKEI` 且非空,并比对冻结的完整奖励项快照。
单独保存奖项请求:
```json
{
"activityId": "190000000000000001",
"prizes": []
}
```
单独保存排名奖励请求:
```json
{
"activityId": "190000000000000001",
"rankRewards": []
}
```
路径 ID 与 body `activityId` 必须一致。
### 3.2 抽奖发放管理
| 方法 | 地址 | 说明 |
| --- | --- | --- |
| GET | `/go/resident-activity/game-king/draw-records/{id}?deliveryStatus=&limit=100` | 查看抽奖和发放明细 |
| POST | `/go/resident-activity/game-king/draw/retry/{recordId}` | 只重试 `FAILED` |
| POST | `/go/resident-activity/game-king/delivery-item/resolve/{itemId}?delivered=true` | 人工核销 `UNKNOWN` |
`delivered` 只接受 `true/false/1/0`
- `true`:已通过 `trackId/businessNo` 确认下游到账,只记为成功,不再发送。
- `false`:已确认下游未到账,先转为 `FAILED`,再执行一次显式重试。
- 非法或缺失值返回 400绝不默认为 `false`
### 3.3 排名结算
| 方法 | 地址 | 说明 |
| --- | --- | --- |
| GET | `/go/resident-activity/game-king/settlement-records/{id}` | 查看冻结名次与发放状态 |
| POST | `/go/resident-activity/game-king/settlement/{id}` | 到结算时间后人工冻结并派发一小批 |
| POST | `/go/resident-activity/game-king/settlement/retry/{recordId}` | 只重试 `FAILED` |
结算只冻结一次 `OVERALL` Top30。记录和发奖任务在同一事务生成人工接口每次最多派发该活动 4 个 `PENDING`,其余由内部定时任务继续。单项硬超时 20 秒,因此最坏外部调用约 80 秒,并为数据库处理留出余量。
### 3.4 事件补数
`POST /go/resident-activity/game-king/backfill/{id}?lastId=0&limit=200`
- 只读取 `task_center_event_archive_outbox``LIKEI + GAME_CONSUME_GOLD + 活动时间窗` 数据。
- 在任何归档扫描前都会检查迁移 058 的四列在线索引;索引未完成、不可见或列序不符时返回 `503 backfill_index_required`,不会退化为全表扫描。
- 仅允许对已启用且结算尚未冻结的活动补数;活动结束后的结算等待期仍可补,进入结算后返回 409游标不会静默推进。
- 调用方只能传不透明游标 `lastId``limit`,不能传 eventType 或钱包口径;`limit` 最大 500。
- 返回 `eventType``scanned``accepted``nextLastId``finished`。未完成时用返回的 `nextLastId` 继续。
- 若结算恰好在一页补数中途冻结,接口返回 409 且不返回新的游标;调用方保留原 `lastId`,已成功写入的前序事件会由活动账本幂等跳过。
- 活动账本以 `activityId + eventId` 幂等,实时事件和补数重复不会重复累计。
## 4. 游戏消耗事件接入
唯一统计事件是任务中心的 `GAME_CONSUME_GOLD`。事件经过任务中心统一校验后、在任务中心自身业务去重之前同步扇出给 Game KingGame King 使用独立账本保证幂等。
已接入事件 ID 口径:
- Java 游戏:`GAME_CONSUME:<provider>:<orderId>`,已覆盖 `YOMI``LINGXIAN``HOTGAME``BAISHUN_LEGACY``LUDO``TURNTABLE`
- Go GameOpen`GAME_OPEN_CONSUME:<orderId>`
- Go HotGame`GAME_HOTGAME_CONSUME:<orderId>`
- Go Baishun`BAISHUN_GAME_CONSUME:<sysOrigin>:<orderId>`
RocketMQ 默认配置:
- topic`RC_DEFAULT_APP_ORDINARY`
- tag`task_center_event`
- consumer group`task-center-progress`
- 开关:`CHATAPP_TASK_CENTER_MQ_ENABLED`(兼容 `TASK_CENTER_EVENT_MQ_ENABLED`
活动累计使用事件的 `occurredAt` 判断 `[startTime,endTime)`,因此活动开始前的历史游戏消耗不会计入。只接受正数 `deltaValue`;退款、胜利收入和非游戏钱包消耗不计入。
## 5. 发奖和异常状态
发奖以“奖励资源组”为一个下游调用单元,使用 Java `SendActivityReward``origin=GAME_KING_AWARD``trackId=deliveryItem.id`。后台明细同时返回稳定 `trackId``businessNo`、用户 ID、资源组 ID 和冻结奖励项。
- `PENDING`:本地事务已提交,尚未领取。
- `PROCESSING`:已领取,正在校验/调用下游。
- `SUCCESS`Java 明确成功。
- `FAILED`:在发奖请求发出前就已明确失败,例如资源组不存在、下架、跨租户或与冻结快照不一致;只允许运营显式重试。
- `UNKNOWN`:发奖请求已尝试但结果不能证明,或 `PROCESSING` 超过 10 分钟;禁止自动重试,必须先核账再 resolve。
一旦活动启用,奖励组内的实际奖励项内容必须保持与冻结快照一致。若运营修改了组内容,发放会在调用 Java 前进入 `FAILED`;应先恢复原快照或完成明确处置,再重试。
## 6. 内部结算任务
`POST /internal/game-king/settle-due`
Header`X-Internal-Token`
本 Go 服务不启动 ticker。由 `chatapp-cron` 周期调用:
- 每次最多修复 20 个过期 `PROCESSING`,并扫描、冻结 10 个到期活动。
- 每次最多派发全局 4 个 `PENDING`;单项硬超时 20 秒,调用方超时应配置为至少 120 秒。
- 只自动恢复 `PENDING``FAILED` / `UNKNOWN` 永不自动重试;超过 10 分钟的 `PROCESSING` 只转为 `UNKNOWN`
响应:
```json
{
"scanned": 2,
"settled": 2,
"failedIds": []
}
```
## 7. 数据库迁移与性能
迁移文件:`migrations/058_yumi_game_king.sql`
- 新建活动、七奖项、六档奖励、幂等消费账本、总榜/日榜聚合、抽奖、结算和发放表。
- 排行读取物化聚合并使用复合排序索引,不对消费流水做运行时 `GROUP BY`
- 补数给 `task_center_event_archive_outbox` 增加 `(sys_origin,event_type,occurred_at,id)` 复合索引,以范围 + keyset 扫描,避免全表排序。
- 迁移不执行历史 `UPDATE/DELETE/backfill`。新增复合索引仍属于线上 DDL执行前应按目标 MySQL 版本、表数据量和 DDL 策略确认锁影响,并在低峰发布。

View File

@ -1,60 +0,0 @@
# Yumi 礼物挑战接口
Yumi 的 Java `sysOrigin` 固定为 `LIKEI`。网关外部地址在以下路径前加 `/go`Go 服务内部路径不加。所有 Snowflake ID 都使用十进制字符串,积分/任务金额使用精确两位小数字符串(如 `"100.50"`)。统一响应业务数据位于 `body`/`data`
## H5 用户接口
鉴权:`Authorization: Bearer <app token>`
| 方法 | 地址 | 参数 | 主要返回 |
|---|---|---|---|
| GET | `/app/h5/gift-challenge/yumi/detail` | query `activityId` 可选 | `activity`、固定三任务 `tasks`、日/总榜奖励 `rankRewards` |
| POST | `/app/h5/gift-challenge/yumi/enter` | JSON `{ "activityId": "..." }` | 当日 `totalScore``dailyScore`、名次和任务状态;幂等完成进入页面任务 |
| GET | `/app/h5/gift-challenge/yumi/ranking` | query `activityId` 可选、`periodType=DAILY\|OVERALL`、DAILY 可传 `statDate=yyyy-MM-dd``limit`(默认/最大 30 | 原始 Top N 过滤神秘人后的 `entries`、本人真实 `my``settled` |
| POST | `/app/h5/gift-challenge/yumi/tasks/:taskCode/claim` | JSON `{ "activityId": "...", "requestId": "..." }` | 更新后的当日用户状态;`requestId` 最长 128 |
只有 `GOLD`、非背包、非 `LUCKY_GIFT`/`MAGIC` 的普通礼物计分。积分按 `giftCandy × quantity × 去重有效收礼人数` 精确计算,支持最多两位小数。
## Webconsole 接口
鉴权Console `Authorization`。所有 GET 还要求页面菜单 `ResidentYumiGiftChallenge`;写操作分别校验按钮权限。
基础地址:`/resident-activity/yumi-gift-challenge`
| 方法 | 地址 | 参数/返回 | 权限 |
|---|---|---|---|
| GET | `/list` | query `sysOrigin=LIKEI&page&size`,返回 `{records,total,current,size}` | 页面菜单 |
| GET | `/detail/:id` | 完整活动、任务、奖励 | 页面菜单 |
| GET | `/tasks/:id` | 返回任务数组 | 页面菜单 |
| GET | `/rank-rewards/:id` | 返回日榜/总榜奖励数组 | 页面菜单 |
| GET | `/ranking/:id` | query `periodType,statDate,limit` | 页面菜单 |
| GET | `/settlement-records/:id` | query `periodType,statDate,deliveryStatus,page,size` | 页面菜单 |
| POST | `/save` | 完整活动 JSON新活动固定 3 个启用任务,榜奖区间可配置 | `resident-activity:yumi-gift-challenge:edit` |
| PUT | `/enable/:id` | query `enabled=true\|false` | `resident-activity:yumi-gift-challenge:enable` |
| PUT | `/tasks/:id` | JSON `{ "activityId":"...", "tasks":[...] }` | `...:edit` |
| PUT | `/rank-rewards/:id` | JSON `{ "activityId":"...", "rankRewards":[...] }` | `...:edit` |
| POST | `/settlement/:id` | JSON `{ "periodType":"DAILY\|OVERALL", "statDate":"yyyy-MM-dd" }`OVERALL 不传日期 | `...:settle` |
| POST | `/delivery-item/resolve/:itemId` | JSON `{ "delivered": true\|false }`;仅人工核账 UNKNOWN | `...:reconcile` |
活动启用时会校验奖励组已上架且属于 LIKEI并冻结完整奖励项。活动开始后仅允许通过 `/save` 修改名称和描述,其他统计/奖励口径只读。
日榜、总榜结算延迟均允许 `0-1440` 分钟,且 `overallSettlementDelayMinutes` 必须大于或等于 `dailySettlementDelayMinutes`,保证最终日榜不会晚于总榜关门。
## chatapp-cron 触发
`POST /internal/yumi-gift-challenge/settle-due?batch=20`
- 鉴权头:`X-Internal-Token`,值与 `CHATAPP_HTTP_INTERNAL_CALLBACK_SECRET` 一致。
- Go 服务或 cron 任一侧未配置该密钥时均 fail-closed不发送/不执行结算请求。
- 单轮最多处理 `batch` 个到期 `NOT_STARTED/PROCESSING` 日榜或总榜,并各恢复最多 `batch` 个任务/榜奖 owner`batch` 默认 20、最大 100。
- 返回 `periodsScanned``periodsFrozen``taskOwnersScanned``settlementOwnersScanned``ownersProcessed``failedKeys`
- HTTP 200 仍可能表示部分 owner 已成功、部分待恢复cron 会把非空 `failedKeys` 提升为失败告警,下轮依靠数据库状态幂等续跑。
- 该端点可重复、可多实例并发调用;周期门闩、业务唯一键和逐奖励项 trackId 保证幂等。过租期 `PROCESSING` 会保守转为 `UNKNOWN`,不会自动盲目补发。
建议 chatapp-cron 每 10 秒至 1 分钟触发一次,单次 HTTP 超时至少覆盖每项 30 秒的下游上限和配置的 batch。
## RocketMQ 与 IM
- 独立消费组:`YUMI_GIFT_CHALLENGE_GIFT`
- 默认 topic/tag`RC_DEFAULT_APP_ORDINARY` / `give_gift_v3`
- 环境变量前缀:`CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_*`;缺少 endpoint/密钥时消费者不会伪造凭证启动。
- 本活动没有新增 IM 消息、房间广播或客户端 IM 协议H5 通过上述 HTTP 接口刷新任务和排行榜。

View File

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

View File

@ -8,12 +8,6 @@ import (
"time"
)
const (
cpRelationBroadcastMQConsumerGroup = "cp-relation-broadcast"
cpRelationBroadcastMQTopic = "RC_DEFAULT_APP_ORDINARY"
cpRelationBroadcastMQTag = "cp_relation_broadcast"
)
// Config 是按运行层和业务模块分段后的应用配置。
type Config struct {
HTTP HTTPConfig
@ -22,18 +16,14 @@ type Config struct {
Worker WorkerConfig
Invite InviteConfig
RechargeReward RechargeRewardConfig
FirstRechargeReward FirstRechargeRewardConfig
TaskCenter TaskCenterConfig
WeekStar WeekStarConfig
YumiGiftChallenge YumiGiftChallengeConfig
RoomTurnoverReward RoomTurnoverRewardConfig
VoiceRoomRedPacket VoiceRoomRedPacketConfig
VoiceRoomRocket VoiceRoomRocketConfig
CPRelationBroadcast CPRelationBroadcastConfig
TencentIM TencentIMConfig
Baishun BaishunConfig
Lingxian LingxianConfig
Hotgame HotgameConfig
GameOpen GameOpenConfig
LuckyGift LuckyGiftConfig
BinanceRecharge BinanceRechargeConfig
@ -98,25 +88,6 @@ type RechargeRewardConfig struct {
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
@ -173,13 +144,6 @@ type WeekStarRocketMQConfig struct {
Tag string
}
// YumiGiftChallengeConfig 保存 Yumi 礼物挑战的独立送礼消费者和发奖租期配置。
type YumiGiftChallengeConfig struct {
DefaultSysOrigin string
DeliveryProcessingLeaseSeconds int
RocketMQ WeekStarRocketMQConfig
}
// RoomTurnoverRewardConfig 保存房间流水奖励模块配置。
type RoomTurnoverRewardConfig struct {
DefaultSysOrigin string
@ -228,25 +192,6 @@ type VoiceRoomRocketMQConfig struct {
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
@ -277,12 +222,6 @@ type LingxianConfig struct {
AppKey string
}
// HotgameConfig 保存热游模块的环境兜底配置。
type HotgameConfig struct {
AppKey string
CallbackBaseURL string
}
// GameOpenConfig 保存统一三方游戏回调配置。
type GameOpenConfig struct {
PublicBaseURL string
@ -323,27 +262,12 @@ func Load() Config {
weekStarRocketMQEnabledDefault := strings.TrimSpace(weekStarRocketMQEndpoint) != "" &&
strings.TrimSpace(weekStarRocketMQAccessKey) != "" &&
strings.TrimSpace(weekStarRocketMQAccessSecret) != ""
yumiGiftRocketMQEndpoint := getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT", "CHATAPP_WEEK_STAR_ROCKETMQ_ENDPOINT"}, "")
yumiGiftRocketMQAccessKey := getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY", "CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_KEY"}, "")
yumiGiftRocketMQAccessSecret := getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_ACCESS_SECRET", "LIKEI_ROCKETMQ_SECRET_KEY", "CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_SECRET"}, "")
yumiGiftRocketMQEnabledDefault := strings.TrimSpace(yumiGiftRocketMQEndpoint) != "" &&
strings.TrimSpace(yumiGiftRocketMQAccessKey) != "" &&
strings.TrimSpace(yumiGiftRocketMQAccessSecret) != ""
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{
@ -405,23 +329,6 @@ func Load() Config {
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"},
@ -453,7 +360,7 @@ func Load() Config {
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),
BatchSize: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_BATCH_SIZE", "TASK_CENTER_ARCHIVE_BATCH_SIZE"}, 10000),
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),
},
@ -474,22 +381,6 @@ func Load() Config {
Tag: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_TAG", "WEEK_STAR_ROCKETMQ_TAG"}, "give_gift_v3"),
},
},
YumiGiftChallenge: YumiGiftChallengeConfig{
// Java 的 Yumi 业务来源枚举是 LIKEI此默认值不能使用仅供品牌识别的 YUMI。
DefaultSysOrigin: strings.ToUpper(getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_DEFAULT_SYS_ORIGIN"}, "LIKEI")),
DeliveryProcessingLeaseSeconds: getEnvIntAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_DELIVERY_LEASE_SECONDS"}, 300),
RocketMQ: WeekStarRocketMQConfig{
Enabled: getEnvBoolAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_ENABLED"}, yumiGiftRocketMQEnabledDefault),
Endpoint: yumiGiftRocketMQEndpoint,
Namespace: getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_NAMESPACE", "LIKEI_ROCKETMQ_NAMESPACE"}, ""),
AccessKey: yumiGiftRocketMQAccessKey,
AccessSecret: yumiGiftRocketMQAccessSecret,
SecurityToken: getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_SECURITY_TOKEN"}, ""),
ConsumerGroup: getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_CONSUMER_GROUP"}, "YUMI_GIFT_CHALLENGE_GIFT"),
Topic: getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_TOPIC", "CHATAPP_WEEK_STAR_ROCKETMQ_TOPIC"}, "RC_DEFAULT_APP_ORDINARY"),
Tag: getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_TAG", "CHATAPP_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"},
@ -595,23 +486,6 @@ func Load() Config {
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"},
@ -654,10 +528,6 @@ func Load() Config {
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"),

View File

@ -102,29 +102,6 @@ func TestLoadTaskCenterMQUsesSharedRocketMQCredentials(t *testing.T) {
}
}
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")

View File

@ -56,16 +56,6 @@ func (g *Gateways) AuthenticateConsoleToken(ctx context.Context, authorization s
return g.Auth.AuthenticateConsoleToken(ctx, authorization)
}
// ListConsoleButtonAliases 透传管理员按钮权限查询。
func (g *Gateways) ListConsoleButtonAliases(ctx context.Context, authorization string) (map[string]struct{}, error) {
return g.Auth.ListConsoleButtonAliases(ctx, authorization)
}
// HasConsoleMenuAlias 查询管理员是否拥有指定页面菜单。
func (g *Gateways) HasConsoleMenuAlias(ctx context.Context, authorization, requiredAlias string) (bool, error) {
return g.Auth.HasConsoleMenuAlias(ctx, authorization, requiredAlias)
}
// GetDeviceFingerprint 透传到设备网关。
func (g *Gateways) GetDeviceFingerprint(ctx context.Context, userID int64) (string, error) {
return g.Device.GetDeviceFingerprint(ctx, userID)
@ -136,51 +126,21 @@ func (g *Gateways) SendActivityReward(ctx context.Context, req SendActivityRewar
return g.RewardDispatch.SendActivityReward(ctx, req)
}
// SendFrozenActivityReward 透传活动冻结奖励项发放网关。
func (g *Gateways) SendFrozenActivityReward(ctx context.Context, req SendFrozenActivityRewardRequest) error {
return g.RewardDispatch.SendFrozenActivityReward(ctx, req)
}
// GetRewardGroupDetail 透传到奖励查询网关。
func (g *Gateways) GetRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) {
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)
}
// MapUserProfiles 透传批量用户资料查询。
func (g *Gateways) MapUserProfiles(ctx context.Context, userIDs []int64) (map[int64]UserProfile, error) {
return g.Profile.MapUserProfiles(ctx, userIDs)
}
// GetUserMysteriousInvisibility 查询用户是否开启排行榜隐身能力。
func (g *Gateways) GetUserMysteriousInvisibility(ctx context.Context, sysOrigin string, userID int64) (bool, error) {
return g.Profile.GetUserMysteriousInvisibility(ctx, sysOrigin, userID)
}
// UpdateUserCountry 透传到 Java 用户国家更新接口。
func (g *Gateways) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error {
return g.Profile.UpdateUserCountry(ctx, userID, countryID)
}
// 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)
@ -306,16 +266,6 @@ func (g *AuthGateway) AuthenticateConsoleToken(ctx context.Context, authorizatio
return g.client.AuthenticateConsoleToken(ctx, authorization)
}
// ListConsoleButtonAliases 查询管理员按钮权限码。
func (g *AuthGateway) ListConsoleButtonAliases(ctx context.Context, authorization string) (map[string]struct{}, error) {
return g.client.ListConsoleButtonAliases(ctx, authorization)
}
// HasConsoleMenuAlias 查询管理员页面菜单权限。
func (g *AuthGateway) HasConsoleMenuAlias(ctx context.Context, authorization, requiredAlias string) (bool, error) {
return g.client.HasConsoleMenuAlias(ctx, authorization, requiredAlias)
}
// DeviceGateway 负责设备指纹相关接口。
type DeviceGateway struct {
client *Client
@ -405,11 +355,6 @@ func (g *RewardDispatchGateway) SendActivityReward(ctx context.Context, req Send
return g.client.SendActivityReward(ctx, req)
}
// SendFrozenActivityReward 发放一个已经冻结的活动奖励项。
func (g *RewardDispatchGateway) SendFrozenActivityReward(ctx context.Context, req SendFrozenActivityRewardRequest) error {
return g.client.SendFrozenActivityReward(ctx, req)
}
// RewardQueryGateway 负责奖励配置查询接口。
type RewardQueryGateway struct {
client *Client
@ -420,11 +365,6 @@ 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)
@ -445,31 +385,11 @@ func (g *ProfileGateway) GetUserProfile(ctx context.Context, userID int64) (User
return g.client.GetUserProfile(ctx, userID)
}
// MapUserProfiles 批量查询用户资料。
func (g *ProfileGateway) MapUserProfiles(ctx context.Context, userIDs []int64) (map[int64]UserProfile, error) {
return g.client.MapUserProfiles(ctx, userIDs)
}
// GetUserMysteriousInvisibility 查询单个用户的排行榜隐身能力。
func (g *ProfileGateway) GetUserMysteriousInvisibility(ctx context.Context, sysOrigin string, userID int64) (bool, error) {
return g.client.GetUserMysteriousInvisibility(ctx, sysOrigin, userID)
}
// UpdateUserCountry 通过 Java other 更新用户国家。
func (g *ProfileGateway) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error {
return g.client.UpdateUserCountry(ctx, userID, countryID)
}
// 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)

View File

@ -41,11 +41,6 @@ type ConsoleAccount struct {
Token string `json:"token"`
}
type ConsoleMenu struct {
Alias string `json:"alias"`
Childrens []ConsoleMenu `json:"childrens"`
}
type UserProfile struct {
ID Int64Value `json:"id"`
Account string `json:"account"`
@ -64,12 +59,6 @@ type UserRegion struct {
RegionCode string `json:"regionCode"`
}
type UserLevel struct {
UserID Int64Value `json:"userId"`
WealthLevel int `json:"wealthLevel"`
CharmLevel int `json:"charmLevel"`
}
type RegionConfig struct {
RegionCode string `json:"regionCode"`
SysOrigin string `json:"sysOrigin"`
@ -204,30 +193,6 @@ type SendActivityRewardRequest struct {
AcceptUserID int64 `json:"acceptUserId"`
}
// SendFrozenActivityRewardRequest 直接携带启用时冻结的单个奖励项,避免活动发奖时重新读取已变化的共享奖励组。
type SendFrozenActivityRewardRequest struct {
TrackID int64 `json:"trackId"`
AcceptUserID int64 `json:"acceptUserId"`
Origin string `json:"origin"`
SysOrigin string `json:"sysOrigin"`
Prizes []FrozenActivityReward `json:"prizes"`
}
// FrozenActivityReward 对齐 Java PrizeDescribe内层 TrackID 是资源组商品 ID外层请求 TrackID 才是 delivery item 幂等号。
type FrozenActivityReward struct {
TrackID int64 `json:"trackId"`
Type string `json:"type"`
DetailType string `json:"detailType,omitempty"`
Content string `json:"content,omitempty"`
Quantity int64 `json:"quantity"`
Remark string `json:"remark,omitempty"`
}
type UserVipAbility struct {
UserID Int64Value `json:"userId"`
MysteriousInvisibility bool `json:"mysteriousInvisibility"`
}
type TeamPolicyReleaseQuery struct {
SysOrigin string
Region string
@ -272,32 +237,20 @@ type OfficialNoticeCustomizeRequest struct {
type RewardGroupItem struct {
ID Int64Value `json:"id"`
Type string `json:"type"`
DetailType string `json:"detailType"`
Name string `json:"name"`
BadgeName string `json:"badgeName"`
Content string `json:"content"`
Quantity Int64Value `json:"quantity"`
Cover string `json:"cover"`
SourceURL string `json:"sourceUrl"`
Remark string `json:"remark"`
Sort int `json:"sort"`
}
type RewardGroupDetail struct {
ID Int64Value `json:"id"`
Name string `json:"name"`
SysOrigin string `json:"sysOrigin"`
ShelfStatus *bool `json:"shelfStatus,omitempty"`
RewardConfigList []RewardGroupItem `json:"rewardConfigList"`
}
type rewardGroupPage struct {
Records []RewardGroupDetail `json:"records"`
Total int64 `json:"total"`
Current int `json:"current"`
Size int `json:"size"`
}
type UserTotalRecharge struct {
ID Int64Value `json:"id"`
UserID Int64Value `json:"userId"`
@ -380,11 +333,8 @@ type freightSellerPageResponse struct {
type resultResponse[T any] struct {
Code int `json:"code"`
Message string `json:"message"`
ErrorMsg string `json:"errorMsg"`
Body T `json:"body"`
Data T `json:"data"`
Success *bool `json:"success"`
Status *bool `json:"status"`
}
type Int64Value int64
@ -547,63 +497,6 @@ func (c *Client) AuthenticateConsoleToken(ctx context.Context, authorization str
return resp.Body, nil
}
// ListConsoleButtonAliases 返回当前管理员真实拥有的按钮权限码,供 Go 写接口做服务端授权。
func (c *Client) ListConsoleButtonAliases(ctx context.Context, authorization string) (map[string]struct{}, error) {
authorization = strings.TrimSpace(authorization)
if authorization == "" {
return nil, fmt.Errorf("authorization is blank")
}
endpoint := strings.TrimRight(c.cfg.Java.ConsoleBaseURL, "/") + "/account/buttons/aliases"
var resp resultResponse[[]string]
if err := c.getJSON(ctx, endpoint, javaConsoleHeaders(authorization), &resp); err != nil {
return nil, err
}
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
return nil, errors.New(firstNonEmpty(resp.ErrorMsg, resp.Message, "list console button aliases failed"))
}
aliases := resp.Body
if len(aliases) == 0 {
aliases = resp.Data
}
result := make(map[string]struct{}, len(aliases))
for _, alias := range aliases {
if alias = strings.TrimSpace(alias); alias != "" {
result[alias] = struct{}{}
}
}
return result, nil
}
// HasConsoleMenuAlias 递归检查当前管理员菜单树,作为 Go 管理端读接口的服务端页面授权。
func (c *Client) HasConsoleMenuAlias(ctx context.Context, authorization, requiredAlias string) (bool, error) {
authorization = strings.TrimSpace(authorization)
if authorization == "" {
return false, fmt.Errorf("authorization is blank")
}
endpoint := strings.TrimRight(c.cfg.Java.ConsoleBaseURL, "/") + "/account/menus"
var resp resultResponse[[]ConsoleMenu]
if err := c.getJSON(ctx, endpoint, javaConsoleHeaders(authorization), &resp); err != nil {
return false, err
}
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
return false, errors.New(firstNonEmpty(resp.ErrorMsg, resp.Message, "list console menus failed"))
}
menus := resp.Body
if len(menus) == 0 {
menus = resp.Data
}
var contains func([]ConsoleMenu) bool
contains = func(items []ConsoleMenu) bool {
for _, item := range items {
if strings.TrimSpace(item.Alias) == requiredAlias || contains(item.Childrens) {
return true
}
}
return false
}
return contains(menus), nil
}
func javaConsoleHeaders(authorization string) http.Header {
headers := http.Header{}
headers.Set("Authorization", strings.TrimSpace(authorization))
@ -723,22 +616,6 @@ func javaAppHeadersFromAuthorization(authorization string) http.Header {
return headers
}
// javaInternalAppHeaders 为没有终端 token 的服务端查询注入可信用户上下文,例如榜单隐身能力检查。
func javaInternalAppHeaders(sysOrigin string, userID int64) http.Header {
sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin))
if sysOrigin == "" {
sysOrigin = "LIKEI"
}
headers := http.Header{}
headers.Set("Req-Sys-Origin", "origin="+sysOrigin+";originChild="+sysOrigin)
headers.Set("Req-Client", "Android")
headers.Set("Req-App-Intel", "version=1.0.0;build=100;channel=internal;model=go-service;sysVersion=go")
headers.Set("Req-Zone", "Asia/Shanghai")
headers.Set("Req-Imei", "go-service")
headers.Set("Req-Inner-Internal", fmt.Sprintf("time=%d;uid=%d;", time.Now().UnixMilli(), userID))
return headers
}
func trimBearerToken(authorization string) string {
token := strings.TrimSpace(authorization)
if len(token) >= 7 && strings.EqualFold(token[:7], "Bearer ") {
@ -756,40 +633,7 @@ func (c *Client) GrantProps(ctx context.Context, req GrantPropsRequest) error {
}
func (c *Client) SendActivityReward(ctx context.Context, req SendActivityRewardRequest) error {
var resp resultResponse[any]
if err := c.postJSON(ctx, c.cfg.Java.OtherBaseURL+"/props-activity-cnf/client/sendActivityReward", req, nil, &resp); err != nil {
return err
}
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
message := strings.TrimSpace(resp.Message)
if message == "" {
message = "send activity reward failed"
}
return errors.New(message)
}
return nil
}
// SendFrozenActivityReward 通过 Java 已有的 PrizeDescribe 接口发放一个冻结奖励项。
func (c *Client) SendFrozenActivityReward(ctx context.Context, req SendFrozenActivityRewardRequest) error {
if req.TrackID <= 0 || req.AcceptUserID <= 0 || len(req.Prizes) != 1 {
return fmt.Errorf("invalid frozen activity reward request")
}
var resp resultResponse[any]
if err := c.postJSON(ctx, strings.TrimRight(c.cfg.Java.OtherBaseURL, "/")+"/props/tool/client/sendProps", req, nil, &resp); err != nil {
return err
}
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
message := strings.TrimSpace(resp.ErrorMsg)
if message == "" {
message = strings.TrimSpace(resp.Message)
}
if message == "" {
message = "send frozen activity reward failed"
}
return errors.New(message)
}
return nil
return c.postJSON(ctx, c.cfg.Java.OtherBaseURL+"/props-activity-cnf/client/sendActivityReward", req, nil, nil)
}
func (c *Client) GivePropsBackpack(ctx context.Context, req GivePropsBackpackRequest) error {
@ -852,154 +696,15 @@ func (c *Client) SendOfficialNoticeCustomize(ctx context.Context, req OfficialNo
}
func (c *Client) GetRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) {
detail, err := c.getInnerRewardGroupDetail(ctx, groupID)
if err == nil && rewardGroupDetailHasContent(detail) {
return detail, nil
}
if fallback, fallbackErr := c.getConsoleRewardGroupDetail(ctx, groupID); fallbackErr == nil && rewardGroupDetailHasContent(fallback) {
return fallback, nil
} else if err == nil && fallbackErr != nil {
return detail, nil
}
return detail, err
}
func (c *Client) getInnerRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) {
endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/props-activity/reward/group/client/getByGroupId?id=" + url.QueryEscape(strconv.FormatInt(groupID, 10))
endpoint := c.cfg.Java.OtherBaseURL + "/props-activity/reward/group/client/getByGroupId?id=" + url.QueryEscape(strconv.FormatInt(groupID, 10))
var resp resultResponse[RewardGroupDetail]
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
return RewardGroupDetail{}, err
}
detail := resp.Body
if int64(detail.ID) == 0 {
detail = resp.Data
}
if int64(detail.ID) == 0 {
if int64(resp.Body.ID) == 0 {
return RewardGroupDetail{}, fmt.Errorf("empty reward group response")
}
return detail, nil
}
func (c *Client) getConsoleRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) {
baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.Java.ConsoleBaseURL), "/")
if baseURL == "" {
return RewardGroupDetail{}, fmt.Errorf("console base url is empty")
}
endpoint := baseURL + "/props/activity/reward/group/" + url.PathEscape(strconv.FormatInt(groupID, 10))
var detail RewardGroupDetail
if err := c.getJSON(ctx, endpoint, nil, &detail); err != nil {
return RewardGroupDetail{}, err
}
if int64(detail.ID) == 0 {
return RewardGroupDetail{}, fmt.Errorf("empty reward group response")
}
return detail, nil
}
func rewardGroupDetailHasContent(detail RewardGroupDetail) bool {
return int64(detail.ID) > 0 && len(detail.RewardConfigList) > 0
}
func (c *Client) FindRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (RewardGroupDetail, error) {
groupName := strings.TrimSpace(name)
if groupName == "" {
return RewardGroupDetail{}, fmt.Errorf("reward group name is empty")
}
if detail, err := c.findInnerRewardGroupDetail(ctx, sysOrigin, groupName); err == nil {
if hydrated, hydrateErr := c.hydrateRewardGroupDetail(ctx, detail); hydrateErr == nil && rewardGroupDetailHasContent(hydrated) {
return hydrated, nil
}
if rewardGroupDetailHasContent(detail) {
return detail, nil
}
}
detail, err := c.findConsoleRewardGroupDetail(ctx, sysOrigin, groupName)
if err != nil {
return RewardGroupDetail{}, err
}
if hydrated, hydrateErr := c.hydrateRewardGroupDetail(ctx, detail); hydrateErr == nil && rewardGroupDetailHasContent(hydrated) {
return hydrated, nil
}
return detail, nil
}
func (c *Client) hydrateRewardGroupDetail(ctx context.Context, detail RewardGroupDetail) (RewardGroupDetail, error) {
if rewardGroupDetailHasContent(detail) {
return detail, nil
}
groupID := int64(detail.ID)
if groupID <= 0 {
return detail, fmt.Errorf("empty reward group id")
}
if hydrated, err := c.getInnerRewardGroupDetail(ctx, groupID); err == nil && rewardGroupDetailHasContent(hydrated) {
return hydrated, nil
}
return c.getConsoleRewardGroupDetail(ctx, groupID)
}
func (c *Client) findInnerRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (RewardGroupDetail, error) {
endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/props-activity/reward/group/client/page"
payload := map[string]any{
"current": 1,
"cursor": 1,
"limit": 10,
"name": name,
"shelfStatus": true,
"size": 10,
}
if origin := strings.TrimSpace(sysOrigin); origin != "" {
payload["sysOrigin"] = origin
}
var resp resultResponse[rewardGroupPage]
if err := c.postJSON(ctx, endpoint, payload, nil, &resp); err != nil {
return RewardGroupDetail{}, err
}
page := resp.Body
if len(page.Records) == 0 {
page = resp.Data
}
return pickRewardGroupDetailByName(page.Records, name)
}
func (c *Client) findConsoleRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (RewardGroupDetail, error) {
baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.Java.ConsoleBaseURL), "/")
if baseURL == "" {
return RewardGroupDetail{}, fmt.Errorf("console base url is empty")
}
values := url.Values{}
values.Set("current", "1")
values.Set("cursor", "1")
values.Set("limit", "10")
values.Set("name", name)
values.Set("shelfStatus", "true")
values.Set("size", "10")
if origin := strings.TrimSpace(sysOrigin); origin != "" {
values.Set("sysOrigin", origin)
}
endpoint := baseURL + "/props/activity/reward/group/page?" + values.Encode()
var page rewardGroupPage
if err := c.getJSON(ctx, endpoint, nil, &page); err != nil {
return RewardGroupDetail{}, err
}
return pickRewardGroupDetailByName(page.Records, name)
}
func pickRewardGroupDetailByName(records []RewardGroupDetail, name string) (RewardGroupDetail, error) {
if len(records) == 0 {
return RewardGroupDetail{}, fmt.Errorf("empty reward group page response")
}
target := strings.TrimSpace(name)
for _, record := range records {
if strings.TrimSpace(record.Name) == target {
return record, nil
}
}
for _, record := range records {
if rewardGroupDetailHasContent(record) {
return record, nil
}
}
return records[0], nil
return resp.Body, nil
}
func (c *Client) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]UserTotalRecharge, error) {
@ -1114,8 +819,8 @@ func (c *Client) ChangeFreightBalance(ctx context.Context, req FreightBalanceCha
if err := c.postJSON(ctx, endpoint, req, nil, &resp); err != nil {
return err
}
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
message := strings.TrimSpace(firstNonEmpty(resp.ErrorMsg, resp.Message))
if resp.Success != nil && !*resp.Success {
message := strings.TrimSpace(resp.Message)
if message == "" {
message = "freight balance change failed"
}
@ -1174,90 +879,6 @@ func (c *Client) GetUserProfile(ctx context.Context, userID int64) (UserProfile,
return resp.Body, nil
}
// MapUserProfiles 批量查询用户资料,避免排行榜逐用户回源造成请求瀑布。
func (c *Client) MapUserProfiles(ctx context.Context, userIDs []int64) (map[int64]UserProfile, error) {
result := make(map[int64]UserProfile, len(userIDs))
if len(userIDs) == 0 {
return result, nil
}
endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/user-profile/client/mapByUserIds"
var resp resultResponse[map[string]UserProfile]
if err := c.postJSON(ctx, endpoint, userIDs, nil, &resp); err != nil {
return nil, err
}
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
message := strings.TrimSpace(firstNonEmpty(resp.Message, resp.ErrorMsg))
if message == "" {
message = "map user profiles failed"
}
return nil, errors.New(message)
}
values := resp.Body
if len(values) == 0 {
values = resp.Data
}
for key, profile := range values {
userID, err := strconv.ParseInt(strings.TrimSpace(key), 10, 64)
if err != nil || userID <= 0 {
userID = int64(profile.ID)
}
if userID > 0 {
result[userID] = profile
}
}
return result, nil
}
// GetUserMysteriousInvisibility 复用 Yumi Java 已有 VIP 能力接口;调用失败由榜单层按隐私优先策略处理。
func (c *Client) GetUserMysteriousInvisibility(ctx context.Context, sysOrigin string, userID int64) (bool, error) {
if userID <= 0 {
return false, fmt.Errorf("userId is required")
}
endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/user/vip/ability"
var resp resultResponse[UserVipAbility]
if err := c.getJSON(ctx, endpoint, javaInternalAppHeaders(sysOrigin, userID), &resp); err != nil {
return false, err
}
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
message := strings.TrimSpace(firstNonEmpty(resp.ErrorMsg, resp.Message))
if message == "" {
message = "get user vip ability failed"
}
return false, errors.New(message)
}
return resp.Body.MysteriousInvisibility, nil
}
// UpdateUserCountry 只提交稳定的用户 ID 和国家 ID国家码、名称以及并发锁统一由 Java other 处理。
func (c *Client) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error {
if userID <= 0 {
return fmt.Errorf("userId is required")
}
if countryID <= 0 {
return fmt.Errorf("countryId is required")
}
endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/user-profile/client/updateSelectiveById"
payload := struct {
ID int64 `json:"id"`
CountryID int64 `json:"countryId"`
}{
ID: userID,
CountryID: countryID,
}
var resp resultResponse[any]
if err := c.postJSON(ctx, endpoint, payload, nil, &resp); err != nil {
return err
}
if resp.Success != nil && !*resp.Success {
message := strings.TrimSpace(resp.Message)
if message == "" {
message = "user country update failed"
}
return errors.New(message)
}
return nil
}
func (c *Client) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) {
if userID <= 0 {
return UserRegion{}, fmt.Errorf("userId is required")
@ -1274,21 +895,6 @@ func (c *Client) GetUserRegion(ctx context.Context, userID int64, authorization
return resp.Body, nil
}
func (c *Client) GetUserLevel(ctx context.Context, sysOrigin string, userID int64) (UserLevel, error) {
if userID <= 0 {
return UserLevel{}, fmt.Errorf("userId is required")
}
values := url.Values{}
values.Set("sysOrigin", strings.TrimSpace(sysOrigin))
values.Set("userId", strconv.FormatInt(userID, 10))
endpoint := c.cfg.Java.OtherBaseURL + "/user-level/client/getLevelByUserId?" + values.Encode()
var resp resultResponse[UserLevel]
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
return UserLevel{}, err
}
return resp.Body, nil
}
func (c *Client) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
if countryCode == "" {

View File

@ -77,131 +77,3 @@ func TestResolveRegionCodeByCountryCodeUsesRegionConfig(t *testing.T) {
t.Fatalf("region config calls = %d, want cached 1", calls)
}
}
func TestUpdateUserCountryPostsOnlyStableIDs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/user-profile/client/updateSelectiveById" {
t.Fatalf("request = %s %s, want POST /user-profile/client/updateSelectiveById", r.Method, r.URL.Path)
}
var payload map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
if len(payload) != 2 || string(payload["id"]) != "2" || string(payload["countryId"]) != "11" {
t.Fatalf("payload = %s, want only id and countryId", payload)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true}`))
}))
defer server.Close()
client := New(config.Config{
HTTP: config.HTTPConfig{Timeout: time.Second},
Java: config.JavaConfig{OtherBaseURL: server.URL},
})
if err := client.UpdateUserCountry(context.Background(), 2, 11); err != nil {
t.Fatalf("UpdateUserCountry() error = %v", err)
}
}
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

@ -1,169 +0,0 @@
package model
import (
"database/sql/driver"
"encoding/json"
"fmt"
"math/big"
"strings"
)
// Decimal24_2 用规范十进制文本承载 MySQL DECIMAL(24,2),避免金额经过 float64 丢精度。
// JSON 输出字符串以保护 22 位整数精度;数据库读写通过 Scanner/Valuer 完成。
type Decimal24_2 string
func ParseDecimal24_2(value string) (Decimal24_2, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", fmt.Errorf("decimal is blank")
}
negative := false
if value[0] == '-' {
negative, value = true, value[1:]
} else if value[0] == '+' {
return "", fmt.Errorf("decimal plus sign is not supported")
}
if value == "" || strings.ContainsAny(value, "eE") {
return "", fmt.Errorf("invalid decimal")
}
parts := strings.Split(value, ".")
if len(parts) > 2 || parts[0] == "" {
return "", fmt.Errorf("invalid decimal")
}
for _, char := range parts[0] {
if char < '0' || char > '9' {
return "", fmt.Errorf("invalid decimal")
}
}
integer := strings.TrimLeft(parts[0], "0")
if integer == "" {
integer = "0"
}
fraction := ""
if len(parts) == 2 {
fraction = parts[1]
for _, char := range fraction {
if char < '0' || char > '9' {
return "", fmt.Errorf("invalid decimal")
}
}
// BigDecimal.stripTrailingZeros 后 scale<=2 的值均可接受,例如 1.2300。
if len(fraction) > 2 && strings.Trim(fraction[2:], "0") != "" {
return "", fmt.Errorf("decimal scale exceeds 2")
}
}
if len(integer) > 22 {
return "", fmt.Errorf("decimal integer digits exceed 22")
}
if len(fraction) > 2 {
fraction = fraction[:2]
}
fraction += strings.Repeat("0", 2-len(fraction))
if integer == "0" && fraction == "00" {
negative = false
}
prefix := ""
if negative {
prefix = "-"
}
return Decimal24_2(prefix + integer + "." + fraction), nil
}
func (d *Decimal24_2) UnmarshalJSON(data []byte) error {
raw := strings.TrimSpace(string(data))
if raw == "" || raw == "null" || raw == `""` {
*d = DecimalZero()
return nil
}
if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) {
var value string
if err := json.Unmarshal(data, &value); err != nil {
return err
}
raw = value
}
parsed, err := ParseDecimal24_2(raw)
if err != nil {
return err
}
*d = parsed
return nil
}
func (d Decimal24_2) MarshalJSON() ([]byte, error) { return json.Marshal(d.String()) }
func (d *Decimal24_2) Scan(value any) error {
var raw string
switch typed := value.(type) {
case nil:
raw = "0"
case []byte:
raw = string(typed)
case string:
raw = typed
case int64:
raw = fmt.Sprintf("%d", typed)
default:
raw = fmt.Sprint(typed)
}
parsed, err := ParseDecimal24_2(raw)
if err != nil {
return err
}
*d = parsed
return nil
}
func (d Decimal24_2) Value() (driver.Value, error) { return d.String(), nil }
func (d Decimal24_2) String() string {
if strings.TrimSpace(string(d)) == "" {
return "0.00"
}
return string(d)
}
func DecimalZero() Decimal24_2 { return Decimal24_2("0.00") }
func (d Decimal24_2) Positive() bool { return d.cents().Sign() > 0 }
func (d Decimal24_2) Compare(other Decimal24_2) int { return d.cents().Cmp(other.cents()) }
func (d Decimal24_2) MultiplyInt(multiplier int64) (Decimal24_2, error) {
if multiplier < 0 {
return "", fmt.Errorf("decimal multiplier must not be negative")
}
value := new(big.Int).Mul(d.cents(), big.NewInt(multiplier))
if len(new(big.Int).Abs(new(big.Int).Set(value)).String()) > 24 {
return "", fmt.Errorf("decimal exceeds DECIMAL(24,2)")
}
return decimalFromCents(value), nil
}
func (d Decimal24_2) cents() *big.Int {
raw := d.String()
negative := strings.HasPrefix(raw, "-")
raw = strings.TrimPrefix(raw, "-")
digits := strings.ReplaceAll(raw, ".", "")
value, ok := new(big.Int).SetString(digits, 10)
if !ok {
return new(big.Int)
}
if negative {
value.Neg(value)
}
return value
}
func decimalFromCents(value *big.Int) Decimal24_2 {
negative := value.Sign() < 0
abs := new(big.Int).Abs(new(big.Int).Set(value)).String()
if len(abs) < 3 {
abs = strings.Repeat("0", 3-len(abs)) + abs
}
result := abs[:len(abs)-2] + "." + abs[len(abs)-2:]
if negative && result != "0.00" {
result = "-" + result
}
return Decimal24_2(result)
}

View File

@ -1,68 +0,0 @@
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

@ -1,41 +0,0 @@
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

@ -116,19 +116,19 @@ 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;index:idx_task_center_game_king_backfill,priority:4"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_task_center_archive_event,priority:1;index:idx_task_center_game_king_backfill,priority:1"`
ID int64 `gorm:"column:id;primaryKey"`
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;index:idx_task_center_game_king_backfill,priority:2"`
EventType string `gorm:"column:event_type;size:64"`
UserID int64 `gorm:"column:user_id"`
OccurredAt time.Time `gorm:"column:occurred_at;index:idx_task_center_game_king_backfill,priority:3"`
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"`
Status string `gorm:"column:status;size:32;index:idx_task_center_archive_status_time,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"`
CreateTime time.Time `gorm:"column:create_time;index:idx_task_center_archive_status_time,priority:2"`
UpdateTime time.Time `gorm:"column:update_time"`
ArchivedAt *time.Time `gorm:"column:archived_at"`
}

View File

@ -76,12 +76,12 @@ 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"`
ID int64 `gorm:"column:id;primaryKey"`
PacketID int64 `gorm:"column:packet_id;uniqueIndex:uk_voice_room_red_packet_slice,priority:1;index:idx_voice_room_red_packet_slice_claim,priority:1"`
PacketNo string `gorm:"column:packet_no;size:64"`
SortNo int `gorm:"column:sort_no;uniqueIndex:uk_voice_room_red_packet_slice,priority:2;index:idx_voice_room_red_packet_slice_claim_order,priority:3"`
SortNo int `gorm:"column:sort_no;uniqueIndex:uk_voice_room_red_packet_slice,priority:2"`
Amount int64 `gorm:"column:amount"`
Status string `gorm:"column:status;size:32;index:idx_voice_room_red_packet_slice_claim,priority:2;index:idx_voice_room_red_packet_slice_claim_order,priority:2"`
Status string `gorm:"column:status;size:32;index:idx_voice_room_red_packet_slice_claim,priority:2"`
ClaimUserID *int64 `gorm:"column:claim_user_id"`
ClaimRecordID *int64 `gorm:"column:claim_record_id"`
CreateTime time.Time `gorm:"column:create_time"`

View File

@ -1,170 +0,0 @@
package model
import "time"
// YumiGameKingActivity 保存一次游戏王活动及其不可变统计口径。
type YumiGameKingActivity struct {
ID int64 `gorm:"column:id;primaryKey"`
ActivityCode string `gorm:"column:activity_code;size:64;uniqueIndex:uk_yumi_gk_activity_code"`
ActivityName string `gorm:"column:activity_name;size:128"`
ActivityDesc string `gorm:"column:activity_desc;size:1000"`
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_yumi_gk_activity_window,priority:1"`
Timezone string `gorm:"column:time_zone;size:64"`
StartTime time.Time `gorm:"column:start_time;index:idx_yumi_gk_activity_window,priority:3"`
EndTime time.Time `gorm:"column:end_time;index:idx_yumi_gk_activity_window,priority:4;index:idx_yumi_gk_activity_settle,priority:3"`
SettlementDelayMinutes int `gorm:"column:settlement_delay_minutes"`
SettlementTime time.Time `gorm:"column:settlement_time;index:idx_yumi_gk_activity_settle,priority:2"`
CoinPerDraw int64 `gorm:"column:coin_per_draw"`
RankingType string `gorm:"column:ranking_type;size:32"`
RankingPeriod string `gorm:"column:ranking_period;size:32"`
Enabled bool `gorm:"column:enabled;index:idx_yumi_gk_activity_window,priority:2"`
SettlementStatus string `gorm:"column:settlement_status;size:32;index:idx_yumi_gk_activity_settle,priority:1"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (YumiGameKingActivity) TableName() string { return "yumi_game_king_activity" }
// YumiGameKingScopeLock 串行同租户启用窗口检查,避免并发创建不同活动时同时通过 overlap COUNT。
type YumiGameKingScopeLock struct {
SysOrigin string `gorm:"column:sys_origin;size:32;primaryKey"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (YumiGameKingScopeLock) TableName() string { return "yumi_game_king_scope_lock" }
// YumiGameKingPrize 保存固定七个抽奖槽位RewardItemsJSON 是启用前校验得到的展示快照。
type YumiGameKingPrize struct {
ID int64 `gorm:"column:id;primaryKey"`
ActivityID int64 `gorm:"column:activity_id;uniqueIndex:uk_yumi_gk_prize_position,priority:1;index:idx_yumi_gk_prize_enabled,priority:1"`
PrizeName string `gorm:"column:prize_name;size:128"`
PrizeImage string `gorm:"column:prize_image;size:500"`
Weight int64 `gorm:"column:weight"`
Stock int64 `gorm:"column:stock"`
ResourceGroupID int64 `gorm:"column:resource_group_id"`
RewardGroupName string `gorm:"column:reward_group_name;size:128"`
RewardItemsJSON string `gorm:"column:reward_items_json;type:text"`
Enabled bool `gorm:"column:enabled;index:idx_yumi_gk_prize_enabled,priority:2"`
SortOrder int `gorm:"column:sort_order;uniqueIndex:uk_yumi_gk_prize_position,priority:2"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (YumiGameKingPrize) TableName() string { return "yumi_game_king_prize" }
// YumiGameKingRankReward 保存活动结束时固定六个连续名次奖励档。
type YumiGameKingRankReward struct {
ID int64 `gorm:"column:id;primaryKey"`
ActivityID int64 `gorm:"column:activity_id;uniqueIndex:uk_yumi_gk_rank_range,priority:1;index:idx_yumi_gk_rank_start,priority:1"`
StartRank int `gorm:"column:start_rank;uniqueIndex:uk_yumi_gk_rank_range,priority:2;index:idx_yumi_gk_rank_start,priority:2"`
EndRank int `gorm:"column:end_rank;uniqueIndex:uk_yumi_gk_rank_range,priority:3"`
ResourceGroupID int64 `gorm:"column:resource_group_id"`
RewardName string `gorm:"column:reward_name;size:128"`
RewardItemsJSON string `gorm:"column:reward_items_json;type:text"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (YumiGameKingRankReward) TableName() string { return "yumi_game_king_rank_reward" }
// YumiGameKingConsumeLedger 是活动维度的幂等消费账本,实时事件和后台补数共用此边界。
type YumiGameKingConsumeLedger struct {
ID int64 `gorm:"column:id;primaryKey"`
ActivityID int64 `gorm:"column:activity_id;uniqueIndex:uk_yumi_gk_ledger_event,priority:1;index:idx_yumi_gk_ledger_user,priority:1"`
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_yumi_gk_ledger_event,priority:2"`
UserID int64 `gorm:"column:user_id;index:idx_yumi_gk_ledger_user,priority:2"`
SysOrigin string `gorm:"column:sys_origin;size:32"`
Amount int64 `gorm:"column:amount"`
EventTime time.Time `gorm:"column:event_time;index:idx_yumi_gk_ledger_user,priority:3"`
PayloadJSON string `gorm:"column:payload_json;type:text"`
CreateTime time.Time `gorm:"column:create_time"`
}
func (YumiGameKingConsumeLedger) TableName() string { return "yumi_game_king_consume_ledger" }
// YumiGameKingUser 保存榜单聚合和已使用抽奖次数,避免排行榜扫描消费账本。
type YumiGameKingUser struct {
ActivityID int64 `gorm:"column:activity_id;primaryKey;index:idx_yumi_gk_user_rank,priority:1"`
UserID int64 `gorm:"column:user_id;primaryKey;index:idx_yumi_gk_user_rank,priority:4,sort:asc"`
TotalConsumed int64 `gorm:"column:total_consumed;index:idx_yumi_gk_user_rank,priority:2,sort:desc"`
UsedChances int64 `gorm:"column:used_chances"`
ConsumeReachedTime time.Time `gorm:"column:consume_reached_time;index:idx_yumi_gk_user_rank,priority:3,sort:asc"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (YumiGameKingUser) TableName() string { return "yumi_game_king_user" }
// YumiGameKingUserDaily 保存活动时区自然日聚合DAILY 排行不扫描消费明细。
type YumiGameKingUserDaily struct {
ActivityID int64 `gorm:"column:activity_id;primaryKey;index:idx_yumi_gk_daily_rank,priority:1"`
StatDate time.Time `gorm:"column:stat_date;type:date;primaryKey;index:idx_yumi_gk_daily_rank,priority:2"`
UserID int64 `gorm:"column:user_id;primaryKey;index:idx_yumi_gk_daily_rank,priority:5,sort:asc"`
TotalConsumed int64 `gorm:"column:total_consumed;index:idx_yumi_gk_daily_rank,priority:3,sort:desc"`
ConsumeReachedTime time.Time `gorm:"column:consume_reached_time;index:idx_yumi_gk_daily_rank,priority:4,sort:asc"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (YumiGameKingUserDaily) TableName() string { return "yumi_game_king_user_daily" }
// YumiGameKingDrawRecord 保存一次请求幂等的抽奖结果和整组奖励发放状态。
type YumiGameKingDrawRecord struct {
ID int64 `gorm:"column:id;primaryKey"`
ActivityID int64 `gorm:"column:activity_id;uniqueIndex:uk_yumi_gk_draw_request,priority:1;index:idx_yumi_gk_draw_user,priority:1;index:idx_yumi_gk_draw_manage,priority:1"`
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_yumi_gk_draw_request,priority:2;index:idx_yumi_gk_draw_user,priority:2"`
RequestID string `gorm:"column:request_id;size:64;uniqueIndex:uk_yumi_gk_draw_request,priority:3"`
PrizeID int64 `gorm:"column:prize_id"`
PrizeName string `gorm:"column:prize_name;size:128"`
PrizeImage string `gorm:"column:prize_image;size:500"`
ResourceGroupID int64 `gorm:"column:resource_group_id"`
DeliveryStatus string `gorm:"column:delivery_status;size:32;index:idx_yumi_gk_draw_manage,priority:2"`
RetryCount int `gorm:"column:retry_count"`
FailureReason string `gorm:"column:failure_reason;size:500"`
DrawTime time.Time `gorm:"column:draw_time"`
DeliverTime *time.Time `gorm:"column:deliver_time"`
CreateTime time.Time `gorm:"column:create_time;index:idx_yumi_gk_draw_user,priority:3"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (YumiGameKingDrawRecord) TableName() string { return "yumi_game_king_draw_record" }
// YumiGameKingSettlementRecord 是活动结算时冻结的 Top30 排名和发奖状态。
type YumiGameKingSettlementRecord struct {
ID int64 `gorm:"column:id;primaryKey"`
ActivityID int64 `gorm:"column:activity_id;uniqueIndex:uk_yumi_gk_settle_user,priority:1;uniqueIndex:uk_yumi_gk_settle_rank,priority:1;index:idx_yumi_gk_settle_status,priority:1"`
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_yumi_gk_settle_user,priority:2"`
RankNo int `gorm:"column:rank_no;uniqueIndex:uk_yumi_gk_settle_rank,priority:2"`
TotalConsumed int64 `gorm:"column:total_consumed"`
RankRewardID int64 `gorm:"column:rank_reward_id"`
ResourceGroupID int64 `gorm:"column:resource_group_id"`
BusinessNo string `gorm:"column:business_no;size:128;uniqueIndex:uk_yumi_gk_settle_business"`
DeliveryStatus string `gorm:"column:delivery_status;size:32;index:idx_yumi_gk_settle_status,priority:2"`
RetryCount int `gorm:"column:retry_count"`
FailureReason string `gorm:"column:failure_reason;size:500"`
DeliverTime *time.Time `gorm:"column:deliver_time"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (YumiGameKingSettlementRecord) TableName() string {
return "yumi_game_king_settlement_record"
}
// YumiGameKingDeliveryItem 把一次奖励组调用作为最小核账单元UNKNOWN 不允许自动重试。
type YumiGameKingDeliveryItem struct {
ID int64 `gorm:"column:id;primaryKey;index:idx_yumi_gk_delivery_status,priority:3"`
OwnerType string `gorm:"column:owner_type;size:20;uniqueIndex:uk_yumi_gk_delivery_owner,priority:1"`
OwnerID int64 `gorm:"column:owner_id;uniqueIndex:uk_yumi_gk_delivery_owner,priority:2"`
ActivityID int64 `gorm:"column:activity_id;index:idx_yumi_gk_delivery_activity,priority:1"`
UserID int64 `gorm:"column:user_id"`
ResourceGroupID int64 `gorm:"column:resource_group_id"`
DeliveryStatus string `gorm:"column:delivery_status;size:32;index:idx_yumi_gk_delivery_status,priority:1;index:idx_yumi_gk_delivery_activity,priority:2"`
RetryCount int `gorm:"column:retry_count"`
FailureReason string `gorm:"column:failure_reason;size:500"`
DeliverTime *time.Time `gorm:"column:deliver_time"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time;index:idx_yumi_gk_delivery_status,priority:2"`
}
func (YumiGameKingDeliveryItem) TableName() string { return "yumi_game_king_delivery_item" }

View File

@ -1,221 +0,0 @@
package model
import "time"
// YumiGiftChallengeActivity 保存一次独立活动周期;活动开始后核心时间和奖励配置只读。
type YumiGiftChallengeActivity struct {
ID int64 `gorm:"column:id;primaryKey"`
ActivityCode string `gorm:"column:activity_code"`
ActivityName string `gorm:"column:activity_name"`
ActivityDesc string `gorm:"column:activity_desc"`
SysOrigin string `gorm:"column:sys_origin"`
Timezone string `gorm:"column:time_zone"`
StartTime time.Time `gorm:"column:start_time"`
EndTime time.Time `gorm:"column:end_time"`
DailySettlementDelayMinutes int `gorm:"column:daily_settlement_delay_minutes"`
OverallSettlementDelayMinutes int `gorm:"column:overall_settlement_delay_minutes"`
OverallSettlementTime time.Time `gorm:"column:overall_settlement_time"`
DisplayTopN int `gorm:"column:display_top_n"`
Enabled bool `gorm:"column:enabled"`
OverallSettlementStatus string `gorm:"column:overall_settlement_status"`
Version int `gorm:"column:version"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (YumiGiftChallengeActivity) TableName() string { return "yumi_gift_challenge_activity" }
// YumiGiftChallengeTaskConfig 是活动固定三个每日任务槽位的定义。
type YumiGiftChallengeTaskConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
ActivityID int64 `gorm:"column:activity_id"`
TaskCode string `gorm:"column:task_code"`
TaskType string `gorm:"column:task_type"`
TaskTitle string `gorm:"column:task_title"`
TaskDesc string `gorm:"column:task_desc"`
TargetValue Decimal24_2 `gorm:"column:target_value;type:decimal(24,2)"`
ResourceGroupID *int64 `gorm:"column:resource_group_id"`
Enabled bool `gorm:"column:enabled"`
SortOrder int `gorm:"column:sort_order"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (YumiGiftChallengeTaskConfig) TableName() string { return "yumi_gift_challenge_task_config" }
// YumiGiftChallengeRankReward 把一个连续名次区间绑定到一个奖励组。
type YumiGiftChallengeRankReward struct {
ID int64 `gorm:"column:id;primaryKey"`
ActivityID int64 `gorm:"column:activity_id"`
PeriodType string `gorm:"column:period_type"`
StartRank int `gorm:"column:start_rank"`
EndRank int `gorm:"column:end_rank"`
ResourceGroupID int64 `gorm:"column:resource_group_id"`
RewardName string `gorm:"column:reward_name"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (YumiGiftChallengeRankReward) TableName() string { return "yumi_gift_challenge_rank_reward" }
// YumiGiftChallengeRewardSnapshot 是启用时冻结的完整奖励项,展示和实际发放均读取此表。
type YumiGiftChallengeRewardSnapshot struct {
ID int64 `gorm:"column:id;primaryKey"`
ActivityID int64 `gorm:"column:activity_id"`
ResourceGroupID int64 `gorm:"column:resource_group_id"`
RewardConfigID int64 `gorm:"column:reward_config_id"`
RewardType string `gorm:"column:reward_type"`
DetailType string `gorm:"column:detail_type"`
Content string `gorm:"column:content"`
Quantity int64 `gorm:"column:quantity"`
SortOrder int `gorm:"column:sort_order"`
Remark string `gorm:"column:remark"`
Cover string `gorm:"column:cover"`
SourceURL string `gorm:"column:source_url"`
DisplayName string `gorm:"column:display_name"`
CreateTime time.Time `gorm:"column:create_time"`
}
func (YumiGiftChallengeRewardSnapshot) TableName() string {
return "yumi_gift_challenge_reward_snapshot"
}
// YumiGiftChallengeGiftLedger 是原始送礼事件的活动账本,也是消费幂等边界。
type YumiGiftChallengeGiftLedger struct {
ID int64 `gorm:"column:id;primaryKey"`
ActivityID int64 `gorm:"column:activity_id"`
SourceEventTrackID string `gorm:"column:source_event_track_id"`
UserID int64 `gorm:"column:user_id"`
GiftID int64 `gorm:"column:gift_id"`
GiftTab string `gorm:"column:gift_tab"`
Amount Decimal24_2 `gorm:"column:amount;type:decimal(24,2)"`
EventTime time.Time `gorm:"column:event_time"`
StatDate string `gorm:"column:stat_date;type:date"`
CreateTime time.Time `gorm:"column:create_time"`
}
func (YumiGiftChallengeGiftLedger) TableName() string { return "yumi_gift_challenge_gift_ledger" }
// YumiGiftChallengeUserScore 保存活动总榜聚合,避免榜单查询扫描明细账本。
type YumiGiftChallengeUserScore struct {
ActivityID int64 `gorm:"column:activity_id;primaryKey"`
UserID int64 `gorm:"column:user_id;primaryKey"`
Score Decimal24_2 `gorm:"column:score;type:decimal(24,2)"`
ScoreReachedTime time.Time `gorm:"column:score_reached_time"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (YumiGiftChallengeUserScore) TableName() string { return "yumi_gift_challenge_user_score" }
// YumiGiftChallengeUserDailyScore 保存活动时区自然日榜聚合。
type YumiGiftChallengeUserDailyScore struct {
ActivityID int64 `gorm:"column:activity_id;primaryKey"`
StatDate string `gorm:"column:stat_date;type:date;primaryKey"`
UserID int64 `gorm:"column:user_id;primaryKey"`
Score Decimal24_2 `gorm:"column:score;type:decimal(24,2)"`
ScoreReachedTime time.Time `gorm:"column:score_reached_time"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (YumiGiftChallengeUserDailyScore) TableName() string {
return "yumi_gift_challenge_user_daily_score"
}
// YumiGiftChallengeUserTaskDaily 冻结用户当天任务目标和奖励归属,并记录首次领取 requestId。
type YumiGiftChallengeUserTaskDaily struct {
ID int64 `gorm:"column:id;primaryKey"`
ActivityID int64 `gorm:"column:activity_id"`
StatDate string `gorm:"column:stat_date;type:date"`
UserID int64 `gorm:"column:user_id"`
TaskConfigID int64 `gorm:"column:task_config_id"`
TaskCode string `gorm:"column:task_code"`
TaskType string `gorm:"column:task_type"`
TaskTitle string `gorm:"column:task_title"`
SortOrder int `gorm:"column:sort_order"`
TargetValue Decimal24_2 `gorm:"column:target_value;type:decimal(24,2)"`
ProgressValue Decimal24_2 `gorm:"column:progress_value;type:decimal(24,2)"`
CompletedTime *time.Time `gorm:"column:completed_time"`
ResourceGroupID *int64 `gorm:"column:resource_group_id"`
BusinessNo string `gorm:"column:business_no"`
ClaimRequestID string `gorm:"column:claim_request_id"`
DeliveryStatus string `gorm:"column:delivery_status"`
RetryCount int `gorm:"column:retry_count"`
FailureReason string `gorm:"column:failure_reason"`
ClaimedTime *time.Time `gorm:"column:claimed_time"`
DeliverTime *time.Time `gorm:"column:deliver_time"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (YumiGiftChallengeUserTaskDaily) TableName() string {
return "yumi_gift_challenge_user_task_daily"
}
// YumiGiftChallengePeriodSettlement 是包括空榜在内的周期门闩PROCESSING 后禁止迟到写入。
type YumiGiftChallengePeriodSettlement struct {
ID int64 `gorm:"column:id;primaryKey"`
ActivityID int64 `gorm:"column:activity_id"`
PeriodType string `gorm:"column:period_type"`
PeriodKey string `gorm:"column:period_key"`
StatDate *string `gorm:"column:stat_date;type:date"`
SnapshotDueTime time.Time `gorm:"column:snapshot_due_time"`
Status string `gorm:"column:status"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (YumiGiftChallengePeriodSettlement) TableName() string {
return "yumi_gift_challenge_period_settlement"
}
// YumiGiftChallengeSettlement 是已经冻结的获奖 parent先提交后再物化和发送奖励项。
type YumiGiftChallengeSettlement struct {
ID int64 `gorm:"column:id;primaryKey"`
ActivityID int64 `gorm:"column:activity_id"`
PeriodType string `gorm:"column:period_type"`
PeriodKey string `gorm:"column:period_key"`
StatDate *string `gorm:"column:stat_date;type:date"`
UserID int64 `gorm:"column:user_id"`
RankNo int `gorm:"column:rank_no"`
Score Decimal24_2 `gorm:"column:score;type:decimal(24,2)"`
RankRewardID int64 `gorm:"column:rank_reward_id"`
ResourceGroupID int64 `gorm:"column:resource_group_id"`
BusinessNo string `gorm:"column:business_no"`
DeliveryStatus string `gorm:"column:delivery_status"`
RetryCount int `gorm:"column:retry_count"`
FailureReason string `gorm:"column:failure_reason"`
DeliverTime *time.Time `gorm:"column:deliver_time"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (YumiGiftChallengeSettlement) TableName() string { return "yumi_gift_challenge_settlement" }
// YumiGiftChallengeDeliveryItem 是实际发奖最小幂等单元UNKNOWN 只允许人工核账处理。
type YumiGiftChallengeDeliveryItem struct {
ID int64 `gorm:"column:id;primaryKey"`
OwnerType string `gorm:"column:owner_type"`
OwnerID int64 `gorm:"column:owner_id"`
ActivityID int64 `gorm:"column:activity_id"`
UserID int64 `gorm:"column:user_id"`
RewardConfigID int64 `gorm:"column:reward_config_id"`
ResourceGroupID int64 `gorm:"column:resource_group_id"`
RewardType string `gorm:"column:reward_type"`
DetailType string `gorm:"column:detail_type"`
Content string `gorm:"column:content"`
Quantity int64 `gorm:"column:quantity"`
SortOrder int `gorm:"column:sort_order"`
Remark string `gorm:"column:remark"`
DeliveryStatus string `gorm:"column:delivery_status"`
RetryCount int `gorm:"column:retry_count"`
FailureReason string `gorm:"column:failure_reason"`
DeliverTime *time.Time `gorm:"column:deliver_time"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (YumiGiftChallengeDeliveryItem) TableName() string {
return "yumi_gift_challenge_delivery_item"
}

View File

@ -248,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(), mapToReportRequest(payload), raw))
writeBaishunJSON(c, http.StatusOK, baishunService.HandleReport(c.Request.Context(), payload, raw))
})
callbackGroup.POST("/balance-info", func(c *gin.Context) {
raw, payload := readRawPayload(c)
@ -338,16 +338,6 @@ 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{
@ -360,26 +350,6 @@ 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

@ -1,101 +0,0 @@
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

@ -1,253 +0,0 @@
package router
import (
"net/http"
"strconv"
"strings"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/service/gameking"
"github.com/gin-gonic/gin"
)
// registerGameKingRoutes 注册 Yumi H5、运营后台和外部 cron 内部触发接口。
func registerGameKingRoutes(engine *gin.Engine, cfg config.Config, javaClient authGateway, service *gameking.Service) {
if service == nil {
return
}
h5 := engine.Group("/app/h5/game-king/yumi")
h5.Use(authMiddleware(javaClient))
h5.GET("/detail", func(c *gin.Context) {
resp, err := service.Detail(c.Request.Context(), mustAuthUser(c), parseGameKingID(c.Query("activityId")))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
h5.GET("/me", func(c *gin.Context) {
resp, err := service.Me(c.Request.Context(), mustAuthUser(c), parseGameKingID(c.Query("activityId")))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
h5.POST("/draw", func(c *gin.Context) {
var req gameking.DrawRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, gameking.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.Draw(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
h5.GET("/ranking", func(c *gin.Context) {
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "30")))
resp, err := service.Ranking(c.Request.Context(), mustAuthUser(c), parseGameKingID(c.Query("activityId")), c.Query("rankingType"), c.Query("period"), limit)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
h5.GET("/draw-records", func(c *gin.Context) {
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
resp, err := service.DrawRecords(c.Request.Context(), mustAuthUser(c), parseGameKingID(c.Query("activityId")), limit)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
admin := engine.Group("/resident-activity/game-king")
admin.Use(consoleAuthMiddleware(javaClient), consoleMenuMiddleware(javaClient, "ResidentGameKingActivity"))
admin.GET("/list", func(c *gin.Context) {
resp, err := service.ListActivities(c.Request.Context())
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
admin.GET("/detail/:id", func(c *gin.Context) {
resp, err := service.ManageDetail(c.Request.Context(), parseGameKingID(c.Param("id")))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
admin.POST("/save", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:edit"), func(c *gin.Context) {
var req gameking.SaveActivityRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, gameking.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.SaveActivity(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
admin.PUT("/enable/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:enable"), func(c *gin.Context) {
raw, exists := c.GetQuery("enabled")
if !exists {
writeError(c, gameking.NewAppError(http.StatusBadRequest, "enabled_required", "enabled query is required"))
return
}
enabled, err := parseGameKingBool(raw, "enabled")
if err != nil {
writeError(c, err)
return
}
if err := service.EnableActivity(c.Request.Context(), parseGameKingID(c.Param("id")), enabled); err != nil {
writeError(c, err)
return
}
writeOK(c, gin.H{"success": true})
})
admin.GET("/prizes/:id", func(c *gin.Context) {
resp, err := service.ListPrizes(c.Request.Context(), parseGameKingID(c.Param("id")))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
admin.PUT("/prizes/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:edit"), func(c *gin.Context) {
var req gameking.SavePrizesRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, gameking.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
if err := service.SavePrizes(c.Request.Context(), parseGameKingID(c.Param("id")), req); err != nil {
writeError(c, err)
return
}
writeOK(c, gin.H{"success": true})
})
admin.GET("/rank-rewards/:id", func(c *gin.Context) {
resp, err := service.ListRankRewards(c.Request.Context(), parseGameKingID(c.Param("id")))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
admin.PUT("/rank-rewards/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:edit"), func(c *gin.Context) {
var req gameking.SaveRankRewardsRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, gameking.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
if err := service.SaveRankRewards(c.Request.Context(), parseGameKingID(c.Param("id")), req); err != nil {
writeError(c, err)
return
}
writeOK(c, gin.H{"success": true})
})
admin.GET("/draw-records/:id", func(c *gin.Context) {
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "100")))
resp, err := service.ManageDrawRecords(c.Request.Context(), parseGameKingID(c.Param("id")), c.Query("deliveryStatus"), limit)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
admin.POST("/draw/retry/:recordId", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:reconcile"), func(c *gin.Context) {
if err := service.RetryDraw(c.Request.Context(), parseGameKingID(c.Param("recordId"))); err != nil {
writeError(c, err)
return
}
writeOK(c, gin.H{"success": true})
})
admin.GET("/settlement-records/:id", func(c *gin.Context) {
resp, err := service.SettlementRecords(c.Request.Context(), parseGameKingID(c.Param("id")))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
admin.POST("/settlement/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:settle"), func(c *gin.Context) {
if err := service.Settle(c.Request.Context(), parseGameKingID(c.Param("id"))); err != nil {
writeError(c, err)
return
}
writeOK(c, gin.H{"success": true})
})
admin.POST("/settlement/retry/:recordId", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:reconcile"), func(c *gin.Context) {
if err := service.RetrySettlement(c.Request.Context(), parseGameKingID(c.Param("recordId"))); err != nil {
writeError(c, err)
return
}
writeOK(c, gin.H{"success": true})
})
admin.POST("/delivery-item/resolve/:itemId", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:reconcile"), func(c *gin.Context) {
raw, exists := c.GetQuery("delivered")
if !exists {
writeError(c, gameking.NewAppError(http.StatusBadRequest, "delivered_required", "delivered query is required"))
return
}
delivered, err := parseGameKingBool(raw, "delivered")
if err != nil {
writeError(c, err)
return
}
if err := service.ResolveUnknownDeliveryItem(c.Request.Context(), parseGameKingID(c.Param("itemId")), delivered); err != nil {
writeError(c, err)
return
}
writeOK(c, gin.H{"success": true})
})
admin.POST("/backfill/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:backfill"), func(c *gin.Context) {
lastID := parseGameKingID(c.DefaultQuery("lastId", "0"))
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "200")))
resp, err := service.Backfill(c.Request.Context(), parseGameKingID(c.Param("id")), lastID, limit)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
internal := engine.Group("/internal/game-king")
// 结算入口会冻结榜单并产生真实奖励副作用,必须与 Yumi 礼物挑战保持相同的
// fail-closed 边界:服务端未配置密钥时直接拒绝,不能沿用历史内部接口的空密钥兼容行为。
internal.Use(requiredInternalSecretMiddleware(cfg.HTTP.InternalCallbackSecret))
internal.POST("/settle-due", func(c *gin.Context) {
resp, err := service.SettleDue(c.Request.Context())
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
}
func parseGameKingID(value string) int64 {
parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return parsed
}
func parseGameKingBool(value, field string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "true", "1":
return true, nil
case "false", "0":
return false, nil
default:
// resolve delivered=false 会立即重发,因此不能把拼写错误或空值默认为 false。
return false, gameking.NewAppError(http.StatusBadRequest, field+"_invalid", field+" must be true, false, 1 or 0")
}
}

View File

@ -46,8 +46,6 @@ 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) {
@ -60,34 +58,6 @@ 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,6 @@
package router
import (
"errors"
"net/http"
"strings"
@ -12,7 +11,7 @@ import (
)
// registerGameProviderRoutes 注册统一游戏厂商 app 路由。
func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, registry *gameprovider.Registry, visibility *gameprovider.VisibilityPolicy) {
func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, registry *gameprovider.Registry) {
if registry == nil {
return
}
@ -20,15 +19,7 @@ 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) {
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"))
resp, err := registry.ListShortcutGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"))
if err != nil {
writeError(c, err)
return
@ -36,15 +27,7 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
writeOK(c, publicRoomGameItems(resp))
})
appGroup.GET("/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
}
resp, err := registry.ListRoomGames(c.Request.Context(), user, c.Query("roomId"), c.Query("category"))
resp, err := registry.ListRoomGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"), c.Query("category"))
if err != nil {
writeError(c, err)
return
@ -89,30 +72,14 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
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(), user, c.Query("roomId"))
resp, err := provider.ListShortcutGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"))
if err != nil {
writeError(c, err)
return
@ -120,19 +87,11 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
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(), user, c.Query("roomId"), c.Query("category"))
resp, err := provider.ListRoomGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"), c.Query("category"))
if err != nil {
writeError(c, err)
return
@ -161,7 +120,7 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := launchProviderGame(c, registry, provider, req)
resp, err := provider.LaunchGame(c.Request.Context(), mustAuthUser(c), req, resolveClientIP(c))
if err != nil {
writeError(c, err)
return
@ -187,33 +146,6 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
})
}
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)
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 {
@ -223,25 +155,6 @@ func resolveGameProvider(c *gin.Context, registry *gameprovider.Registry) (gamep
return provider, true
}
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
}
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"`
@ -398,14 +311,8 @@ func publicLaunch(resp *gameprovider.LaunchResponse) *publicLaunchResponse {
}
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

@ -1,18 +1,9 @@
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) {
@ -83,41 +74,6 @@ func TestPublicRoomGameListResponseMapsLingxianToLeaderForOldClients(t *testing.
}
}
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{
@ -185,325 +141,3 @@ func TestPublicLaunchMapsLingxianToLeaderForOldClients(t *testing.T) {
t.Fatalf("roomState.provider = %q, want LEADER", resp.RoomState.Provider)
}
}
func TestGameProviderRoutesReturnRoomListForTurkeyRegionUser(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: "土耳其"},
},
),
)
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) != 1 {
t.Fatalf("items = %d, want 1", len(payload.Body.Items))
}
if provider.listRoomCalls != 1 {
t.Fatalf("provider list calls = %d, want 1", 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: "土耳其"},
},
),
)
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
}
func (s routeStubVisibilityGateway) GetUserRegion(context.Context, int64, string) (integration.UserRegion, error) {
return s.region, 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

@ -1,115 +0,0 @@
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

@ -37,15 +37,6 @@ func registerManagerCenterRoutes(engine *gin.Engine, javaClient authGateway, ser
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 {
@ -69,29 +60,6 @@ func registerManagerCenterRoutes(engine *gin.Engine, javaClient authGateway, ser
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 {
@ -119,18 +87,4 @@ func registerManagerCenterRoutes(engine *gin.Engine, javaClient authGateway, ser
}
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

@ -2,7 +2,6 @@ package router
import (
"context"
"crypto/subtle"
"net/http"
"strings"
"time"
@ -20,14 +19,6 @@ type authGateway interface {
AuthenticateConsoleToken(ctx context.Context, authorization string) (integration.ConsoleAccount, error)
}
type consolePermissionGateway interface {
ListConsoleButtonAliases(ctx context.Context, authorization string) (map[string]struct{}, error)
}
type consoleMenuGateway interface {
HasConsoleMenuAlias(ctx context.Context, authorization, requiredAlias string) (bool, error)
}
// authMiddleware 校验用户 token并把认证后的用户信息写入上下文。
func authMiddleware(javaClient authGateway) gin.HandlerFunc {
return func(c *gin.Context) {
@ -82,58 +73,6 @@ func consoleAuthMiddleware(javaClient authGateway) gin.HandlerFunc {
}
}
// consolePermissionMiddleware 在写接口执行前读取 Java Console 的真实按钮权限,禁止只依赖前端隐藏按钮。
func consolePermissionMiddleware(javaClient authGateway, requiredAlias string) gin.HandlerFunc {
return func(c *gin.Context) {
gateway, ok := javaClient.(consolePermissionGateway)
if !ok {
writeError(c, common.NewAppError(http.StatusServiceUnavailable, "permission_gateway_unavailable", "console permission gateway is unavailable"))
c.Abort()
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
aliases, err := gateway.ListConsoleButtonAliases(ctx, strings.TrimSpace(c.GetHeader("Authorization")))
if err != nil {
writeError(c, common.NewAppError(http.StatusForbidden, "permission_check_failed", err.Error()))
c.Abort()
return
}
if _, allowed := aliases[requiredAlias]; !allowed {
writeError(c, common.NewAppError(http.StatusForbidden, "permission_denied", "required permission: "+requiredAlias))
c.Abort()
return
}
c.Next()
}
}
// consoleMenuMiddleware 校验页面菜单权限,所有管理端 GET 都不能只依赖前端路由可见性。
func consoleMenuMiddleware(javaClient authGateway, requiredAlias string) gin.HandlerFunc {
return func(c *gin.Context) {
gateway, ok := javaClient.(consoleMenuGateway)
if !ok {
writeError(c, common.NewAppError(http.StatusServiceUnavailable, "menu_gateway_unavailable", "console menu gateway is unavailable"))
c.Abort()
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
allowed, err := gateway.HasConsoleMenuAlias(ctx, strings.TrimSpace(c.GetHeader("Authorization")), requiredAlias)
if err != nil {
writeError(c, common.NewAppError(http.StatusForbidden, "menu_permission_check_failed", err.Error()))
c.Abort()
return
}
if !allowed {
writeError(c, common.NewAppError(http.StatusForbidden, "permission_denied", "required menu: "+requiredAlias))
c.Abort()
return
}
c.Next()
}
}
// internalSecretMiddleware 校验内部回调密钥。
func internalSecretMiddleware(secret string) gin.HandlerFunc {
return func(c *gin.Context) {
@ -153,29 +92,6 @@ func internalSecretMiddleware(secret string) gin.HandlerFunc {
}
}
// requiredInternalSecretMiddleware 用于会触发资金/奖励副作用的内部入口。
// 与兼容历史调用的 internalSecretMiddleware 不同,服务端未配置密钥时必须 fail-closed
// 否则错误的部署配置会把无鉴权请求直接放进结算状态机。
func requiredInternalSecretMiddleware(secret string) gin.HandlerFunc {
configuredSecret := strings.TrimSpace(secret)
return func(c *gin.Context) {
if configuredSecret == "" {
writeError(c, common.NewAppError(http.StatusServiceUnavailable, "internal_secret_not_configured", "internal callback secret is not configured"))
c.Abort()
return
}
token := strings.TrimSpace(c.GetHeader("X-Internal-Token"))
if subtle.ConstantTimeCompare([]byte(token), []byte(configuredSecret)) != 1 {
writeError(c, common.NewAppError(http.StatusUnauthorized, "invalid_internal_token", "internal token is invalid"))
c.Abort()
return
}
c.Next()
}
}
// mustAuthUser 从 Gin 上下文读取已经通过鉴权的用户。
func mustAuthUser(c *gin.Context) common.AuthUser {
value, exists := c.Get(authUserContextKey)
@ -196,9 +112,6 @@ 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

@ -4,8 +4,6 @@ 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"
@ -15,7 +13,6 @@ import (
func registerRegionIMGroupRoutes(
engine *gin.Engine,
cfg config.Config,
repository *repo.Repository,
javaClient authGateway,
service *regionimgroup.Service,
) {
@ -25,7 +22,7 @@ func registerRegionIMGroupRoutes(
registerRegionIMGroupAppRoutes(engine, javaClient, service)
registerRegionIMGroupInternalRoutes(engine, cfg, service)
registerRegionIMGroupAdminRoutes(engine, repository, javaClient, service)
registerRegionIMGroupAdminRoutes(engine, javaClient, service)
}
func registerRegionIMGroupAppRoutes(engine *gin.Engine, javaClient authGateway, service *regionimgroup.Service) {
@ -65,14 +62,9 @@ func registerRegionIMGroupInternalRoutes(engine *gin.Engine, cfg config.Config,
})
}
func registerRegionIMGroupAdminRoutes(engine *gin.Engine, repository *repo.Repository, javaClient authGateway, service *regionimgroup.Service) {
func registerRegionIMGroupAdminRoutes(engine *gin.Engine, javaClient authGateway, service *regionimgroup.Service) {
registerRegionIMGroupAdminGroup(engine.Group("/region/config"), javaClient, service)
registerRegionIMGroupAdminGroup(engine.Group("/resident-activity/voice-room-red-packet"), javaClient, service)
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) {
@ -112,39 +104,3 @@ func registerRegionIMGroupAdminGroup(group *gin.RouterGroup, javaClient authGate
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

@ -10,12 +10,9 @@ import (
"chatapp3-golang/internal/service/baishun"
"chatapp3-golang/internal/service/binancerecharge"
"chatapp3-golang/internal/service/errorlog"
"chatapp3-golang/internal/service/firstrechargereward"
"chatapp3-golang/internal/service/gameking"
"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"
@ -35,7 +32,6 @@ import (
"chatapp3-golang/internal/service/voiceroomrocket"
"chatapp3-golang/internal/service/weekstar"
"chatapp3-golang/internal/service/wheel"
"chatapp3-golang/internal/service/yumigiftchallenge"
"github.com/gin-gonic/gin"
)
@ -47,17 +43,13 @@ type Services struct {
Invite *invite.InviteService
Baishun *baishun.BaishunService
BinanceRecharge *binancerecharge.Service
FirstRechargeReward *firstrechargereward.Service
Lingxian *lingxian.Service
GameOpen *gameopen.GameOpenService
GameKing *gameking.Service
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
@ -72,7 +64,6 @@ type Services struct {
VoiceRoomRedPacket *voiceroomredpacket.Service
VoiceRoomRocket *voiceroomrocket.Service
WeekStar *weekstar.WeekStarService
YumiGiftChallenge *yumigiftchallenge.Service
}
// NewRouter 创建 Gin 路由,并按模块注册所有接口。
@ -91,28 +82,24 @@ func NewRouter(
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)
registerGameKingRoutes(engine, cfg, javaClient, services.GameKing)
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)
registerRegionIMGroupRoutes(engine, cfg, javaClient, services.RegionIMGroup)
registerVoiceRoomRedPacketRoutes(engine, javaClient, services.VoiceRoomRedPacket)
registerVoiceRoomRocketRoutes(engine, cfg, javaClient, services.VoiceRoomRocket)
registerWeekStarRoutes(engine, javaClient, services.WeekStar)
registerYumiGiftChallengeRoutes(engine, cfg, javaClient, services.YumiGiftChallenge)
registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward)
registerHostCenterRoutes(engine, services.HostCenter)
registerGameOpenRoutes(engine, cfg, services.GameOpen)
registerGameProviderRoutes(engine, javaClient, services.GameProviders, services.GameVisibility)
registerGameProviderRoutes(engine, javaClient, services.GameProviders)
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)

View File

@ -1,335 +0,0 @@
package router
import (
"net/http"
"strconv"
"strings"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/service/yumigiftchallenge"
"github.com/gin-gonic/gin"
)
const yumiGiftChallengeMenuAlias = "ResidentYumiGiftChallenge"
// registerYumiGiftChallengeRoutes 注册 Yumi H5 用户接口与 Console 配置、结算、核账接口。
func registerYumiGiftChallengeRoutes(engine *gin.Engine, cfg config.Config, javaClient authGateway, service *yumigiftchallenge.Service) {
if service == nil {
return
}
internal := engine.Group("/internal/yumi-gift-challenge")
// 结算会产生真实奖励副作用;密钥缺失也必须拒绝,不能沿用历史内部接口的空密钥兼容行为。
internal.Use(requiredInternalSecretMiddleware(cfg.HTTP.InternalCallbackSecret))
internal.POST("/settle-due", func(c *gin.Context) {
batch, err := parseOptionalInt(c.Query("batch"))
if err != nil {
writeError(c, err)
return
}
response, err := service.SettleDue(c.Request.Context(), batch)
if err != nil {
writeError(c, err)
return
}
writeOK(c, response)
})
h5 := engine.Group("/app/h5/gift-challenge/yumi")
h5.Use(authMiddleware(javaClient))
h5.GET("/detail", func(c *gin.Context) {
activityID, err := parseOptionalInt64(c.Query("activityId"))
if err != nil {
writeError(c, err)
return
}
response, err := service.GetDetail(c.Request.Context(), mustAuthUser(c), activityID)
if err != nil {
writeError(c, err)
return
}
writeOK(c, response)
})
h5.POST("/enter", func(c *gin.Context) {
var request yumigiftchallenge.EnterRequest
if err := c.ShouldBindJSON(&request); err != nil {
writeBadRequest(c, err)
return
}
response, err := service.Enter(c.Request.Context(), mustAuthUser(c), request)
if err != nil {
writeError(c, err)
return
}
writeOK(c, response)
})
h5.GET("/ranking", func(c *gin.Context) {
activityID, err := parseOptionalInt64(c.Query("activityId"))
if err != nil {
writeError(c, err)
return
}
limit, err := parseOptionalInt(c.Query("limit"))
if err != nil {
writeError(c, err)
return
}
period := firstNonEmptyString(c.Query("periodType"), c.Query("period"))
response, err := service.GetRanking(c.Request.Context(), mustAuthUser(c), activityID, period, c.Query("statDate"), limit)
if err != nil {
writeError(c, err)
return
}
writeOK(c, response)
})
h5.POST("/tasks/:taskCode/claim", func(c *gin.Context) {
var request yumigiftchallenge.ClaimRequest
if err := c.ShouldBindJSON(&request); err != nil {
writeBadRequest(c, err)
return
}
response, err := service.ClaimTask(c.Request.Context(), mustAuthUser(c), c.Param("taskCode"), request)
if err != nil {
writeError(c, err)
return
}
writeOK(c, response)
})
admin := engine.Group("/resident-activity/yumi-gift-challenge")
admin.Use(consoleAuthMiddleware(javaClient))
read := admin.Group("")
read.Use(consoleMenuMiddleware(javaClient, yumiGiftChallengeMenuAlias))
read.GET("/list", func(c *gin.Context) {
page, pageErr := parseOptionalInt(c.Query("page"))
size, sizeErr := parseOptionalInt(c.Query("size"))
if pageErr != nil || sizeErr != nil {
writeError(c, firstParseError(pageErr, sizeErr))
return
}
response, err := service.ListActivities(c.Request.Context(), c.Query("sysOrigin"), page, size)
if err != nil {
writeError(c, err)
return
}
writeOK(c, response)
})
read.GET("/detail/:id", func(c *gin.Context) {
activityID, ok := requirePathInt64(c, "id")
if !ok {
return
}
response, err := service.GetAdminDetail(c.Request.Context(), activityID)
if err != nil {
writeError(c, err)
return
}
writeOK(c, response)
})
read.GET("/tasks/:id", func(c *gin.Context) {
activityID, ok := requirePathInt64(c, "id")
if !ok {
return
}
response, err := service.GetAdminDetail(c.Request.Context(), activityID)
if err != nil {
writeError(c, err)
return
}
writeOK(c, response.Tasks)
})
read.GET("/rank-rewards/:id", func(c *gin.Context) {
activityID, ok := requirePathInt64(c, "id")
if !ok {
return
}
response, err := service.GetAdminDetail(c.Request.Context(), activityID)
if err != nil {
writeError(c, err)
return
}
writeOK(c, response.RankRewards)
})
read.GET("/ranking/:id", func(c *gin.Context) {
activityID, ok := requirePathInt64(c, "id")
if !ok {
return
}
limit, err := parseOptionalInt(c.Query("limit"))
if err != nil {
writeError(c, err)
return
}
period := firstNonEmptyString(c.Query("periodType"), c.Query("period"))
response, err := service.GetAdminRanking(c.Request.Context(), activityID, period, c.Query("statDate"), limit)
if err != nil {
writeError(c, err)
return
}
writeOK(c, response)
})
read.GET("/settlement-records/:id", func(c *gin.Context) {
activityID, ok := requirePathInt64(c, "id")
if !ok {
return
}
page, pageErr := parseOptionalInt(c.Query("page"))
size, sizeErr := parseOptionalInt(c.Query("size"))
if pageErr != nil || sizeErr != nil {
writeError(c, firstParseError(pageErr, sizeErr))
return
}
response, err := service.PageSettlementRecords(c.Request.Context(), activityID, c.Query("periodType"), c.Query("statDate"), c.Query("deliveryStatus"), page, size)
if err != nil {
writeError(c, err)
return
}
writeOK(c, response)
})
admin.POST("/save", consolePermissionMiddleware(javaClient, "resident-activity:yumi-gift-challenge:edit"), func(c *gin.Context) {
var request yumigiftchallenge.SaveRequest
if err := c.ShouldBindJSON(&request); err != nil {
writeBadRequest(c, err)
return
}
response, err := service.SaveActivity(c.Request.Context(), request)
if err != nil {
writeError(c, err)
return
}
writeOK(c, response)
})
admin.PUT("/enable/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-gift-challenge:enable"), func(c *gin.Context) {
activityID, ok := requirePathInt64(c, "id")
if !ok {
return
}
enabled, err := strconv.ParseBool(strings.TrimSpace(c.Query("enabled")))
if err != nil {
writeBadRequest(c, err)
return
}
if err := service.SetEnabled(c.Request.Context(), activityID, enabled); err != nil {
writeError(c, err)
return
}
writeOK(c, gin.H{"id": strconv.FormatInt(activityID, 10), "enabled": enabled})
})
admin.PUT("/tasks/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-gift-challenge:edit"), func(c *gin.Context) {
activityID, ok := requirePathInt64(c, "id")
if !ok {
return
}
var request yumigiftchallenge.TaskSaveRequest
if err := c.ShouldBindJSON(&request); err != nil {
writeBadRequest(c, err)
return
}
request.ActivityID = yumigiftchallenge.FlexibleInt64(activityID)
response, err := service.SaveTasks(c.Request.Context(), activityID, request)
if err != nil {
writeError(c, err)
return
}
writeOK(c, response)
})
admin.PUT("/rank-rewards/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-gift-challenge:edit"), func(c *gin.Context) {
activityID, ok := requirePathInt64(c, "id")
if !ok {
return
}
var request yumigiftchallenge.RankRewardSaveRequest
if err := c.ShouldBindJSON(&request); err != nil {
writeBadRequest(c, err)
return
}
request.ActivityID = yumigiftchallenge.FlexibleInt64(activityID)
response, err := service.SaveRankRewards(c.Request.Context(), activityID, request)
if err != nil {
writeError(c, err)
return
}
writeOK(c, response)
})
admin.POST("/settlement/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-gift-challenge:settle"), func(c *gin.Context) {
activityID, ok := requirePathInt64(c, "id")
if !ok {
return
}
var request yumigiftchallenge.SettlementRequest
if err := c.ShouldBindJSON(&request); err != nil {
writeBadRequest(c, err)
return
}
if err := service.Settle(c.Request.Context(), activityID, request); err != nil {
writeError(c, err)
return
}
writeOK(c, gin.H{"settled": true})
})
admin.POST("/delivery-item/resolve/:itemId", consolePermissionMiddleware(javaClient, "resident-activity:yumi-gift-challenge:reconcile"), func(c *gin.Context) {
itemID, ok := requirePathInt64(c, "itemId")
if !ok {
return
}
var request yumigiftchallenge.ResolveDeliveryRequest
if err := c.ShouldBindJSON(&request); err != nil {
writeBadRequest(c, err)
return
}
if err := service.ResolveUnknownDeliveryItem(c.Request.Context(), itemID, request.Delivered); err != nil {
writeError(c, err)
return
}
writeOK(c, gin.H{"id": strconv.FormatInt(itemID, 10), "delivered": request.Delivered})
})
}
func parseOptionalInt64(value string) (int64, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, nil
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed <= 0 {
return 0, yumigiftchallenge.NewAppError(http.StatusBadRequest, "invalid_id", "id must be a positive int64 string")
}
return parsed, nil
}
func parseOptionalInt(value string) (int, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, nil
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 0 {
return 0, yumigiftchallenge.NewAppError(http.StatusBadRequest, "invalid_number", "query number must be a non-negative integer")
}
return parsed, nil
}
func requirePathInt64(c *gin.Context, name string) (int64, bool) {
value, err := parseOptionalInt64(c.Param(name))
if err != nil || value <= 0 {
if err == nil {
err = yumigiftchallenge.NewAppError(http.StatusBadRequest, "invalid_id", name+" must be a positive int64 string")
}
writeError(c, err)
return 0, false
}
return value, true
}
func firstParseError(values ...error) error {
for _, err := range values {
if err != nil {
return err
}
}
return yumigiftchallenge.NewAppError(http.StatusBadRequest, "bad_request", "invalid query")
}
func writeBadRequest(c *gin.Context, err error) {
writeError(c, yumigiftchallenge.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
}

View File

@ -243,6 +243,21 @@ func (s *BaishunService) HandleChangeBalance(ctx context.Context, req BaishunCha
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{},
}
s.saveCallbackLog(ctx, "report", rawJSON, utils.MustJSONString(resp, ""), "", userID, stringPtr(orderID), stringPtr(gameRoundID), baishunCallbackStatusSuccess, 0, resp.Message)
return resp
}
func (s *BaishunService) reportGameConsumeTaskEvent(ctx context.Context, session model.BaishunLaunchSession, req BaishunChangeBalanceRequest) {
if s.taskReporter == nil || req.CurrencyDiff >= 0 {
return

View File

@ -49,7 +49,6 @@ func newTestBaishunService(t *testing.T) (*BaishunService, *gorm.DB) {
&model.BaishunProviderConfig{},
&model.BaishunGameCatalog{},
&model.BaishunLaunchSession{},
&model.BaishunCallbackLog{},
&model.BaishunRoomState{},
); err != nil {
t.Fatalf("auto migrate: %v", err)

View File

@ -1,291 +0,0 @@
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

@ -1,115 +0,0 @@
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

@ -13,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, user.RegionID, roomID, "")
items, err := s.listRoomGames(ctx, user.SysOrigin, roomID, "")
if err != nil {
return nil, err
}
@ -25,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, user.RegionID, roomID, category)
items, err := s.listRoomGames(ctx, user.SysOrigin, roomID, category)
if err != nil {
return nil, err
}
@ -59,7 +59,7 @@ func (s *BaishunService) GetRoomState(ctx context.Context, user AuthUser, roomID
}
// listRoomGames 联表读取房间可启动游戏,并按展示规则排序。
func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, regionID, roomID, category string) ([]RoomGameListItem, error) {
func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, roomID, category string) ([]RoomGameListItem, error) {
sysOrigin = normalizeAdminSysOrigin(sysOrigin)
profile, err := s.activeProfile(ctx, sysOrigin)
if err != nil {
@ -102,10 +102,6 @@ func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, regionID,
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)
}

View File

@ -3,7 +3,6 @@ package baishun
import (
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/service/highwin"
"context"
"net/http"
@ -67,7 +66,6 @@ type BaishunService struct {
java baishunGateway
httpClient *http.Client
taskReporter taskEventReporter
highWinBroadcaster highwin.RegionBroadcaster
}
// NewBaishunService 创建百顺接入服务实例。
@ -86,10 +84,6 @@ 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"`
@ -424,13 +418,6 @@ 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"`

View File

@ -1,327 +0,0 @@
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

@ -1,142 +0,0 @@
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

@ -1,139 +0,0 @@
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

@ -1,199 +0,0 @@
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

@ -1,249 +0,0 @@
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

@ -1,456 +0,0 @@
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

@ -1,112 +0,0 @@
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

@ -1,139 +0,0 @@
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

@ -1,180 +0,0 @@
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)
}

View File

@ -1,31 +0,0 @@
package firstrechargereward
import (
"fmt"
"math"
"strconv"
"strings"
)
func parseAmountCents(raw string) (int64, error) {
value := strings.TrimSpace(raw)
if value == "" {
return 0, nil
}
value = strings.TrimPrefix(value, "$")
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0, err
}
if parsed < 0 {
return 0, fmt.Errorf("amount must not be negative")
}
return int64(math.Round(parsed * 100)), nil
}
func formatAmountCents(cents int64) string {
if cents <= 0 {
return "0.00"
}
return fmt.Sprintf("%.2f", float64(cents)/100)
}

View File

@ -1,109 +0,0 @@
package firstrechargereward
import (
"context"
"strings"
"chatapp3-golang/internal/model"
)
// PageGrantRecords returns admin-facing first recharge reward grant records.
func (s *Service) PageGrantRecords(
ctx context.Context,
sysOrigin string,
status string,
userID int64,
cursor int,
limit int,
) (*GrantRecordPageResponse, error) {
sysOrigin = s.normalizeSysOrigin(sysOrigin)
status = strings.ToUpper(strings.TrimSpace(status))
cursor, limit = NormalizePage(cursor, limit)
query := s.db.WithContext(ctx).Model(&model.FirstRechargeRewardGrantRecord{}).
Where("sys_origin = ?", sysOrigin)
if status != "" {
query = query.Where("status = ?", status)
}
if userID > 0 {
query = query.Where("user_id = ?", userID)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, err
}
var rows []model.FirstRechargeRewardGrantRecord
if err := query.
Order("create_time desc").
Offset((cursor - 1) * limit).
Limit(limit).
Find(&rows).Error; err != nil {
return nil, err
}
rewardItems := s.loadRewardItemsForRecords(ctx, rows)
views := make([]GrantRecordView, 0, len(rows))
for _, row := range rows {
views = append(views, grantRecordViewFromModel(row, rewardItems[row.RewardGroupID]))
}
return &GrantRecordPageResponse{
Records: views,
Total: total,
Current: cursor,
Size: limit,
}, nil
}
func (s *Service) loadRewardItemsForRecords(ctx context.Context, rows []model.FirstRechargeRewardGrantRecord) map[int64][]RewardItem {
levels := make([]levelSnapshot, 0, len(rows))
seen := make(map[int64]struct{})
for _, row := range rows {
if row.RewardGroupID <= 0 {
continue
}
if _, exists := seen[row.RewardGroupID]; exists {
continue
}
seen[row.RewardGroupID] = struct{}{}
levels = append(levels, levelSnapshot{
RewardGroupID: row.RewardGroupID,
})
}
return s.loadRewardItemsMap(ctx, levels)
}
func grantRecordViewFromModel(row model.FirstRechargeRewardGrantRecord, rewardItems []RewardItem) GrantRecordView {
return GrantRecordView{
ID: row.ID,
EventID: row.EventID,
UserID: row.UserID,
Account: row.Account,
UserAvatar: row.UserAvatar,
UserNickname: row.UserNickname,
CountryCode: row.CountryCode,
CountryName: row.CountryName,
SysOrigin: row.SysOrigin,
RechargeAmount: formatAmountCents(row.RechargeAmountCents),
RechargeAmountCents: row.RechargeAmountCents,
RechargeThreshold: formatAmountCents(row.RechargeThresholdCents),
RechargeThresholdCents: row.RechargeThresholdCents,
Level: row.Level,
PayPlatform: row.PayPlatform,
PaymentMethod: row.PaymentMethod,
GoogleProductID: row.GoogleProductID,
SourceOrderID: row.SourceOrderID,
RewardGroupID: row.RewardGroupID,
RewardGroupName: row.RewardGroupName,
RewardItems: rewardItems,
Status: row.Status,
RewardGroupStatus: row.RewardGroupStatus,
RetryCount: row.RetryCount,
LastError: row.LastError,
EventTime: formatDateTime(row.EventTime),
SentAt: formatPtrDateTime(row.SentAt),
CreateTime: formatDateTime(row.CreateTime),
UpdateTime: formatDateTime(row.UpdateTime),
}
}

View File

@ -1,567 +0,0 @@
package firstrechargereward
import (
"context"
"encoding/json"
"testing"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestSaveConfigReturnsRewardItems(t *testing.T) {
service, gateway, _ := newTestService(t)
gateway.groups[1001] = testRewardGroup(1001, "首冲礼包")
req := mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI",
"enabled": true,
"minAppVersion": "1.5.0",
"levelConfigs": [
{"level": 1, "rechargeAmount": "9.99", "googleProductId": "gold_999", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
]
}`)
resp, err := service.SaveConfig(context.Background(), req)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
if !resp.Configured || !resp.Enabled {
t.Fatalf("SaveConfig() configured/enabled = %v/%v", resp.Configured, resp.Enabled)
}
if len(resp.LevelConfigs) != 1 {
t.Fatalf("level count = %d", len(resp.LevelConfigs))
}
level := resp.LevelConfigs[0]
if level.RechargeAmountCents != 999 || level.RewardGroupID != 1001 {
t.Fatalf("level payload = %+v", level)
}
if level.GoogleProductID != "gold_999" {
t.Fatalf("google product id = %q", level.GoogleProductID)
}
if len(level.RewardItems) != 1 || level.RewardItems[0].Name != "首冲礼包奖励" {
t.Fatalf("reward items = %+v", level.RewardItems)
}
}
func TestSaveConfigReturnsVipCoverFromSourceURL(t *testing.T) {
service, gateway, _ := newTestService(t)
gateway.groups[2001] = integration.RewardGroupDetail{
ID: integration.Int64Value(2001),
Name: "VIP礼包",
RewardConfigList: []integration.RewardGroupItem{
{
ID: integration.Int64Value(11),
Type: "PROPS",
DetailType: "NOBLE_VIP",
Name: "VIP 1",
Content: "2049402425177018400",
Quantity: integration.Int64Value(30),
Cover: "",
SourceURL: "https://example.com/vip-short-badge.png",
Remark: "30 天",
},
},
}
resp, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI",
"enabled": true,
"minAppVersion": "1.5.0",
"levelConfigs": [
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "2001", "rewardGroupName": "VIP礼包", "enabled": true}
]
}`))
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
if len(resp.LevelConfigs) != 1 || len(resp.LevelConfigs[0].RewardItems) != 1 {
t.Fatalf("reward items = %+v", resp.LevelConfigs)
}
if got := resp.LevelConfigs[0].RewardItems[0].Cover; got != "https://example.com/vip-short-badge.png" {
t.Fatalf("vip cover = %q", got)
}
if got := resp.LevelConfigs[0].RewardItems[0].Type; got != "NOBLE_VIP" {
t.Fatalf("vip type = %q", got)
}
}
func TestRewardItemsReturnConcretePropsType(t *testing.T) {
service, gateway, _ := newTestService(t)
gateway.groups[3001] = integration.RewardGroupDetail{
ID: integration.Int64Value(3001),
Name: "道具礼包",
RewardConfigList: []integration.RewardGroupItem{
{
ID: integration.Int64Value(21),
Type: "PROPS",
DetailType: "RIDE",
Name: "Luxury sports car",
Content: "2044620024357908482",
Quantity: integration.Int64Value(7),
Cover: "https://example.com/ride.png",
Remark: "Luxury sports car",
},
{
ID: integration.Int64Value(22),
Type: "PROPS",
Name: "VIP 3",
Content: "2050200000000000003",
Quantity: integration.Int64Value(3),
Cover: "https://example.com/vip3.png",
Remark: "VIP 3",
},
},
}
resp, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI",
"enabled": true,
"minAppVersion": "1.5.0",
"levelConfigs": [
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "3001", "rewardGroupName": "道具礼包", "enabled": true}
]
}`))
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
items := resp.LevelConfigs[0].RewardItems
if len(items) != 2 {
t.Fatalf("reward items = %+v", items)
}
if items[0].Type != "RIDE" {
t.Fatalf("ride type = %q", items[0].Type)
}
if items[1].Type != "NOBLE_VIP" {
t.Fatalf("vip fallback type = %q", items[1].Type)
}
}
func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
service, gateway, db := newTestService(t)
gateway.groups[1001] = testRewardGroup(1001, "首冲礼包")
gateway.profiles[123] = integration.UserProfile{
ID: integration.Int64Value(123),
Account: "100123",
UserNickname: "tester",
CountryCode: "SA",
}
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI",
"enabled": true,
"minAppVersion": "1.5.0",
"levelConfigs": [
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
]
}`))
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
unsupported, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:APPLE:1",
"sysOrigin": "LIKEI",
"userId": "123",
"amountCents": "999",
"payPlatform": "APPLE",
"paymentMethod": "APPLE",
"appVersion": "1.5.0",
"sourceOrderId": "1"
}`))
if err != nil {
t.Fatalf("unsupported ProcessRechargeEvent() error = %v", err)
}
if unsupported.Processed || unsupported.Reason != "unsupported_payment_method" {
t.Fatalf("unsupported response = %+v", unsupported)
}
lowAmount, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:GOOGLE:LOW",
"sysOrigin": "LIKEI",
"userId": "123",
"amountCents": "499",
"payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE",
"appVersion": "1.5.0",
"sourceOrderId": "LOW"
}`))
if err != nil {
t.Fatalf("low amount ProcessRechargeEvent() error = %v", err)
}
if lowAmount.Processed || lowAmount.Reason != "level_not_matched" {
t.Fatalf("low amount response = %+v", lowAmount)
}
granted, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:GOOGLE:OK",
"sysOrigin": "LIKEI",
"userId": "123",
"amountCents": "999",
"payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE",
"appVersion": "1.5.0",
"sourceOrderId": "OK",
"occurredAt": "2026-05-21T12:00:00Z"
}`))
if err != nil {
t.Fatalf("grant ProcessRechargeEvent() error = %v", err)
}
if !granted.Processed || granted.Status != statusSuccess || granted.Level != 1 {
t.Fatalf("grant response = %+v", granted)
}
if len(gateway.rewardRequests) != 1 {
t.Fatalf("reward request count = %d", len(gateway.rewardRequests))
}
if got := gateway.rewardRequests[0].Origin; got != firstRechargeRewardOrigin {
t.Fatalf("reward origin = %s", got)
}
if len(gateway.notices) != 1 || gateway.notices[0].NoticeType != firstRechargeRewardNoticeType {
t.Fatalf("notices = %+v", gateway.notices)
}
duplicate, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:GOOGLE:OK",
"sysOrigin": "LIKEI",
"userId": "123",
"amountCents": "999",
"payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE",
"appVersion": "1.5.0",
"sourceOrderId": "OK"
}`))
if err != nil {
t.Fatalf("duplicate ProcessRechargeEvent() error = %v", err)
}
if !duplicate.Processed || duplicate.Status != statusSuccess {
t.Fatalf("duplicate response = %+v", duplicate)
}
if len(gateway.rewardRequests) != 1 {
t.Fatalf("duplicate reward request count = %d", len(gateway.rewardRequests))
}
thirdPartyUnknownPlatform, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:THIRD_PARTY:CUSTOM",
"sysOrigin": "LIKEI",
"userId": "456",
"amountCents": "999",
"payPlatform": "CUSTOM_PAY",
"paymentMethod": "THIRD_PARTY",
"appVersion": "1.5.1",
"sourceOrderId": "CUSTOM"
}`))
if err != nil {
t.Fatalf("third party ProcessRechargeEvent() error = %v", err)
}
if !thirdPartyUnknownPlatform.Processed || thirdPartyUnknownPlatform.Status != statusSuccess {
t.Fatalf("third party response = %+v", thirdPartyUnknownPlatform)
}
if len(gateway.rewardRequests) != 2 {
t.Fatalf("third party reward request count = %d", len(gateway.rewardRequests))
}
googleDifferentPlatform, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:GOOGLE:CUSTOM",
"sysOrigin": "LIKEI",
"userId": "789",
"amountCents": "999",
"payPlatform": "CUSTOM_GOOGLE_GATEWAY",
"paymentMethod": "GOOGLE",
"appVersion": "1.5.1",
"sourceOrderId": "CUSTOM_GOOGLE"
}`))
if err != nil {
t.Fatalf("google ProcessRechargeEvent() error = %v", err)
}
if !googleDifferentPlatform.Processed || googleDifferentPlatform.Status != statusSuccess {
t.Fatalf("google response = %+v", googleDifferentPlatform)
}
if len(gateway.rewardRequests) != 3 {
t.Fatalf("google reward request count = %d", len(gateway.rewardRequests))
}
var count int64
if err := db.Model(&model.FirstRechargeRewardGrantRecord{}).Count(&count).Error; err != nil {
t.Fatalf("count grant records: %v", err)
}
if count != 3 {
t.Fatalf("grant record count = %d", count)
}
}
func TestProcessRechargeEventStartsCountingAtSupportedAppVersion(t *testing.T) {
service, gateway, db := newTestService(t)
gateway.groups[1001] = testRewardGroup(1001, "首冲礼包")
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI",
"enabled": true,
"minAppVersion": "1.5.0",
"levelConfigs": [
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
]
}`))
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
oldVersion, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:GOOGLE:OLD_VERSION",
"sysOrigin": "LIKEI",
"userId": "123",
"amountCents": "999",
"payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE",
"appVersion": "1.4.9",
"sourceOrderId": "OLD_VERSION"
}`))
if err != nil {
t.Fatalf("old version ProcessRechargeEvent() error = %v", err)
}
if oldVersion.Processed || oldVersion.Reason != "app_version_not_supported" {
t.Fatalf("old version response = %+v", oldVersion)
}
missingVersion, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:GOOGLE:MISSING_VERSION",
"sysOrigin": "LIKEI",
"userId": "123",
"amountCents": "999",
"payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE",
"sourceOrderId": "MISSING_VERSION"
}`))
if err != nil {
t.Fatalf("missing version ProcessRechargeEvent() error = %v", err)
}
if missingVersion.Processed || missingVersion.Reason != "app_version_not_supported" {
t.Fatalf("missing version response = %+v", missingVersion)
}
var count int64
if err := db.Model(&model.FirstRechargeRewardGrantRecord{}).Count(&count).Error; err != nil {
t.Fatalf("count grant records after unsupported versions: %v", err)
}
if count != 0 {
t.Fatalf("grant record count after unsupported versions = %d", count)
}
supported, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:GOOGLE:SUPPORTED_VERSION",
"sysOrigin": "LIKEI",
"userId": "123",
"amountCents": "999",
"payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE",
"appVersion": "1.5.0",
"sourceOrderId": "SUPPORTED_VERSION"
}`))
if err != nil {
t.Fatalf("supported version ProcessRechargeEvent() error = %v", err)
}
if !supported.Processed || supported.Status != statusSuccess {
t.Fatalf("supported version response = %+v", supported)
}
if err := db.Model(&model.FirstRechargeRewardGrantRecord{}).Count(&count).Error; err != nil {
t.Fatalf("count grant records after supported version: %v", err)
}
if count != 1 {
t.Fatalf("grant record count after supported version = %d", count)
}
}
func TestFirstRechargeRewardAppVersionSupported(t *testing.T) {
tests := []struct {
name string
appVersion string
want bool
}{
{name: "missing", appVersion: "", want: false},
{name: "below", appVersion: "1.4.9", want: false},
{name: "equal", appVersion: "1.5.0", want: true},
{name: "short equal", appVersion: "1.5", want: true},
{name: "above", appVersion: "1.5.1", want: true},
{name: "suffix", appVersion: "1.5.0+100", want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isFirstRechargeRewardAppVersionSupported(tt.appVersion, "1.5.0"); got != tt.want {
t.Fatalf("isFirstRechargeRewardAppVersionSupported(%q) = %v, want %v", tt.appVersion, got, tt.want)
}
})
}
}
func TestHomeRequiresConfiguredMinAppVersion(t *testing.T) {
service, gateway, _ := newTestService(t)
gateway.groups[1001] = testRewardGroup(1001, "首冲礼包")
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI",
"enabled": true,
"minAppVersion": "2.0.0",
"levelConfigs": [
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
]
}`))
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
oldVersion, err := service.GetHome(context.Background(), AuthUser{UserID: 123, SysOrigin: "LIKEI"}, "1.9.9")
if err != nil {
t.Fatalf("old version GetHome() error = %v", err)
}
if oldVersion.Enabled || oldVersion.ActivityStatus != activityStatusDisabled {
t.Fatalf("old version home = %+v", oldVersion)
}
supported, err := service.GetHome(context.Background(), AuthUser{UserID: 123, SysOrigin: "LIKEI"}, "2.0.0")
if err != nil {
t.Fatalf("supported GetHome() error = %v", err)
}
if !supported.Enabled || supported.ActivityStatus != activityStatusOngoing {
t.Fatalf("supported home = %+v", supported)
}
}
func TestProcessRechargeEventMatchesGoogleProductIDBeforeAmount(t *testing.T) {
service, gateway, _ := newTestService(t)
gateway.groups[1001] = testRewardGroup(1001, "首冲礼包")
gateway.groups[1002] = testRewardGroup(1002, "Google 首冲礼包")
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI",
"enabled": true,
"minAppVersion": "1.5.0",
"levelConfigs": [
{"level": 1, "rechargeAmount": "9.99", "googleProductId": "gold_999", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true},
{"level": 2, "rechargeAmount": "19.99", "googleProductId": "first_recharge_google_1999", "rewardGroupId": "1002", "rewardGroupName": "Google 首冲礼包", "enabled": true}
]
}`))
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
granted, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:GOOGLE:PRODUCT_ID",
"sysOrigin": "LIKEI",
"userId": "123",
"amountCents": "999",
"payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE",
"googleProductId": "first_recharge_google_1999",
"appVersion": "1.5.0",
"sourceOrderId": "PRODUCT_ID"
}`))
if err != nil {
t.Fatalf("product id ProcessRechargeEvent() error = %v", err)
}
if !granted.Processed || granted.Status != statusSuccess || granted.Level != 2 {
t.Fatalf("product id response = %+v", granted)
}
if len(gateway.rewardRequests) != 1 || gateway.rewardRequests[0].SourceGroupID != 1002 {
t.Fatalf("reward requests = %+v", gateway.rewardRequests)
}
}
func newTestService(t *testing.T) (*Service, *fakeGateway, *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.FirstRechargeRewardConfig{},
&model.FirstRechargeRewardLevel{},
&model.FirstRechargeRewardGrantRecord{},
); err != nil {
t.Fatalf("migrate sqlite: %v", err)
}
gateway := &fakeGateway{
groups: map[int64]integration.RewardGroupDetail{},
profiles: map[int64]integration.UserProfile{},
}
service := NewService(config.Config{
FirstRechargeReward: config.FirstRechargeRewardConfig{
DefaultSysOrigin: "LIKEI",
},
}, db, gateway)
return service, gateway, db
}
func mustDecodeSaveConfigRequest(t *testing.T, raw string) SaveConfigRequest {
t.Helper()
var req SaveConfigRequest
if err := json.Unmarshal([]byte(raw), &req); err != nil {
t.Fatalf("decode SaveConfigRequest: %v", err)
}
return req
}
func mustDecodeRechargeEvent(t *testing.T, raw string) RechargeEvent {
t.Helper()
var event RechargeEvent
if err := json.Unmarshal([]byte(raw), &event); err != nil {
t.Fatalf("decode RechargeEvent: %v", err)
}
return event
}
func testRewardGroup(id int64, name string) integration.RewardGroupDetail {
return integration.RewardGroupDetail{
ID: integration.Int64Value(id),
Name: name,
RewardConfigList: []integration.RewardGroupItem{
{
ID: integration.Int64Value(11),
Type: "PROPS",
DetailType: "RIDE",
Name: name + "奖励",
Content: "10001",
Quantity: integration.Int64Value(1),
Cover: "https://example.com/reward.png",
},
},
}
}
type fakeGateway struct {
groups map[int64]integration.RewardGroupDetail
profiles map[int64]integration.UserProfile
rewardRequests []integration.SendActivityRewardRequest
notices []integration.OfficialNoticeCustomizeRequest
cacheRemoved []int64
}
func (g *fakeGateway) GetRewardGroupDetail(_ context.Context, groupID int64) (integration.RewardGroupDetail, error) {
if group, ok := g.groups[groupID]; ok {
return group, nil
}
return testRewardGroup(groupID, "默认奖励组"), nil
}
func (g *fakeGateway) GetUserProfile(_ context.Context, userID int64) (integration.UserProfile, error) {
if profile, ok := g.profiles[userID]; ok {
return profile, nil
}
return integration.UserProfile{ID: integration.Int64Value(userID)}, nil
}
func (g *fakeGateway) SendActivityReward(_ context.Context, req integration.SendActivityRewardRequest) error {
g.rewardRequests = append(g.rewardRequests, req)
return nil
}
func (g *fakeGateway) SendOfficialNoticeCustomize(_ context.Context, req integration.OfficialNoticeCustomizeRequest) error {
g.notices = append(g.notices, req)
return nil
}
func (g *fakeGateway) RemoveUserProfileCacheAll(_ context.Context, userID int64) error {
g.cacheRemoved = append(g.cacheRemoved, userID)
return nil
}

View File

@ -1,372 +0,0 @@
package firstrechargereward
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
rmq "github.com/apache/rocketmq-clients/golang/v5"
"gorm.io/gorm"
)
const (
statusPending = "PENDING"
statusSuccess = "SUCCESS"
statusFailed = "FAILED"
paymentMethodGoogle = "GOOGLE"
paymentMethodThirdParty = "THIRD_PARTY"
firstRechargeRewardOrigin = "FIRST_CHARGE_REWARD"
firstRechargeRewardNoticeType = "FIRST_RECHARGE_REWARD_GRANTED"
firstRechargeRewardNoticeTitle = "First recharge reward"
firstRechargeRewardNoticeContent = "Reward granted"
)
type AppError = common.AppError
type AuthUser = common.AuthUser
var NewAppError = common.NewAppError
type dbHandle interface {
WithContext(ctx context.Context) *gorm.DB
}
type gateway interface {
GetRewardGroupDetail(ctx context.Context, groupID int64) (integration.RewardGroupDetail, error)
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error)
SendActivityReward(ctx context.Context, req integration.SendActivityRewardRequest) error
SendOfficialNoticeCustomize(ctx context.Context, req integration.OfficialNoticeCustomizeRequest) error
RemoveUserProfileCacheAll(ctx context.Context, userID int64) error
}
// Service owns first recharge reward config, MQ consumption, and reward dispatch.
type Service struct {
cfg config.Config
db dbHandle
java gateway
rocketConsumer rmq.PushConsumer
}
func NewService(cfg config.Config, db dbHandle, java gateway) *Service {
return &Service{cfg: cfg, db: db, java: java}
}
type LevelPayload struct {
ID int64 `json:"id,string"`
Level int `json:"level"`
RechargeAmount string `json:"rechargeAmount"`
RechargeAmountCents int64 `json:"rechargeAmountCents"`
GoogleProductID string `json:"googleProductId,omitempty"`
RewardGroupID int64 `json:"rewardGroupId,string"`
RewardGroupName string `json:"rewardGroupName,omitempty"`
RewardItems []RewardItem `json:"rewardItems"`
Enabled bool `json:"enabled"`
Reached bool `json:"reached"`
Current bool `json:"current"`
}
type LevelInput struct {
ID flexibleInt64 `json:"id"`
Level int `json:"level"`
RechargeAmount flexibleAmount `json:"rechargeAmount"`
RechargeAmountCents int64 `json:"rechargeAmountCents"`
GoogleProductID string `json:"googleProductId"`
RewardGroupID flexibleInt64 `json:"rewardGroupId"`
RewardGroupName string `json:"rewardGroupName"`
Enabled bool `json:"enabled"`
}
type ConfigResponse struct {
Configured bool `json:"configured"`
ID int64 `json:"id,string"`
SysOrigin string `json:"sysOrigin"`
Enabled bool `json:"enabled"`
MinAppVersion string `json:"minAppVersion,omitempty"`
UpdateTime string `json:"updateTime,omitempty"`
LevelConfigs []LevelPayload `json:"levelConfigs"`
}
type SaveConfigRequest struct {
ID flexibleInt64 `json:"id"`
SysOrigin string `json:"sysOrigin"`
Enabled bool `json:"enabled"`
MinAppVersion string `json:"minAppVersion"`
LevelConfigs []LevelInput `json:"levelConfigs"`
}
type HomeResponse struct {
Configured bool `json:"configured"`
Enabled bool `json:"enabled"`
ActivityStatus string `json:"activityStatus"`
UserID int64 `json:"userId,string"`
SysOrigin string `json:"sysOrigin"`
HasRewardRecord bool `json:"hasRewardRecord"`
RewardStatus string `json:"rewardStatus,omitempty"`
Rewarded bool `json:"rewarded"`
MinAppVersion string `json:"minAppVersion,omitempty"`
MatchedLevel int `json:"matchedLevel,omitempty"`
RechargeAmount string `json:"rechargeAmount,omitempty"`
RechargeAmountCents int64 `json:"rechargeAmountCents,omitempty"`
RewardGroupID int64 `json:"rewardGroupId,string,omitempty"`
RewardGroupName string `json:"rewardGroupName,omitempty"`
RewardItems []RewardItem `json:"rewardItems,omitempty"`
LevelConfigs []LevelPayload `json:"levelConfigs"`
}
type GrantRecordPageResponse struct {
Records []GrantRecordView `json:"records"`
Total int64 `json:"total"`
Current int `json:"current"`
Size int `json:"size"`
}
type GrantRecordView struct {
ID int64 `json:"id,string"`
EventID string `json:"eventId"`
UserID int64 `json:"userId,string"`
Account string `json:"account,omitempty"`
UserAvatar string `json:"userAvatar,omitempty"`
UserNickname string `json:"userNickname,omitempty"`
CountryCode string `json:"countryCode,omitempty"`
CountryName string `json:"countryName,omitempty"`
SysOrigin string `json:"sysOrigin"`
RechargeAmount string `json:"rechargeAmount"`
RechargeAmountCents int64 `json:"rechargeAmountCents"`
RechargeThreshold string `json:"rechargeThreshold"`
RechargeThresholdCents int64 `json:"rechargeThresholdCents"`
Level int `json:"level"`
PayPlatform string `json:"payPlatform"`
PaymentMethod string `json:"paymentMethod"`
GoogleProductID string `json:"googleProductId,omitempty"`
SourceOrderID string `json:"sourceOrderId,omitempty"`
RewardGroupID int64 `json:"rewardGroupId,string"`
RewardGroupName string `json:"rewardGroupName,omitempty"`
RewardItems []RewardItem `json:"rewardItems"`
Status string `json:"status"`
RewardGroupStatus string `json:"rewardGroupStatus"`
RetryCount int `json:"retryCount"`
LastError string `json:"lastError,omitempty"`
EventTime string `json:"eventTime,omitempty"`
SentAt string `json:"sentAt,omitempty"`
CreateTime string `json:"createTime,omitempty"`
UpdateTime string `json:"updateTime,omitempty"`
}
type RewardItem struct {
ID int64 `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
Quantity int64 `json:"quantity"`
Cover string `json:"cover"`
Remark string `json:"remark,omitempty"`
}
type RechargeEvent struct {
EventID string `json:"eventId"`
SysOrigin string `json:"sysOrigin"`
UserID flexibleInt64 `json:"userId"`
AmountCents flexibleInt64 `json:"amountCents"`
Amount flexibleString `json:"amount"`
AmountUSD flexibleString `json:"amountUsd"`
USDAmount flexibleString `json:"usdAmount"`
Currency string `json:"currency"`
PayPlatform string `json:"payPlatform"`
PaymentMethod string `json:"paymentMethod"`
GoogleProductID string `json:"googleProductId"`
ProductID flexibleString `json:"productId"`
AppVersion flexibleString `json:"appVersion"`
ClientVersion flexibleString `json:"clientVersion"`
Version flexibleString `json:"version"`
ReqVersion flexibleString `json:"reqVersion"`
SourceOrderID flexibleString `json:"sourceOrderId"`
OrderID flexibleString `json:"orderId"`
OccurredAt flexibleString `json:"occurredAt"`
Payload json.RawMessage `json:"payload,omitempty"`
}
type ProcessRechargeResponse struct {
Processed bool `json:"processed"`
Reason string `json:"reason,omitempty"`
RecordID int64 `json:"recordId,string,omitempty"`
Status string `json:"status,omitempty"`
Level int `json:"level,omitempty"`
}
type configBundle struct {
Config *configSnapshot
Levels []levelSnapshot
}
type configSnapshot struct {
ID int64
SysOrigin string
Enabled bool
MinAppVersion string
UpdateTime time.Time
}
type levelSnapshot struct {
ID int64
Level int
RechargeAmountCents int64
GoogleProductID string
RewardGroupID int64
RewardGroupName string
Enabled bool
}
type normalizedRechargeEvent struct {
EventID string
SysOrigin string
UserID int64
AmountCents int64
Currency string
PayPlatform string
PaymentMethod string
AppVersion string
GoogleProductID string
SourceOrderID string
OccurredAt time.Time
}
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)
if raw == "" {
*v = 0
return nil
}
}
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return fmt.Errorf("invalid int64 value: %w", err)
}
*v = flexibleInt64(parsed)
return nil
}
func (v flexibleInt64) Int64() int64 { return int64(v) }
func (v flexibleInt64) MarshalJSON() ([]byte, error) {
return json.Marshal(strconv.FormatInt(int64(v), 10))
}
type flexibleString string
func (v *flexibleString) UnmarshalJSON(data []byte) error {
raw := strings.TrimSpace(string(data))
if raw == "" || raw == "null" {
*v = ""
return nil
}
if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) {
unquoted, err := strconv.Unquote(raw)
if err != nil {
return err
}
*v = flexibleString(strings.TrimSpace(unquoted))
return nil
}
*v = flexibleString(raw)
return nil
}
func (v flexibleString) String() string {
return strings.TrimSpace(string(v))
}
type flexibleAmount int64
func (v *flexibleAmount) 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)
}
cents, err := parseAmountCents(raw)
if err != nil {
return err
}
*v = flexibleAmount(cents)
return nil
}
func (v flexibleAmount) Cents() int64 {
return int64(v)
}
func ParseInt64(raw string) int64 {
value, _ := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
return value
}
func NormalizePage(cursor int, limit int) (int, int) {
if cursor <= 0 {
cursor = 1
}
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
return cursor, limit
}
func (s *Service) normalizeSysOrigin(sysOrigin string) string {
sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin))
if sysOrigin != "" {
return sysOrigin
}
if fallback := strings.ToUpper(strings.TrimSpace(s.cfg.FirstRechargeReward.DefaultSysOrigin)); fallback != "" {
return fallback
}
if fallback := strings.ToUpper(strings.TrimSpace(s.cfg.RechargeReward.DefaultSysOrigin)); fallback != "" {
return fallback
}
if fallback := strings.ToUpper(strings.TrimSpace(s.cfg.WeekStar.DefaultSysOrigin)); fallback != "" {
return fallback
}
return "LIKEI"
}
func requireUser(user AuthUser) (string, error) {
if user.UserID <= 0 {
return "", NewAppError(http.StatusUnauthorized, "invalid_user", "user is required")
}
sysOrigin := strings.ToUpper(strings.TrimSpace(user.SysOrigin))
if sysOrigin == "" {
return "", NewAppError(http.StatusUnauthorized, "invalid_sys_origin", "authenticated sysOrigin is required")
}
return sysOrigin, nil
}

View File

@ -1,79 +0,0 @@
package firstrechargereward
import (
"strconv"
"strings"
)
const defaultFirstRechargeRewardMinAppVersion = "1.5.0"
func isFirstRechargeRewardAppVersionSupported(appVersion string, minAppVersion string) bool {
appVersion = strings.TrimSpace(appVersion)
minAppVersion = strings.TrimSpace(minAppVersion)
if minAppVersion == "" {
minAppVersion = defaultFirstRechargeRewardMinAppVersion
}
if appVersion == "" {
return false
}
return compareAppVersion(appVersion, minAppVersion) >= 0
}
func compareAppVersion(left, right string) int {
leftParts := parseAppVersion(left)
rightParts := parseAppVersion(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 parseAppVersion(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
}

View File

@ -1,534 +0,0 @@
package gameking
import (
"context"
"errors"
"net/http"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ListActivities 返回后台活动列表,活动数量很小,避免引入无意义分页状态。
func (s *Service) ListActivities(ctx context.Context) ([]ActivityView, error) {
var rows []model.YumiGameKingActivity
if err := s.db.WithContext(ctx).Order("id DESC").Find(&rows).Error; err != nil {
return nil, err
}
result := make([]ActivityView, 0, len(rows))
for _, row := range rows {
result = append(result, activityView(row))
}
return result, nil
}
// ManageDetail 返回后台活动及其七奖项、六排名档完整配置。
func (s *Service) ManageDetail(ctx context.Context, activityID int64) (*DetailResponse, error) {
activity, err := s.loadActivity(ctx, activityID)
if err != nil {
return nil, err
}
return s.buildDetail(ctx, activity)
}
// Detail 返回当前登录用户所属平台可见的活动详情。
func (s *Service) Detail(ctx context.Context, user AuthUser, activityID int64) (*DetailResponse, error) {
activity, err := s.resolveAppActivity(ctx, normalizeSysOrigin(user.SysOrigin), activityID, time.Now())
if err != nil {
return nil, err
}
return s.buildDetail(ctx, activity)
}
func (s *Service) buildDetail(ctx context.Context, activity model.YumiGameKingActivity) (*DetailResponse, error) {
prizes, err := s.loadPrizes(ctx, activity.ID)
if err != nil {
return nil, err
}
rankRewards, err := s.loadRankRewards(ctx, activity.ID)
if err != nil {
return nil, err
}
prizeViews := make([]PrizeView, 0, len(prizes))
for _, row := range prizes {
prizeViews = append(prizeViews, prizeView(row))
}
rankViews := make([]RankRewardView, 0, len(rankRewards))
for _, row := range rankRewards {
rankViews = append(rankViews, rankRewardView(row))
}
now := time.Now()
return &DetailResponse{
Activity: activityView(activity),
Prizes: prizeViews,
RankRewards: rankViews,
SupportedRankingTypes: []string{rankingTypeTycoon},
SupportedRankingPeriods: []string{"DAILY", rankingPeriodOverall},
ActivityStatus: activityStatus(activity, now),
ServerTime: now.UnixMilli(),
}, nil
}
// SaveActivity 新增或修改活动。首次启用前会重新读取全部奖励组并冻结展示快照。
func (s *Service) SaveActivity(ctx context.Context, req SaveActivityRequest) (*ActivityView, error) {
normalized, err := normalizeActivityInput(req)
if err != nil {
return nil, err
}
var existing *model.YumiGameKingActivity
if normalized.ID.Int64() > 0 {
row, loadErr := s.loadActivity(ctx, normalized.ID.Int64())
if loadErr != nil {
return nil, loadErr
}
existing = &row
}
var normalizedPrizes []normalizedPrize
if normalized.Prizes != nil {
normalizedPrizes, err = s.normalizePrizes(ctx, normalized.SysOrigin, normalized.Prizes)
if err != nil {
return nil, err
}
}
var normalizedRanks []normalizedRankReward
if normalized.RankRewards != nil {
normalizedRanks, err = s.normalizeRankRewards(ctx, normalized.SysOrigin, normalized.RankRewards)
if err != nil {
return nil, err
}
}
// 只在“首次启用/重新启用”的开始前状态补齐并刷新子配置。已经开始且仍为 enabled
// 的元数据编辑绝不能走这条路径,否则会重建奖品 ID、刷新快照并把已消耗库存写回旧值。
needsEnableValidation := normalized.Enabled && (existing == nil || !existing.Enabled)
if needsEnableValidation {
if normalizedPrizes == nil {
if existing == nil {
return nil, NewAppError(http.StatusBadRequest, "prizes_required", "seven prizes are required before enabling")
}
inputs, loadErr := s.currentPrizeInputs(ctx, existing.ID)
if loadErr != nil {
return nil, loadErr
}
normalizedPrizes, err = s.normalizePrizes(ctx, normalized.SysOrigin, inputs)
if err != nil {
return nil, err
}
}
if normalizedRanks == nil {
if existing == nil {
return nil, NewAppError(http.StatusBadRequest, "rank_rewards_required", "six rank reward tiers are required before enabling")
}
inputs, loadErr := s.currentRankInputs(ctx, existing.ID)
if loadErr != nil {
return nil, loadErr
}
normalizedRanks, err = s.normalizeRankRewards(ctx, normalized.SysOrigin, inputs)
if err != nil {
return nil, err
}
}
}
activityID := normalized.ID.Int64()
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var row model.YumiGameKingActivity
created := activityID <= 0
writeTime := time.Now()
if !created {
if err := withWriteLock(tx).Where("id = ?", activityID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return NewAppError(http.StatusNotFound, "activity_not_found", "game king activity was not found")
}
return err
}
// 奖励组远程校验和数据库锁等待都可能跨过 startTime必须在拿到排他锁后
// 重新取时钟,不能用事务外的旧 now 绕过开始锁。
writeTime = time.Now()
if row.Enabled && !writeTime.Before(row.StartTime) {
if err := validateStartedActivityUpdate(row, normalized); err != nil {
return err
}
if normalizedPrizes != nil || normalizedRanks != nil {
return NewAppError(http.StatusConflict, "activity_config_locked", "prizes and rank rewards cannot change after activity start")
}
}
} else {
activityID, err = utils.NextID()
if err != nil {
return err
}
row = model.YumiGameKingActivity{ID: activityID, SettlementStatus: SettlementNotStarted, CreateTime: writeTime}
}
// 事务外读取时可能仍为 enabled但在等待锁期间被另一个管理员关闭。此时本请求
// 没有执行重新启用所需的奖励组校验,必须失败并让调用方重试,不能用旧快照启用。
if normalized.Enabled && !row.Enabled && (normalizedPrizes == nil || normalizedRanks == nil) {
return NewAppError(http.StatusConflict, "activity_state_changed", "activity state changed; reload and retry")
}
if normalized.Enabled && !row.Enabled && !time.UnixMilli(normalized.StartTime).After(writeTime) {
return NewAppError(http.StatusConflict, "activity_start_must_be_future", "the first enabled startTime must be in the future")
}
if normalized.Enabled {
if err := lockActivityScopeTx(tx, normalized.SysOrigin, writeTime); err != nil {
return err
}
if err := ensureNoEnabledOverlap(tx, activityID, normalized.SysOrigin, time.UnixMilli(normalized.StartTime), time.UnixMilli(normalized.EndTime)); err != nil {
return err
}
}
var codeCount int64
if err := tx.Model(&model.YumiGameKingActivity{}).
Where("activity_code = ? AND id <> ?", normalized.ActivityCode, activityID).
Count(&codeCount).Error; err != nil {
return err
}
if codeCount > 0 {
return NewAppError(http.StatusConflict, "activity_code_exists", "activityCode already exists")
}
row.ActivityCode = normalized.ActivityCode
row.ActivityName = normalized.ActivityName
row.ActivityDesc = normalized.ActivityDesc
row.SysOrigin = normalized.SysOrigin
row.Timezone = normalized.Timezone
row.StartTime = time.UnixMilli(normalized.StartTime)
row.EndTime = time.UnixMilli(normalized.EndTime)
row.SettlementDelayMinutes = *normalized.SettlementDelayMinutes
row.SettlementTime = row.EndTime.Add(time.Duration(row.SettlementDelayMinutes) * time.Minute)
row.CoinPerDraw = normalized.CoinPerDraw
row.RankingType = normalized.RankingType
row.RankingPeriod = normalized.RankingPeriod
row.Enabled = normalized.Enabled
row.UpdateTime = writeTime
if row.SettlementStatus == "" {
row.SettlementStatus = SettlementNotStarted
}
if err := tx.Save(&row).Error; err != nil {
return err
}
if normalizedPrizes != nil {
if err := replacePrizesTx(tx, activityID, normalizedPrizes, writeTime); err != nil {
return err
}
}
if normalizedRanks != nil {
if err := replaceRankRewardsTx(tx, activityID, normalizedRanks, writeTime); err != nil {
return err
}
}
return nil
}); err != nil {
return nil, err
}
row, err := s.loadActivity(ctx, activityID)
if err != nil {
return nil, err
}
view := activityView(row)
return &view, nil
}
// EnableActivity 在活动开始前启停活动;启用会重新验证并刷新全部奖励快照。
func (s *Service) EnableActivity(ctx context.Context, activityID int64, enabled bool) error {
activity, err := s.loadActivity(ctx, activityID)
if err != nil {
return err
}
if activity.Enabled && !time.Now().Before(activity.StartTime) {
return NewAppError(http.StatusConflict, "activity_config_locked", "activity enable state cannot change after activity start")
}
var prizes []normalizedPrize
var ranks []normalizedRankReward
if enabled {
prizeInputs, loadErr := s.currentPrizeInputs(ctx, activityID)
if loadErr != nil {
return loadErr
}
prizes, err = s.normalizePrizes(ctx, activity.SysOrigin, prizeInputs)
if err != nil {
return err
}
rankInputs, loadErr := s.currentRankInputs(ctx, activityID)
if loadErr != nil {
return loadErr
}
ranks, err = s.normalizeRankRewards(ctx, activity.SysOrigin, rankInputs)
if err != nil {
return err
}
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var locked model.YumiGameKingActivity
if err := withWriteLock(tx).Where("id = ?", activityID).First(&locked).Error; err != nil {
return err
}
lockedNow := time.Now()
if locked.Enabled && !lockedNow.Before(locked.StartTime) {
return NewAppError(http.StatusConflict, "activity_config_locked", "activity enable state cannot change after activity start")
}
if enabled {
if !locked.StartTime.After(lockedNow) {
return NewAppError(http.StatusConflict, "activity_start_must_be_future", "the first enabled startTime must be in the future")
}
if err := lockActivityScopeTx(tx, locked.SysOrigin, lockedNow); err != nil {
return err
}
if err := ensureNoEnabledOverlap(tx, activityID, locked.SysOrigin, locked.StartTime, locked.EndTime); err != nil {
return err
}
if err := replacePrizesTx(tx, activityID, prizes, lockedNow); err != nil {
return err
}
if err := replaceRankRewardsTx(tx, activityID, ranks, lockedNow); err != nil {
return err
}
}
return tx.Model(&model.YumiGameKingActivity{}).Where("id = ?", activityID).
Updates(map[string]any{"enabled": enabled, "update_time": lockedNow}).Error
})
}
func (s *Service) ListPrizes(ctx context.Context, activityID int64) ([]PrizeView, error) {
if _, err := s.loadActivity(ctx, activityID); err != nil {
return nil, err
}
rows, err := s.loadPrizes(ctx, activityID)
if err != nil {
return nil, err
}
result := make([]PrizeView, 0, len(rows))
for _, row := range rows {
result = append(result, prizeView(row))
}
return result, nil
}
func (s *Service) SavePrizes(ctx context.Context, activityID int64, req SavePrizesRequest) error {
if req.ActivityID.Int64() != activityID {
return NewAppError(http.StatusBadRequest, "activity_id_mismatch", "body activityId must match path id")
}
activity, err := s.loadActivity(ctx, activityID)
if err != nil {
return err
}
if activity.Enabled && !time.Now().Before(activity.StartTime) {
return NewAppError(http.StatusConflict, "activity_config_locked", "prizes cannot change after activity start")
}
values, err := s.normalizePrizes(ctx, activity.SysOrigin, req.Prizes)
if err != nil {
return err
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var locked model.YumiGameKingActivity
if err := withWriteLock(tx).Where("id = ?", activityID).First(&locked).Error; err != nil {
return err
}
lockedNow := time.Now()
if locked.Enabled && !lockedNow.Before(locked.StartTime) {
return NewAppError(http.StatusConflict, "activity_config_locked", "prizes cannot change after activity start")
}
return replacePrizesTx(tx, activityID, values, lockedNow)
})
}
func (s *Service) ListRankRewards(ctx context.Context, activityID int64) ([]RankRewardView, error) {
if _, err := s.loadActivity(ctx, activityID); err != nil {
return nil, err
}
rows, err := s.loadRankRewards(ctx, activityID)
if err != nil {
return nil, err
}
result := make([]RankRewardView, 0, len(rows))
for _, row := range rows {
result = append(result, rankRewardView(row))
}
return result, nil
}
func (s *Service) SaveRankRewards(ctx context.Context, activityID int64, req SaveRankRewardsRequest) error {
if req.ActivityID.Int64() != activityID {
return NewAppError(http.StatusBadRequest, "activity_id_mismatch", "body activityId must match path id")
}
activity, err := s.loadActivity(ctx, activityID)
if err != nil {
return err
}
if activity.Enabled && !time.Now().Before(activity.StartTime) {
return NewAppError(http.StatusConflict, "activity_config_locked", "rank rewards cannot change after activity start")
}
values, err := s.normalizeRankRewards(ctx, activity.SysOrigin, req.RankRewards)
if err != nil {
return err
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var locked model.YumiGameKingActivity
if err := withWriteLock(tx).Where("id = ?", activityID).First(&locked).Error; err != nil {
return err
}
lockedNow := time.Now()
if locked.Enabled && !lockedNow.Before(locked.StartTime) {
return NewAppError(http.StatusConflict, "activity_config_locked", "rank rewards cannot change after activity start")
}
return replaceRankRewardsTx(tx, activityID, values, lockedNow)
})
}
func (s *Service) loadActivity(ctx context.Context, activityID int64) (model.YumiGameKingActivity, error) {
if activityID <= 0 {
return model.YumiGameKingActivity{}, NewAppError(http.StatusBadRequest, "invalid_activity_id", "activityId is required")
}
var row model.YumiGameKingActivity
if err := s.db.WithContext(ctx).Where("id = ?", activityID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return row, NewAppError(http.StatusNotFound, "activity_not_found", "game king activity was not found")
}
return row, err
}
return row, nil
}
func (s *Service) resolveAppActivity(ctx context.Context, sysOrigin string, activityID int64, now time.Time) (model.YumiGameKingActivity, error) {
var row model.YumiGameKingActivity
base := s.db.WithContext(ctx).Where("sys_origin = ? AND enabled = ?", sysOrigin, true)
if activityID > 0 {
if err := base.Where("id = ?", activityID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return row, NewAppError(http.StatusNotFound, "activity_not_found", "enabled game king activity was not found")
}
return row, err
}
return row, nil
}
for _, query := range []*gorm.DB{
base.Session(&gorm.Session{}).Where("start_time <= ? AND end_time > ?", now, now).Order("start_time DESC"),
base.Session(&gorm.Session{}).Where("start_time > ?", now).Order("start_time ASC"),
base.Session(&gorm.Session{}).Where("end_time <= ?", now).Order("end_time DESC"),
} {
if err := query.First(&row).Error; err == nil {
return row, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return row, err
}
}
return row, NewAppError(http.StatusNotFound, "activity_not_found", "enabled game king activity was not found")
}
func (s *Service) loadPrizes(ctx context.Context, activityID int64) ([]model.YumiGameKingPrize, error) {
var rows []model.YumiGameKingPrize
err := s.db.WithContext(ctx).Where("activity_id = ?", activityID).Order("sort_order ASC").Find(&rows).Error
return rows, err
}
func (s *Service) loadRankRewards(ctx context.Context, activityID int64) ([]model.YumiGameKingRankReward, error) {
var rows []model.YumiGameKingRankReward
err := s.db.WithContext(ctx).Where("activity_id = ?", activityID).Order("start_rank ASC").Find(&rows).Error
return rows, err
}
func (s *Service) currentPrizeInputs(ctx context.Context, activityID int64) ([]PrizeInput, error) {
rows, err := s.loadPrizes(ctx, activityID)
if err != nil {
return nil, err
}
values := make([]PrizeInput, 0, len(rows))
for _, row := range rows {
values = append(values, PrizeInput{ID: FlexibleInt64(row.ID), PrizeName: row.PrizeName, PrizeImage: row.PrizeImage, Weight: row.Weight, Stock: row.Stock, ResourceGroupID: FlexibleInt64(row.ResourceGroupID), Enabled: row.Enabled, SortOrder: row.SortOrder})
}
return values, nil
}
func (s *Service) currentRankInputs(ctx context.Context, activityID int64) ([]RankRewardInput, error) {
rows, err := s.loadRankRewards(ctx, activityID)
if err != nil {
return nil, err
}
values := make([]RankRewardInput, 0, len(rows))
for _, row := range rows {
values = append(values, RankRewardInput{ID: FlexibleInt64(row.ID), StartRank: row.StartRank, EndRank: row.EndRank, ResourceGroupID: FlexibleInt64(row.ResourceGroupID), RewardName: row.RewardName})
}
return values, nil
}
func replacePrizesTx(tx *gorm.DB, activityID int64, values []normalizedPrize, now time.Time) error {
if err := tx.Where("activity_id = ?", activityID).Delete(&model.YumiGameKingPrize{}).Error; err != nil {
return err
}
rows := make([]model.YumiGameKingPrize, 0, len(values))
for _, value := range values {
id, err := utils.NextID()
if err != nil {
return err
}
rows = append(rows, model.YumiGameKingPrize{
ID: id, ActivityID: activityID, PrizeName: value.Input.PrizeName, PrizeImage: value.Input.PrizeImage,
Weight: value.Input.Weight, Stock: value.Input.Stock, ResourceGroupID: value.Snapshot.GroupID,
RewardGroupName: value.Snapshot.GroupName, RewardItemsJSON: value.Snapshot.ItemsJSON,
Enabled: true, SortOrder: value.Input.SortOrder, CreateTime: now, UpdateTime: now,
})
}
return tx.Create(&rows).Error
}
func replaceRankRewardsTx(tx *gorm.DB, activityID int64, values []normalizedRankReward, now time.Time) error {
if err := tx.Where("activity_id = ?", activityID).Delete(&model.YumiGameKingRankReward{}).Error; err != nil {
return err
}
rows := make([]model.YumiGameKingRankReward, 0, len(values))
for _, value := range values {
id, err := utils.NextID()
if err != nil {
return err
}
rows = append(rows, model.YumiGameKingRankReward{
ID: id, ActivityID: activityID, StartRank: value.Input.StartRank, EndRank: value.Input.EndRank,
ResourceGroupID: value.Snapshot.GroupID, RewardName: value.Input.RewardName,
RewardItemsJSON: value.Snapshot.ItemsJSON, CreateTime: now, UpdateTime: now,
})
}
return tx.Create(&rows).Error
}
func ensureNoEnabledOverlap(tx *gorm.DB, activityID int64, sysOrigin string, start, end time.Time) error {
var count int64
if err := tx.Model(&model.YumiGameKingActivity{}).
Where("sys_origin = ? AND enabled = ? AND id <> ? AND start_time < ? AND end_time > ?", sysOrigin, true, activityID, end, start).
Count(&count).Error; err != nil {
return err
}
if count > 0 {
return NewAppError(http.StatusConflict, "activity_time_overlap", "enabled activities for the same sysOrigin cannot overlap")
}
return nil
}
func lockActivityScopeTx(tx *gorm.DB, sysOrigin string, now time.Time) error {
row := model.YumiGameKingScopeLock{SysOrigin: sysOrigin, UpdateTime: now}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "sys_origin"}},
DoNothing: true,
}).Create(&row).Error; err != nil {
return err
}
// 插入竞争失败后,显式 FOR UPDATE 等待持锁事务提交;锁一直持有到本次 overlap
// 检查和 activity 保存一起提交,两个不同 activity 行也无法并发穿透。
return withWriteLock(tx).Where("sys_origin = ?", sysOrigin).First(&row).Error
}
func validateStartedActivityUpdate(row model.YumiGameKingActivity, req SaveActivityRequest) error {
if row.ActivityCode != req.ActivityCode || row.SysOrigin != req.SysOrigin || row.Timezone != req.Timezone ||
row.StartTime.UnixMilli() != req.StartTime || row.EndTime.UnixMilli() != req.EndTime ||
row.SettlementDelayMinutes != *req.SettlementDelayMinutes || row.CoinPerDraw != req.CoinPerDraw ||
row.RankingType != req.RankingType || row.RankingPeriod != req.RankingPeriod || row.Enabled != req.Enabled {
return NewAppError(http.StatusConflict, "activity_config_locked", "activity scope, time, chance rule and enable state cannot change after activity start")
}
return nil
}

View File

@ -1,392 +0,0 @@
package gameking
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"gorm.io/gorm"
)
const deliveryProcessingLease = 10 * time.Minute
type frozenDeliveryContext struct {
SysOrigin string
BusinessNo string
RewardItemsJSON string
RewardItems []RewardItemView
}
// dispatchDeliveryItem 原子领取一个 PENDING/人工确认后的 FAILED 奖励组并调用 Java。
// 一旦 HTTP 调用被尝试,任何错误都进入 UNKNOWN只有调用前校验失败才是可重试 FAILED。
func (s *Service) dispatchDeliveryItem(ctx context.Context, itemID int64, retry bool) error {
// 事务已提交后发奖不能继承 H5/Admin/cron 连接取消,否则会在真正调用 Java 前留下
// PROCESSING 并被误判 UNKNOWN。脱离上游取消但保留硬超时避免无界后台工作。
dispatchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 20*time.Second)
defer cancel()
ctx = dispatchCtx
var item model.YumiGameKingDeliveryItem
claimed := false
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := withWriteLock(tx).Where("id = ?", itemID).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return NewAppError(http.StatusNotFound, "delivery_item_not_found", "delivery item was not found")
}
return err
}
switch item.DeliveryStatus {
case DeliverySuccess:
return nil
case DeliveryUnknown:
return NewAppError(http.StatusConflict, "delivery_unknown_requires_resolution", "UNKNOWN delivery must be reconciled before retry")
case DeliveryProcessing:
return NewAppError(http.StatusConflict, "delivery_processing", "delivery is already processing")
case DeliveryPending:
if retry {
return NewAppError(http.StatusConflict, "delivery_not_retryable", "only FAILED delivery can be retried")
}
case DeliveryFailed:
if !retry {
return NewAppError(http.StatusConflict, "delivery_not_pending", "FAILED delivery requires an explicit retry")
}
default:
return NewAppError(http.StatusConflict, "delivery_status_invalid", "delivery status is invalid")
}
now := time.Now()
updates := map[string]any{"delivery_status": DeliveryProcessing, "failure_reason": "", "update_time": now}
if retry {
updates["retry_count"] = gorm.Expr("retry_count + 1")
item.RetryCount++
}
if err := tx.Model(&model.YumiGameKingDeliveryItem{}).Where("id = ?", item.ID).Updates(updates).Error; err != nil {
return err
}
if err := updateDeliveryParentTx(tx, item, DeliveryProcessing, "", nil, retry); err != nil {
return err
}
item.DeliveryStatus = DeliveryProcessing
item.UpdateTime = now
claimed = true
return nil
}); err != nil {
return err
}
if !claimed {
return nil
}
frozen, err := s.loadFrozenDeliveryContext(ctx, item)
if err != nil {
_ = s.finishDeliveryItem(ctx, item.ID, DeliveryFailed, err.Error())
return err
}
current, err := s.loadRewardGroupSnapshot(ctx, frozen.SysOrigin, item.ResourceGroupID)
if err != nil {
// 奖励组查询发生在真正发奖之前,因此网络失败、下架、跨租户或不存在都能明确
// 判定“未调用发奖”,记录 FAILED待配置恢复后由运营显式 retry。
_ = s.finishDeliveryItem(ctx, item.ID, DeliveryFailed, err.Error())
return err
}
if current.ItemsJSON != frozen.RewardItemsJSON {
err = NewAppError(http.StatusConflict, "reward_group_snapshot_changed", "reward group content changed after activity enable; restore it before retry")
_ = s.finishDeliveryItem(ctx, item.ID, DeliveryFailed, err.Error())
return err
}
if s.gateway == nil {
err = NewAppError(http.StatusServiceUnavailable, "reward_gateway_unavailable", "reward gateway is unavailable")
_ = s.finishDeliveryItem(ctx, item.ID, DeliveryFailed, err.Error())
return err
}
err = s.gateway.SendActivityReward(ctx, integration.SendActivityRewardRequest{
TrackID: item.ID, Origin: rewardOriginGameKing, SysOrigin: frozen.SysOrigin,
SourceGroupID: item.ResourceGroupID, AcceptUserID: item.UserID,
})
if err != nil {
// HTTP 已经发出后无法证明 Java 是否落账;包括 4xx/409 在内全部 UNKNOWN
// 禁止自动或普通 retry必须用 trackId/businessNo 核账后人工 resolve。
_ = s.finishDeliveryItem(ctx, item.ID, DeliveryUnknown, err.Error())
return err
}
return s.finishDeliveryItem(ctx, item.ID, DeliverySuccess, "")
}
func (s *Service) finishDeliveryItem(ctx context.Context, itemID int64, status, reason string) error {
// Java 调用和前置检查共用 20 秒操作超时,但最终状态必须使用独立短租约落库;
// 否则操作刚好超时时,已取消的 ctx 会让 FAILED/UNKNOWN/SUCCESS 都留在 PROCESSING。
finalizeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
return s.db.WithContext(finalizeCtx).Transaction(func(tx *gorm.DB) error {
var item model.YumiGameKingDeliveryItem
if err := withWriteLock(tx).Where("id = ?", itemID).First(&item).Error; err != nil {
return err
}
if item.DeliveryStatus != DeliveryProcessing {
return nil
}
now := time.Now()
var deliverTime *time.Time
if status == DeliverySuccess {
deliverTime = &now
}
if err := tx.Model(&model.YumiGameKingDeliveryItem{}).Where("id = ?", item.ID).Updates(map[string]any{
"delivery_status": status, "failure_reason": truncateFailure(reason), "deliver_time": deliverTime, "update_time": now,
}).Error; err != nil {
return err
}
if err := updateDeliveryParentTx(tx, item, status, reason, deliverTime, false); err != nil {
return err
}
if item.OwnerType == OwnerSettlement {
return refreshSettlementStatusTx(tx, item.ActivityID)
}
return nil
})
}
func updateDeliveryParentTx(tx *gorm.DB, item model.YumiGameKingDeliveryItem, status, reason string, deliverTime *time.Time, incrementRetry bool) error {
updates := map[string]any{
"delivery_status": status, "failure_reason": truncateFailure(reason), "deliver_time": deliverTime, "update_time": time.Now(),
}
if incrementRetry {
updates["retry_count"] = gorm.Expr("retry_count + 1")
}
switch item.OwnerType {
case OwnerDraw:
return tx.Model(&model.YumiGameKingDrawRecord{}).Where("id = ?", item.OwnerID).Updates(updates).Error
case OwnerSettlement:
return tx.Model(&model.YumiGameKingSettlementRecord{}).Where("id = ?", item.OwnerID).Updates(updates).Error
default:
return NewAppError(http.StatusConflict, "delivery_owner_invalid", "delivery owner type is invalid")
}
}
func refreshSettlementStatusTx(tx *gorm.DB, activityID int64) error {
var total, succeeded, unfinished, failed int64
base := tx.Model(&model.YumiGameKingSettlementRecord{}).Where("activity_id = ?", activityID)
if err := base.Count(&total).Error; err != nil {
return err
}
if total == 0 {
return nil
}
if err := tx.Model(&model.YumiGameKingSettlementRecord{}).Where("activity_id = ? AND delivery_status = ?", activityID, DeliverySuccess).Count(&succeeded).Error; err != nil {
return err
}
if err := tx.Model(&model.YumiGameKingSettlementRecord{}).Where("activity_id = ? AND delivery_status IN ?", activityID, []string{DeliveryPending, DeliveryProcessing}).Count(&unfinished).Error; err != nil {
return err
}
if err := tx.Model(&model.YumiGameKingSettlementRecord{}).Where("activity_id = ? AND delivery_status IN ?", activityID, []string{DeliveryFailed, DeliveryUnknown}).Count(&failed).Error; err != nil {
return err
}
status := SettlementProcessing
if succeeded == total {
status = SettlementCompleted
} else if failed > 0 {
status = SettlementPartialFailed
} else if unfinished == 0 {
status = SettlementPartialFailed
}
return tx.Model(&model.YumiGameKingActivity{}).Where("id = ?", activityID).
Updates(map[string]any{"settlement_status": status, "update_time": time.Now()}).Error
}
func (s *Service) loadFrozenDeliveryContext(ctx context.Context, item model.YumiGameKingDeliveryItem) (frozenDeliveryContext, error) {
var activity model.YumiGameKingActivity
if err := s.db.WithContext(ctx).Where("id = ?", item.ActivityID).First(&activity).Error; err != nil {
return frozenDeliveryContext{}, err
}
result := frozenDeliveryContext{SysOrigin: activity.SysOrigin}
switch item.OwnerType {
case OwnerDraw:
var record model.YumiGameKingDrawRecord
if err := s.db.WithContext(ctx).Where("id = ?", item.OwnerID).First(&record).Error; err != nil {
return result, err
}
var prize model.YumiGameKingPrize
if err := s.db.WithContext(ctx).Where("id = ? AND activity_id = ?", record.PrizeID, item.ActivityID).First(&prize).Error; err != nil {
return result, err
}
if prize.ResourceGroupID != item.ResourceGroupID {
return result, NewAppError(http.StatusConflict, "delivery_snapshot_mismatch", "draw reward group does not match frozen prize")
}
result.BusinessNo = fmt.Sprintf("YUMI_GAME_KING_DRAW:%d", record.ID)
result.RewardItemsJSON = prize.RewardItemsJSON
result.RewardItems = rewardItems(prize.RewardItemsJSON)
case OwnerSettlement:
var record model.YumiGameKingSettlementRecord
if err := s.db.WithContext(ctx).Where("id = ?", item.OwnerID).First(&record).Error; err != nil {
return result, err
}
var rankReward model.YumiGameKingRankReward
if err := s.db.WithContext(ctx).Where("id = ? AND activity_id = ?", record.RankRewardID, item.ActivityID).First(&rankReward).Error; err != nil {
return result, err
}
if rankReward.ResourceGroupID != item.ResourceGroupID {
return result, NewAppError(http.StatusConflict, "delivery_snapshot_mismatch", "settlement reward group does not match frozen rank tier")
}
result.BusinessNo = record.BusinessNo
result.RewardItemsJSON = rankReward.RewardItemsJSON
result.RewardItems = rewardItems(rankReward.RewardItemsJSON)
default:
return result, NewAppError(http.StatusConflict, "delivery_owner_invalid", "delivery owner type is invalid")
}
if strings.TrimSpace(result.RewardItemsJSON) == "" || len(result.RewardItems) == 0 {
return result, NewAppError(http.StatusConflict, "delivery_snapshot_empty", "frozen reward snapshot is empty")
}
return result, nil
}
func (s *Service) deliveryItems(ctx context.Context, ownerType string, ownerID int64) ([]DeliveryItemView, error) {
var rows []model.YumiGameKingDeliveryItem
if err := s.db.WithContext(ctx).Where("owner_type = ? AND owner_id = ?", ownerType, ownerID).Order("id ASC").Find(&rows).Error; err != nil {
return nil, err
}
result := make([]DeliveryItemView, 0, len(rows))
for _, row := range rows {
frozen, err := s.loadFrozenDeliveryContext(ctx, row)
if err != nil {
return nil, err
}
result = append(result, DeliveryItemView{
ID: row.ID, TrackID: row.ID, OwnerType: row.OwnerType, OwnerID: row.OwnerID,
BusinessNo: frozen.BusinessNo, UserID: row.UserID, ResourceGroupID: row.ResourceGroupID,
DeliveryStatus: row.DeliveryStatus, RetryCount: row.RetryCount, FailureReason: row.FailureReason,
DeliverTime: unixMilliPtr(row.DeliverTime), UpdateTime: unixMilli(row.UpdateTime), RewardItems: frozen.RewardItems,
})
}
return result, nil
}
func (s *Service) RetryDraw(ctx context.Context, recordID int64) error {
var record model.YumiGameKingDrawRecord
if err := s.db.WithContext(ctx).Where("id = ?", recordID).First(&record).Error; err != nil {
return err
}
if record.DeliveryStatus != DeliveryFailed {
return NewAppError(http.StatusConflict, "draw_not_retryable", "only FAILED draw rewards can be retried")
}
var item model.YumiGameKingDeliveryItem
if err := s.db.WithContext(ctx).Where("owner_type = ? AND owner_id = ?", OwnerDraw, record.ID).First(&item).Error; err != nil {
return err
}
return s.dispatchDeliveryItem(ctx, item.ID, true)
}
func (s *Service) RetrySettlement(ctx context.Context, recordID int64) error {
var record model.YumiGameKingSettlementRecord
if err := s.db.WithContext(ctx).Where("id = ?", recordID).First(&record).Error; err != nil {
return err
}
if record.DeliveryStatus != DeliveryFailed {
return NewAppError(http.StatusConflict, "settlement_not_retryable", "only FAILED settlement rewards can be retried")
}
var item model.YumiGameKingDeliveryItem
if err := s.db.WithContext(ctx).Where("owner_type = ? AND owner_id = ?", OwnerSettlement, record.ID).First(&item).Error; err != nil {
return err
}
return s.dispatchDeliveryItem(ctx, item.ID, true)
}
// ResolveUnknownDeliveryItem 只能由运营核账后调用。delivered=false 是明确未到账授权,
// 服务先把 UNKNOWN 原子转为 FAILED再复用普通 retrydelivered=true 只落成功,不再发送。
func (s *Service) ResolveUnknownDeliveryItem(ctx context.Context, itemID int64, delivered bool) error {
var item model.YumiGameKingDeliveryItem
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := withWriteLock(tx).Where("id = ?", itemID).First(&item).Error; err != nil {
return err
}
if item.DeliveryStatus != DeliveryUnknown {
return NewAppError(http.StatusConflict, "delivery_not_unknown", "only UNKNOWN delivery can be resolved")
}
now := time.Now()
status := DeliveryFailed
var deliveredAt *time.Time
failureReason := "operator confirmed not delivered"
if delivered {
status = DeliverySuccess
deliveredAt = &now
failureReason = ""
}
if err := tx.Model(&model.YumiGameKingDeliveryItem{}).Where("id = ?", item.ID).Updates(map[string]any{
"delivery_status": status, "failure_reason": failureReason, "deliver_time": deliveredAt, "update_time": now,
}).Error; err != nil {
return err
}
if err := updateDeliveryParentTx(tx, item, status, failureReason, deliveredAt, false); err != nil {
return err
}
if item.OwnerType == OwnerSettlement {
return refreshSettlementStatusTx(tx, item.ActivityID)
}
return nil
}); err != nil {
return err
}
if delivered {
return nil
}
return s.dispatchDeliveryItem(ctx, item.ID, true)
}
// RecoverPendingDeliveries 供 settle-due 恢复“事务已提交但进程尚未调用下游”的 PENDING。
// 过期 PROCESSING 由调用方在同一批次先行处理避免这里重复扫描FAILED/UNKNOWN 永不自动重试。
func (s *Service) RecoverPendingDeliveries(ctx context.Context, limit int) (int, error) {
if limit <= 0 || limit > 100 {
limit = 100
}
var rows []model.YumiGameKingDeliveryItem
if err := s.db.WithContext(ctx).Where("delivery_status = ?", DeliveryPending).Order("update_time ASC, id ASC").Limit(limit).Find(&rows).Error; err != nil {
return 0, err
}
processed := 0
for _, row := range rows {
// 外部调用错误已经可靠落为 FAILED/UNKNOWN恢复扫描继续处理其他 PENDING
// 只把查询/事务级错误作为扫描失败返回。
_ = s.dispatchDeliveryItem(ctx, row.ID, false)
processed++
}
return processed, nil
}
func (s *Service) markStaleProcessingUnknown(ctx context.Context, limit int) error {
var rows []model.YumiGameKingDeliveryItem
if err := s.db.WithContext(ctx).Where("delivery_status = ? AND update_time < ?", DeliveryProcessing, time.Now().Add(-deliveryProcessingLease)).
Order("update_time ASC").Limit(limit).Find(&rows).Error; err != nil {
return err
}
for _, candidate := range rows {
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var item model.YumiGameKingDeliveryItem
if err := withWriteLock(tx).Where("id = ?", candidate.ID).First(&item).Error; err != nil {
return err
}
if item.DeliveryStatus != DeliveryProcessing || !item.UpdateTime.Before(time.Now().Add(-deliveryProcessingLease)) {
return nil
}
now := time.Now()
reason := "delivery processing lease expired; downstream result requires reconciliation"
if err := tx.Model(&model.YumiGameKingDeliveryItem{}).Where("id = ?", item.ID).Updates(map[string]any{
"delivery_status": DeliveryUnknown, "failure_reason": reason, "update_time": now,
}).Error; err != nil {
return err
}
if err := updateDeliveryParentTx(tx, item, DeliveryUnknown, reason, nil, false); err != nil {
return err
}
if item.OwnerType == OwnerSettlement {
return refreshSettlementStatusTx(tx, item.ActivityID)
}
return nil
}); err != nil {
return err
}
}
return nil
}

View File

@ -1,241 +0,0 @@
package gameking
import (
"context"
cryptorand "crypto/rand"
"errors"
"math"
"math/big"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Draw 消耗一次由游戏金币支出获得的机会,并在同一事务冻结奖项和奖励组。
func (s *Service) Draw(ctx context.Context, user AuthUser, req DrawRequest) (*DrawResult, error) {
if user.UserID <= 0 {
return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "authenticated user is required")
}
if req.ActivityID.Int64() <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_activity_id", "activityId is required")
}
req.RequestID = strings.TrimSpace(req.RequestID)
if req.RequestID == "" || len(req.RequestID) > 64 {
return nil, NewAppError(http.StatusBadRequest, "invalid_request_id", "requestId is required and must not exceed 64 characters")
}
// 网络超时后的同 requestId 重放必须先于 ACTIVE 校验;已提交的结果即使活动已经结束、
// 关闭或进入结算,也要原样返回,且绝不再次发奖。
if replay, err := s.findDrawReplay(ctx, user, req); err != nil || replay != nil {
return replay, err
}
now := time.Now()
activity, err := s.resolveAppActivity(ctx, normalizeSysOrigin(user.SysOrigin), req.ActivityID.Int64(), now)
if err != nil {
return nil, err
}
if activityStatus(activity, now) != activityActive {
return nil, NewAppError(http.StatusConflict, "activity_not_active", "game king activity is not active")
}
var record model.YumiGameKingDrawRecord
var prize model.YumiGameKingPrize
var remaining int64
replayed := false
var deliveryItemID int64
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var lockedActivity model.YumiGameKingActivity
// 与事件入账相同,抽奖使用共享活动锁;结算排他锁会等待已进入抽奖完成。
if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where("id = ?", activity.ID).First(&lockedActivity).Error; err != nil {
return err
}
txNow := time.Now()
if !lockedActivity.Enabled || lockedActivity.SettlementStatus != SettlementNotStarted ||
txNow.Before(lockedActivity.StartTime) || !txNow.Before(lockedActivity.EndTime) {
return NewAppError(http.StatusConflict, "activity_not_active", "game king activity is not active")
}
var userRow model.YumiGameKingUser
if err := withWriteLock(tx).Where("activity_id = ? AND user_id = ?", activity.ID, user.UserID).First(&userRow).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return NewAppError(http.StatusConflict, "draw_chance_unavailable", "no draw chance is available")
}
return err
}
// 同一用户的请求都由聚合行串行化,因此并发复用 requestId 不会重复扣机会。
if err := tx.Where("activity_id = ? AND user_id = ? AND request_id = ?", activity.ID, user.UserID, req.RequestID).First(&record).Error; err == nil {
if err := tx.Where("id = ?", record.PrizeID).First(&prize).Error; err != nil {
return err
}
replayed = true
remaining = userRow.TotalConsumed/lockedActivity.CoinPerDraw - userRow.UsedChances
if remaining < 0 {
remaining = 0
}
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
earned := userRow.TotalConsumed / lockedActivity.CoinPerDraw
if userRow.UsedChances >= earned {
return NewAppError(http.StatusConflict, "draw_chance_unavailable", "no draw chance is available")
}
selected, err := selectAndReservePrizeTx(tx, activity.ID)
if err != nil {
return err
}
prize = selected
recordID, err := utils.NextID()
if err != nil {
return err
}
deliveryItemID, err = utils.NextID()
if err != nil {
return err
}
record = model.YumiGameKingDrawRecord{
ID: recordID, ActivityID: activity.ID, UserID: user.UserID, RequestID: req.RequestID,
PrizeID: prize.ID, PrizeName: prize.PrizeName, PrizeImage: prize.PrizeImage,
ResourceGroupID: prize.ResourceGroupID, DeliveryStatus: DeliveryPending,
DrawTime: txNow, CreateTime: txNow, UpdateTime: txNow,
}
if err := tx.Create(&record).Error; err != nil {
return err
}
if err := tx.Create(&model.YumiGameKingDeliveryItem{
ID: deliveryItemID, OwnerType: OwnerDraw, OwnerID: record.ID, ActivityID: activity.ID,
UserID: user.UserID, ResourceGroupID: prize.ResourceGroupID, DeliveryStatus: DeliveryPending,
CreateTime: txNow, UpdateTime: txNow,
}).Error; err != nil {
return err
}
userRow.UsedChances++
userRow.UpdateTime = txNow
if err := tx.Save(&userRow).Error; err != nil {
return err
}
remaining = earned - userRow.UsedChances
return nil
}); err != nil {
return nil, err
}
if !replayed {
// 发奖在提交后执行,外部超时不会回滚已经向 H5 展示的抽奖结果,也不会再次扣机会。
_ = s.dispatchDeliveryItem(ctx, deliveryItemID, false)
_ = s.db.WithContext(ctx).Where("id = ?", record.ID).First(&record).Error
}
return &DrawResult{
Record: drawRecordView(record), Prize: prizeView(prize), RemainingChances: remaining, IdempotentReplay: replayed,
}, nil
}
func (s *Service) findDrawReplay(ctx context.Context, user AuthUser, req DrawRequest) (*DrawResult, error) {
var record model.YumiGameKingDrawRecord
query := s.db.WithContext(ctx).Table("yumi_game_king_draw_record AS draw").
Select("draw.*").
Joins("JOIN yumi_game_king_activity AS activity ON activity.id = draw.activity_id").
Where("draw.user_id = ? AND draw.request_id = ? AND activity.sys_origin = ?", user.UserID, req.RequestID, normalizeSysOrigin(user.SysOrigin))
if req.ActivityID.Int64() > 0 {
query = query.Where("draw.activity_id = ?", req.ActivityID.Int64())
}
result := query.Order("draw.id DESC").Limit(1).Scan(&record)
if result.Error != nil {
return nil, result.Error
}
if result.RowsAffected == 0 || record.ID <= 0 {
return nil, nil
}
var prize model.YumiGameKingPrize
if err := s.db.WithContext(ctx).Where("id = ? AND activity_id = ?", record.PrizeID, record.ActivityID).First(&prize).Error; err != nil {
return nil, err
}
var activity model.YumiGameKingActivity
if err := s.db.WithContext(ctx).Where("id = ?", record.ActivityID).First(&activity).Error; err != nil {
return nil, err
}
var aggregate model.YumiGameKingUser
if err := s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", record.ActivityID, user.UserID).First(&aggregate).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
remaining := aggregate.TotalConsumed/activity.CoinPerDraw - aggregate.UsedChances
if remaining < 0 {
remaining = 0
}
return &DrawResult{Record: drawRecordView(record), Prize: prizeView(prize), RemainingChances: remaining, IdempotentReplay: true}, nil
}
func selectAndReservePrizeTx(tx *gorm.DB, activityID int64) (model.YumiGameKingPrize, error) {
for attempt := 0; attempt < 20; attempt++ {
var rows []model.YumiGameKingPrize
if err := tx.Where("activity_id = ? AND enabled = ? AND weight > 0 AND stock <> 0", activityID, true).
Order("sort_order ASC").Find(&rows).Error; err != nil {
return model.YumiGameKingPrize{}, err
}
if len(rows) != 7 {
// 配置固定七个,但有限库存耗尽后允许从剩余奖项继续抽;只有配置本身缺失才报错。
var configured int64
if err := tx.Model(&model.YumiGameKingPrize{}).Where("activity_id = ? AND enabled = ? AND weight > 0", activityID, true).Count(&configured).Error; err != nil {
return model.YumiGameKingPrize{}, err
}
if configured != 7 {
return model.YumiGameKingPrize{}, NewAppError(http.StatusConflict, "prize_config_invalid", "seven enabled positive-weight prizes are required")
}
}
if len(rows) == 0 {
return model.YumiGameKingPrize{}, NewAppError(http.StatusConflict, "prize_stock_empty", "all prize stock is exhausted")
}
selected, err := weightedPrize(rows)
if err != nil {
return model.YumiGameKingPrize{}, err
}
if selected.Stock < 0 {
return selected, nil
}
result := tx.Model(&model.YumiGameKingPrize{}).
Where("id = ? AND stock > 0", selected.ID).
Updates(map[string]any{"stock": gorm.Expr("stock - 1"), "update_time": time.Now()})
if result.Error != nil {
return model.YumiGameKingPrize{}, result.Error
}
if result.RowsAffected == 1 {
selected.Stock--
return selected, nil
}
// 另一个抽奖刚好抢完有限库存,重新读取候选并按剩余权重抽取。
}
return model.YumiGameKingPrize{}, NewAppError(http.StatusConflict, "prize_stock_busy", "prize stock changed concurrently; please retry")
}
func weightedPrize(rows []model.YumiGameKingPrize) (model.YumiGameKingPrize, error) {
var total int64
for _, row := range rows {
if row.Weight <= 0 || total > math.MaxInt64-row.Weight {
return model.YumiGameKingPrize{}, NewAppError(http.StatusConflict, "prize_weight_invalid", "prize weights are invalid")
}
total += row.Weight
}
if total <= 0 {
return model.YumiGameKingPrize{}, NewAppError(http.StatusConflict, "prize_weight_invalid", "prize weights are invalid")
}
randomValue, err := cryptorand.Int(cryptorand.Reader, big.NewInt(total))
if err != nil {
return model.YumiGameKingPrize{}, err
}
needle := randomValue.Int64()
var cursor int64
for _, row := range rows {
cursor += row.Weight
if needle < cursor {
return row, nil
}
}
return rows[len(rows)-1], nil
}

View File

@ -1,271 +0,0 @@
package gameking
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/service/taskcenter"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ConsumeGameEvent 实现 taskcenter.GameConsumeSink。GAME_CONSUME_GOLD 是唯一统计入口,
// 不再允许后台配置钱包 eventType 或把非游戏支出混入活动。
func (s *Service) ConsumeGameEvent(ctx context.Context, event taskcenter.ValidatedEvent) error {
if !strings.EqualFold(event.EventType, taskcenter.EventTypeGameConsumeGold) {
return nil
}
if event.UserID <= 0 || event.DeltaValue <= 0 || strings.TrimSpace(event.EventID) == "" {
return NewAppError(http.StatusBadRequest, "invalid_game_consume_event", "game consume event requires eventId, userId and positive deltaValue")
}
event.SysOrigin = normalizeSysOrigin(event.SysOrigin)
event.EventID = strings.TrimSpace(event.EventID)
var activities []model.YumiGameKingActivity
if err := s.db.WithContext(ctx).
Where("sys_origin = ? AND enabled = ? AND settlement_status = ? AND start_time <= ? AND end_time > ?",
event.SysOrigin, true, SettlementNotStarted, event.OccurredAt, event.OccurredAt).
Order("start_time ASC").
Find(&activities).Error; err != nil {
return err
}
for _, activity := range activities {
if _, err := s.applyEventToActivity(ctx, activity.ID, event); err != nil {
return err
}
}
return nil
}
// applyEventToActivity 在一个事务内完成账本幂等、总榜和活动时区日榜聚合。
func (s *Service) applyEventToActivity(ctx context.Context, activityID int64, event taskcenter.ValidatedEvent) (bool, error) {
accepted := false
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var activity model.YumiGameKingActivity
// 事件仅持有共享活动锁:不同用户可并发入账;结算使用排他锁并等待所有已进入的
// 事件事务完成,从而在切换 settlement_status 时形成明确冻结边界。
if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where("id = ?", activityID).First(&activity).Error; err != nil {
return err
}
// 结算事务先锁活动并切换状态;此处复核状态和 [start,end),因此结算冻结后到达的
// 延迟 MQ/补数不会让 Top30 继续漂移。
if !activity.Enabled || activity.SettlementStatus != SettlementNotStarted ||
event.SysOrigin != activity.SysOrigin || event.OccurredAt.Before(activity.StartTime) || !event.OccurredAt.Before(activity.EndTime) {
return nil
}
ledgerID, err := utils.NextID()
if err != nil {
return err
}
payloadJSON, _ := json.Marshal(event.Payload)
ledger := model.YumiGameKingConsumeLedger{
ID: ledgerID, ActivityID: activity.ID, EventID: event.EventID, UserID: event.UserID,
SysOrigin: event.SysOrigin, Amount: event.DeltaValue, EventTime: event.OccurredAt,
PayloadJSON: string(payloadJSON), CreateTime: time.Now(),
}
result := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "activity_id"}, {Name: "event_id"}},
DoNothing: true,
}).Create(&ledger)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return nil
}
now := time.Now()
if err := addOverallConsumeTx(tx, activity.ID, event.UserID, event.DeltaValue, event.OccurredAt, now); err != nil {
return err
}
_, location, err := normalizeTimezone(activity.Timezone)
if err != nil {
return err
}
if err := addDailyConsumeTx(tx, activity.ID, dateKey(event.OccurredAt, location), event.UserID, event.DeltaValue, event.OccurredAt, now); err != nil {
return err
}
accepted = true
return nil
})
return accepted, err
}
func addOverallConsumeTx(tx *gorm.DB, activityID, userID, amount int64, eventTime, now time.Time) error {
row := model.YumiGameKingUser{
ActivityID: activityID, UserID: userID, TotalConsumed: amount, UsedChances: 0,
ConsumeReachedTime: eventTime, CreateTime: now, UpdateTime: now,
}
// 首笔事件不能“SELECT 未命中后 INSERT”同一用户并发首笔会发生 PK 冲突或死锁,
// 同步上报若不重试就会漏计。单条 upsert 由聚合主键串行累加,并且不覆盖 used_chances。
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "activity_id"}, {Name: "user_id"}},
DoUpdates: clause.Assignments(map[string]any{
"total_consumed": gorm.Expr("total_consumed + ?", amount),
"consume_reached_time": gorm.Expr("GREATEST(consume_reached_time, ?)", eventTime),
"update_time": now,
}),
}).Create(&row).Error
}
func addDailyConsumeTx(tx *gorm.DB, activityID int64, statDate time.Time, userID, amount int64, eventTime, now time.Time) error {
row := model.YumiGameKingUserDaily{
ActivityID: activityID, StatDate: statDate, UserID: userID, TotalConsumed: amount,
ConsumeReachedTime: eventTime, CreateTime: now, UpdateTime: now,
}
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "activity_id"}, {Name: "stat_date"}, {Name: "user_id"}},
DoUpdates: clause.Assignments(map[string]any{
"total_consumed": gorm.Expr("total_consumed + ?", amount),
"consume_reached_time": gorm.Expr("GREATEST(consume_reached_time, ?)", eventTime),
"update_time": now,
}),
}).Create(&row).Error
}
type archivedTaskEvent struct {
DeltaValue int64 `json:"delta_value"`
Payload map[string]any `json:"payload"`
}
const taskCenterGameKingBackfillIndex = "idx_task_center_game_king_backfill"
type backfillIndexColumn struct {
ColumnName string `gorm:"column:column_name"`
SeqInIndex int `gorm:"column:seq_in_index"`
SubPart *int64 `gorm:"column:sub_part"`
IndexType string `gorm:"column:index_type"`
IsVisible string `gorm:"column:is_visible"`
}
// Backfill 从任务中心的权威事件归档表按主键游标补数。调用方只控制 lastId/limit
// 不能提交 eventType 或钱包口径;查询始终绑定复合索引并限制到活动 [start,end)。
func (s *Service) Backfill(ctx context.Context, activityID, lastID int64, limit int) (*BackfillResult, error) {
// 生产归档表约 35M 行;索引若未独立完成,后续游标定位或范围扫描可能退化成全表扫描。
// 因此元数据门禁必须位于任何活动/游标查询之前,并且每次调用重新检查,在线 DDL 完成后
// 无需重启即可恢复补数。information_schema 查询只读取固定索引的少量元数据行。
if err := s.requireBackfillIndex(ctx); err != nil {
return nil, err
}
activity, err := s.loadActivity(ctx, activityID)
if err != nil {
return nil, err
}
// 游标只有在仍可入账时才允许推进;否则 settled/disabled 活动会扫描到底却全部
// accepted=false给运营造成“补数成功”的假象。结束后等待结算期间仍允许补数。
if !activity.Enabled {
return nil, NewAppError(http.StatusConflict, "activity_disabled", "disabled activity cannot be backfilled")
}
if activity.SettlementStatus != SettlementNotStarted {
return nil, NewAppError(http.StatusConflict, "settlement_frozen", "settlement has frozen activity consumption")
}
if limit <= 0 {
limit = defaultBackfillLimit
}
if limit > maxBackfillLimit {
limit = maxBackfillLimit
}
if lastID < 0 {
lastID = 0
}
cursorTime := activity.StartTime
if lastID > 0 {
var cursor model.TaskCenterEventArchiveOutbox
if err := s.db.WithContext(ctx).
Where("id = ? AND sys_origin = ? AND event_type = ? AND occurred_at >= ? AND occurred_at < ?",
lastID, activity.SysOrigin, taskcenter.EventTypeGameConsumeGold, activity.StartTime, activity.EndTime).
First(&cursor).Error; err != nil {
if isRecordNotFound(err) {
return nil, NewAppError(http.StatusBadRequest, "invalid_backfill_cursor", "lastId does not belong to this activity event window")
}
return nil, err
}
cursorTime = cursor.OccurredAt
}
var rows []model.TaskCenterEventArchiveOutbox
if err := s.db.WithContext(ctx).
Where("sys_origin = ? AND event_type = ? AND occurred_at >= ? AND occurred_at < ? AND (occurred_at > ? OR (occurred_at = ? AND id > ?))",
activity.SysOrigin, taskcenter.EventTypeGameConsumeGold, activity.StartTime, activity.EndTime, cursorTime, cursorTime, lastID).
Order("occurred_at ASC, id ASC").Limit(limit + 1).Find(&rows).Error; err != nil {
return nil, err
}
finished := len(rows) <= limit
if len(rows) > limit {
rows = rows[:limit]
}
result := &BackfillResult{EventType: taskcenter.EventTypeGameConsumeGold, Finished: finished, NextLastID: lastID}
for _, row := range rows {
var archived archivedTaskEvent
if err := json.Unmarshal([]byte(row.RawJSON), &archived); err != nil || archived.DeltaValue <= 0 {
return nil, NewAppError(http.StatusUnprocessableEntity, "invalid_archived_event", fmt.Sprintf("task center archive row %d has no valid delta_value", row.ID))
}
accepted, err := s.applyEventToActivity(ctx, activity.ID, taskcenter.ValidatedEvent{
SysOrigin: activity.SysOrigin, EventID: row.EventID, EventType: taskcenter.EventTypeGameConsumeGold,
UserID: row.UserID, DeltaValue: archived.DeltaValue, OccurredAt: row.OccurredAt, Payload: archived.Payload,
})
if err != nil {
return nil, err
}
if !accepted {
var existing int64
if err := s.db.WithContext(ctx).Model(&model.YumiGameKingConsumeLedger{}).
Where("activity_id = ? AND event_id = ?", activity.ID, row.EventID).Count(&existing).Error; err != nil {
return nil, err
}
if existing == 0 {
// 入口校验后结算可能在本页中途取得排他锁。未命中 ledger 说明这条不是幂等
// 重复而是已被冻结拒绝;整页返回冲突且不暴露推进后的 cursor调用方沿旧游标重试。
return nil, NewAppError(http.StatusConflict, "backfill_window_closed", "activity stopped accepting consumption during backfill")
}
}
result.Scanned++
if accepted {
result.Accepted++
}
result.NextLastID = row.ID
}
return result, nil
}
// requireBackfillIndex 只接受迁移 058 创建的完整 BTREE 列序VARCHAR 前缀索引也会拒绝,
// 因为它不能提供与完整等值前缀相同的执行计划保证。缺表、缺索引或列序漂移统一 fail-closed。
func (s *Service) requireBackfillIndex(ctx context.Context) error {
var columns []backfillIndexColumn
if err := s.db.WithContext(ctx).Raw(`
SELECT COLUMN_NAME, SEQ_IN_INDEX, SUB_PART, INDEX_TYPE, IS_VISIBLE
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'task_center_event_archive_outbox'
AND INDEX_NAME = ?
ORDER BY SEQ_IN_INDEX ASC`, taskCenterGameKingBackfillIndex).Scan(&columns).Error; err != nil {
return NewAppError(http.StatusServiceUnavailable, "backfill_index_check_failed", "cannot verify the required task center backfill index")
}
required := [...]string{"sys_origin", "event_type", "occurred_at", "id"}
if len(columns) == len(required) {
valid := true
for index, requiredName := range required {
column := columns[index]
if column.SeqInIndex != index+1 || !strings.EqualFold(column.ColumnName, requiredName) || column.SubPart != nil ||
!strings.EqualFold(column.IndexType, "BTREE") || !strings.EqualFold(column.IsVisible, "YES") {
valid = false
break
}
}
if valid {
return nil
}
}
return NewAppError(
http.StatusServiceUnavailable,
"backfill_index_required",
"migration 058 backfill index (sys_origin,event_type,occurred_at,id) must be online before backfill",
)
}

View File

@ -1,351 +0,0 @@
package gameking
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"sort"
"strings"
"time"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var fixedRankRanges = [][2]int{{1, 1}, {2, 2}, {3, 3}, {4, 7}, {8, 10}, {11, 30}}
func normalizeSysOrigin(value string) string {
value = strings.ToUpper(strings.TrimSpace(value))
if value == "" {
return "LIKEI"
}
return value
}
func normalizeTimezone(value string) (string, *time.Location, error) {
value = strings.TrimSpace(value)
if value == "" {
value = defaultTimezone
}
location, err := time.LoadLocation(value)
if err != nil {
return "", nil, NewAppError(http.StatusBadRequest, "invalid_timezone", "timeZone must be a valid IANA timezone")
}
return value, location, nil
}
func normalizeRankingType(value string) (string, error) {
value = strings.ToUpper(strings.TrimSpace(value))
if value == "" {
value = rankingTypeTycoon
}
if value != rankingTypeTycoon {
return "", NewAppError(http.StatusBadRequest, "invalid_ranking_type", "rankingType must be TYCOON")
}
return value, nil
}
func normalizeRankingPeriod(value string) (string, error) {
value = strings.ToUpper(strings.TrimSpace(value))
if value == "" {
value = rankingPeriodOverall
}
if value != rankingPeriodOverall {
return "", NewAppError(http.StatusBadRequest, "invalid_ranking_period", "configured rankingPeriod must be OVERALL")
}
return value, nil
}
func normalizeActivityInput(req SaveActivityRequest) (SaveActivityRequest, error) {
req.ActivityCode = strings.TrimSpace(req.ActivityCode)
req.ActivityName = strings.TrimSpace(req.ActivityName)
req.ActivityDesc = strings.TrimSpace(req.ActivityDesc)
if req.ActivityCode == "" || len(req.ActivityCode) > 64 {
return req, NewAppError(http.StatusBadRequest, "invalid_activity_code", "activityCode is required and must not exceed 64 characters")
}
if req.ActivityName == "" || len(req.ActivityName) > 128 {
return req, NewAppError(http.StatusBadRequest, "invalid_activity_name", "activityName is required and must not exceed 128 characters")
}
if len(req.ActivityDesc) > 1000 {
return req, NewAppError(http.StatusBadRequest, "invalid_activity_desc", "activityDesc must not exceed 1000 characters")
}
req.SysOrigin = normalizeSysOrigin(req.SysOrigin)
if req.SysOrigin != "LIKEI" {
return req, NewAppError(http.StatusBadRequest, "invalid_sys_origin", "Yumi game king only supports LIKEI sysOrigin")
}
timezone, _, err := normalizeTimezone(req.Timezone)
if err != nil {
return req, err
}
req.Timezone = timezone
if req.StartTime <= 0 || req.EndTime <= req.StartTime {
return req, NewAppError(http.StatusBadRequest, "invalid_activity_time", "startTime and endTime are required and startTime must be before endTime")
}
if req.SettlementDelayMinutes == nil {
value := defaultSettlementDelayMinutes
req.SettlementDelayMinutes = &value
}
if *req.SettlementDelayMinutes < 0 || *req.SettlementDelayMinutes > maxSettlementDelayMinutes {
return req, NewAppError(http.StatusBadRequest, "invalid_settlement_delay", "settlementDelayMinutes must be between 0 and 1440")
}
if req.CoinPerDraw <= 0 {
return req, NewAppError(http.StatusBadRequest, "invalid_coin_per_draw", "coinPerDraw must be greater than zero")
}
req.RankingType, err = normalizeRankingType(req.RankingType)
if err != nil {
return req, err
}
req.RankingPeriod, err = normalizeRankingPeriod(req.RankingPeriod)
return req, err
}
func (s *Service) normalizePrizes(ctx context.Context, sysOrigin string, values []PrizeInput) ([]normalizedPrize, error) {
if len(values) != 7 {
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_count", "exactly seven prizes are required")
}
seenSort := make(map[int]struct{}, 7)
result := make([]normalizedPrize, 0, 7)
for _, value := range values {
value.PrizeName = strings.TrimSpace(value.PrizeName)
value.PrizeImage = strings.TrimSpace(value.PrizeImage)
if value.PrizeName == "" || len(value.PrizeName) > 128 {
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_name", "every prizeName is required and must not exceed 128 characters")
}
if value.PrizeImage == "" || len(value.PrizeImage) > 500 {
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_image", "every prizeImage is required and must not exceed 500 characters")
}
if !value.Enabled {
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_enabled", "all seven prizes must be enabled")
}
if value.Weight <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_weight", "every prize weight must be greater than zero")
}
if value.Stock < -1 {
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_stock", "prize stock must be -1 or greater")
}
if value.SortOrder < 1 || value.SortOrder > 7 {
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_sort", "prize sortOrder must cover 1 through 7")
}
if _, exists := seenSort[value.SortOrder]; exists {
return nil, NewAppError(http.StatusBadRequest, "duplicate_prize_sort", "prize sortOrder must be unique")
}
seenSort[value.SortOrder] = struct{}{}
if value.ResourceGroupID.Int64() <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_reward_group", "every prize must reference a reward group")
}
snapshot, err := s.loadRewardGroupSnapshot(ctx, sysOrigin, value.ResourceGroupID.Int64())
if err != nil {
return nil, err
}
result = append(result, normalizedPrize{Input: value, Snapshot: snapshot})
}
sort.Slice(result, func(i, j int) bool { return result[i].Input.SortOrder < result[j].Input.SortOrder })
return result, nil
}
func (s *Service) normalizeRankRewards(ctx context.Context, sysOrigin string, values []RankRewardInput) ([]normalizedRankReward, error) {
if len(values) != len(fixedRankRanges) {
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_count", "exactly six rank reward tiers are required")
}
byRange := make(map[string]RankRewardInput, len(values))
for _, value := range values {
key := fmt.Sprintf("%d-%d", value.StartRank, value.EndRank)
if _, exists := byRange[key]; exists {
return nil, NewAppError(http.StatusBadRequest, "duplicate_rank_reward", "rank reward ranges must be unique")
}
byRange[key] = value
}
result := make([]normalizedRankReward, 0, len(fixedRankRanges))
for _, rankRange := range fixedRankRanges {
key := fmt.Sprintf("%d-%d", rankRange[0], rankRange[1])
value, exists := byRange[key]
if !exists {
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_ranges", "rank rewards must be 1, 2, 3, 4-7, 8-10 and 11-30")
}
if value.ResourceGroupID.Int64() <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_group", "every rank tier must reference a reward group")
}
value.RewardName = strings.TrimSpace(value.RewardName)
snapshot, err := s.loadRewardGroupSnapshot(ctx, sysOrigin, value.ResourceGroupID.Int64())
if err != nil {
return nil, err
}
if value.RewardName == "" {
value.RewardName = snapshot.GroupName
}
if len(value.RewardName) > 128 {
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_name", "rewardName must not exceed 128 characters")
}
result = append(result, normalizedRankReward{Input: value, Snapshot: snapshot})
}
return result, nil
}
func (s *Service) loadRewardGroupSnapshot(ctx context.Context, sysOrigin string, groupID int64) (rewardGroupSnapshot, error) {
if s.gateway == nil {
return rewardGroupSnapshot{}, NewAppError(http.StatusServiceUnavailable, "reward_gateway_unavailable", "reward gateway is unavailable")
}
detail, err := s.gateway.GetRewardGroupDetail(ctx, groupID)
if err != nil {
return rewardGroupSnapshot{}, NewAppError(http.StatusBadGateway, "reward_group_lookup_failed", err.Error())
}
if int64(detail.ID) != groupID || len(detail.RewardConfigList) == 0 {
return rewardGroupSnapshot{}, NewAppError(http.StatusBadRequest, "reward_group_empty", "reward group must exist and contain at least one reward")
}
// 租户信息缺失也必须 fail-closed否则旧 Java DTO 或异常响应会让跨租户奖励组绕过校验。
if strings.TrimSpace(detail.SysOrigin) == "" || !strings.EqualFold(strings.TrimSpace(detail.SysOrigin), sysOrigin) {
return rewardGroupSnapshot{}, NewAppError(http.StatusBadRequest, "reward_group_sys_origin_mismatch", "reward group sysOrigin does not match activity")
}
// 启用和每次发奖前都必须拿到明确的上架状态;缺失状态不能被当成“已上架”。
if detail.ShelfStatus == nil || !*detail.ShelfStatus {
return rewardGroupSnapshot{}, NewAppError(http.StatusBadRequest, "reward_group_not_shelved", "reward group must be shelved")
}
items := make([]RewardItemView, 0, len(detail.RewardConfigList))
for _, item := range detail.RewardConfigList {
if int64(item.ID) <= 0 || strings.TrimSpace(item.Type) == "" || int64(item.Quantity) <= 0 || int64(item.Quantity) > math.MaxInt32 {
return rewardGroupSnapshot{}, NewAppError(http.StatusBadRequest, "reward_group_item_invalid", "reward group contains an invalid reward item")
}
items = append(items, RewardItemView{
ID: int64(item.ID),
Type: strings.TrimSpace(item.Type),
DetailType: strings.TrimSpace(item.DetailType),
Content: strings.TrimSpace(item.Content),
Quantity: int64(item.Quantity),
Cover: strings.TrimSpace(item.Cover),
SourceURL: strings.TrimSpace(item.SourceURL),
Name: strings.TrimSpace(item.Name),
Remark: strings.TrimSpace(item.Remark),
})
}
// 奖励组接口不承诺返回顺序;按配置 ID 及实际发奖字段规范化后再冻结/比对,
// 避免单纯数组重排被误判,同时确保 type/detailType/content/quantity 全部参与校验。
sort.Slice(items, func(i, j int) bool {
if items[i].ID != items[j].ID {
return items[i].ID < items[j].ID
}
if items[i].Type != items[j].Type {
return items[i].Type < items[j].Type
}
if items[i].DetailType != items[j].DetailType {
return items[i].DetailType < items[j].DetailType
}
if items[i].Content != items[j].Content {
return items[i].Content < items[j].Content
}
return items[i].Quantity < items[j].Quantity
})
for index := range items {
items[index].Sort = index + 1
}
raw, err := json.Marshal(items)
if err != nil {
return rewardGroupSnapshot{}, err
}
return rewardGroupSnapshot{GroupID: groupID, GroupName: strings.TrimSpace(detail.Name), ItemsJSON: string(raw), Items: items}, nil
}
func rewardItems(raw string) []RewardItemView {
items := make([]RewardItemView, 0)
if strings.TrimSpace(raw) == "" {
return items
}
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return []RewardItemView{}
}
return items
}
func activityView(row model.YumiGameKingActivity) ActivityView {
return ActivityView{
ID: row.ID,
ActivityCode: row.ActivityCode,
ActivityName: row.ActivityName,
ActivityDesc: row.ActivityDesc,
SysOrigin: row.SysOrigin,
Timezone: row.Timezone,
StartTime: unixMilli(row.StartTime),
EndTime: unixMilli(row.EndTime),
SettlementDelayMinutes: row.SettlementDelayMinutes,
SettlementTime: unixMilli(row.SettlementTime),
CoinPerDraw: row.CoinPerDraw,
EventTypes: []string{"GAME_CONSUME_GOLD"},
RankingType: row.RankingType,
RankingPeriod: row.RankingPeriod,
Enabled: row.Enabled,
SettlementStatus: row.SettlementStatus,
CreateTime: unixMilli(row.CreateTime),
UpdateTime: unixMilli(row.UpdateTime),
}
}
func prizeView(row model.YumiGameKingPrize) PrizeView {
return PrizeView{
ID: row.ID,
ActivityID: row.ActivityID,
PrizeName: row.PrizeName,
PrizeImage: row.PrizeImage,
Weight: row.Weight,
Stock: row.Stock,
ResourceGroupID: row.ResourceGroupID,
RewardGroupName: row.RewardGroupName,
Enabled: row.Enabled,
SortOrder: row.SortOrder,
RewardItems: rewardItems(row.RewardItemsJSON),
}
}
func rankRewardView(row model.YumiGameKingRankReward) RankRewardView {
return RankRewardView{
ID: row.ID,
ActivityID: row.ActivityID,
StartRank: row.StartRank,
EndRank: row.EndRank,
ResourceGroupID: row.ResourceGroupID,
RewardName: row.RewardName,
RewardItems: rewardItems(row.RewardItemsJSON),
}
}
func activityStatus(row model.YumiGameKingActivity, now time.Time) string {
if !row.Enabled {
return activityDisabled
}
if now.Before(row.StartTime) {
return activityUpcoming
}
if now.Before(row.EndTime) {
return activityActive
}
return activityEnded
}
func dateKey(at time.Time, location *time.Location) time.Time {
local := at.In(location)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, time.UTC)
}
func withWriteLock(db *gorm.DB) *gorm.DB {
return db.Clauses(clause.Locking{Strength: "UPDATE"})
}
func isRecordNotFound(err error) bool { return errors.Is(err, gorm.ErrRecordNotFound) }
func truncateFailure(value string) string {
value = strings.TrimSpace(value)
if len(value) <= 500 {
return value
}
return value[:500]
}
func integrationProfile(entry *RankEntryView, profile integration.UserProfile) {
entry.Account = profile.Account
entry.Nickname = profile.UserNickname
entry.Avatar = profile.UserAvatar
}

View File

@ -1,330 +0,0 @@
package gameking
import (
"context"
"errors"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/model"
"gorm.io/gorm"
)
// Me 返回机会账本和活动默认榜单中的当前名次。
func (s *Service) Me(ctx context.Context, user AuthUser, activityID int64) (*UserStateResponse, error) {
if user.UserID <= 0 {
return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "authenticated user is required")
}
activity, err := s.resolveAppActivity(ctx, normalizeSysOrigin(user.SysOrigin), activityID, time.Now())
if err != nil {
return nil, err
}
var row model.YumiGameKingUser
err = s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", activity.ID, user.UserID).First(&row).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
if errors.Is(err, gorm.ErrRecordNotFound) {
row = model.YumiGameKingUser{ActivityID: activity.ID, UserID: user.UserID}
}
earned := row.TotalConsumed / activity.CoinPerDraw
available := earned - row.UsedChances
if available < 0 {
available = 0
}
remainder := row.TotalConsumed % activity.CoinPerDraw
next := activity.CoinPerDraw
if remainder > 0 {
next -= remainder
}
var rank *int
if row.TotalConsumed > 0 {
value, rankErr := s.userRank(ctx, activity, user.UserID, activity.RankingPeriod, time.Now())
if rankErr != nil {
return nil, rankErr
}
rank = value
}
return &UserStateResponse{
ActivityID: activity.ID, TotalConsumed: row.TotalConsumed, EarnedChances: earned,
UsedChances: row.UsedChances, AvailableChances: available, NextChanceRemaining: next, Rank: rank,
}, nil
}
// Ranking 返回活动时区日榜或总榜。VICTORIOUS 是旧 H5 兼容维度;当前活动没有胜利事件,
// 因此显式请求时返回空榜而不是 400supportedRankingTypes 会让新 H5 隐藏该页签。
func (s *Service) Ranking(ctx context.Context, user AuthUser, activityID int64, rankingType, period string, limit int) (*RankingResponse, error) {
activity, err := s.resolveAppActivity(ctx, normalizeSysOrigin(user.SysOrigin), activityID, time.Now())
if err != nil {
return nil, err
}
rankingType = strings.ToUpper(strings.TrimSpace(rankingType))
if rankingType == "" {
rankingType = activity.RankingType
}
period = strings.ToUpper(strings.TrimSpace(period))
if period == "" {
period = activity.RankingPeriod
}
if period != "DAILY" && period != rankingPeriodOverall {
return nil, NewAppError(http.StatusBadRequest, "invalid_ranking_period", "period must be DAILY or OVERALL")
}
if rankingType != rankingTypeTycoon && rankingType != "VICTORIOUS" {
return nil, NewAppError(http.StatusBadRequest, "invalid_ranking_type", "rankingType must be TYCOON or VICTORIOUS")
}
if limit <= 0 {
limit = defaultRankingLimit
}
if limit > maxRankingLimit {
limit = maxRankingLimit
}
response := &RankingResponse{RankingType: rankingType, Period: period, Entries: []RankEntryView{}}
if rankingType == "VICTORIOUS" {
return response, nil
}
now := time.Now()
if period == "DAILY" {
_, location, timezoneErr := normalizeTimezone(activity.Timezone)
if timezoneErr != nil {
return nil, timezoneErr
}
statDate := dateKey(now, location)
response.StatDate = now.In(location).Format("2006-01-02")
var rows []model.YumiGameKingUserDaily
if err := s.db.WithContext(ctx).
Where("activity_id = ? AND stat_date = ? AND total_consumed > 0", activity.ID, statDate).
Order("total_consumed DESC, consume_reached_time ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil {
return nil, err
}
for index, row := range rows {
entry := RankEntryView{Rank: index + 1, UserID: row.UserID, Score: row.TotalConsumed, TotalConsumed: row.TotalConsumed, Me: row.UserID == user.UserID}
response.Entries = append(response.Entries, entry)
}
} else {
var rows []model.YumiGameKingUser
if err := s.db.WithContext(ctx).
Where("activity_id = ? AND total_consumed > 0", activity.ID).
Order("total_consumed DESC, consume_reached_time ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil {
return nil, err
}
for index, row := range rows {
entry := RankEntryView{Rank: index + 1, UserID: row.UserID, Score: row.TotalConsumed, TotalConsumed: row.TotalConsumed, Me: row.UserID == user.UserID}
response.Entries = append(response.Entries, entry)
}
}
s.hydrateRankProfiles(ctx, response.Entries)
if user.UserID > 0 {
response.My, err = s.myRankEntry(ctx, activity, user.UserID, period, now)
if err != nil {
return nil, err
}
s.hydrateMyRankProfile(ctx, response.My, response.Entries)
}
return response, nil
}
func (s *Service) myRankEntry(ctx context.Context, activity model.YumiGameKingActivity, userID int64, period string, now time.Time) (*RankEntryView, error) {
rank, err := s.userRank(ctx, activity, userID, period, now)
if err != nil || rank == nil {
return nil, err
}
entry := &RankEntryView{Rank: *rank, UserID: userID, Me: true}
if period == "DAILY" {
_, location, _ := normalizeTimezone(activity.Timezone)
var row model.YumiGameKingUserDaily
if err := s.db.WithContext(ctx).Where("activity_id = ? AND stat_date = ? AND user_id = ?", activity.ID, dateKey(now, location), userID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
entry.Score, entry.TotalConsumed = row.TotalConsumed, row.TotalConsumed
} else {
var row model.YumiGameKingUser
if err := s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", activity.ID, userID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
entry.Score, entry.TotalConsumed = row.TotalConsumed, row.TotalConsumed
}
return entry, nil
}
func (s *Service) userRank(ctx context.Context, activity model.YumiGameKingActivity, userID int64, period string, now time.Time) (*int, error) {
if period == "DAILY" {
_, location, _ := normalizeTimezone(activity.Timezone)
statDate := dateKey(now, location)
var row model.YumiGameKingUserDaily
if err := s.db.WithContext(ctx).Where("activity_id = ? AND stat_date = ? AND user_id = ?", activity.ID, statDate, userID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
var ahead int64
if err := s.db.WithContext(ctx).Model(&model.YumiGameKingUserDaily{}).
Where("activity_id = ? AND stat_date = ? AND (total_consumed > ? OR (total_consumed = ? AND (consume_reached_time < ? OR (consume_reached_time = ? AND user_id < ?))))",
activity.ID, statDate, row.TotalConsumed, row.TotalConsumed, row.ConsumeReachedTime, row.ConsumeReachedTime, userID).
Count(&ahead).Error; err != nil {
return nil, err
}
value := int(ahead) + 1
return &value, nil
}
var row model.YumiGameKingUser
if err := s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", activity.ID, userID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
var ahead int64
if err := s.db.WithContext(ctx).Model(&model.YumiGameKingUser{}).
Where("activity_id = ? AND (total_consumed > ? OR (total_consumed = ? AND (consume_reached_time < ? OR (consume_reached_time = ? AND user_id < ?))))",
activity.ID, row.TotalConsumed, row.TotalConsumed, row.ConsumeReachedTime, row.ConsumeReachedTime, userID).
Count(&ahead).Error; err != nil {
return nil, err
}
value := int(ahead) + 1
return &value, nil
}
func (s *Service) hydrateRankProfiles(ctx context.Context, entries []RankEntryView) {
if s.gateway == nil || len(entries) == 0 {
return
}
seen := make(map[int64]struct{}, len(entries))
userIDs := make([]int64, 0, len(entries))
for _, entry := range entries {
if entry.UserID <= 0 {
continue
}
if _, exists := seen[entry.UserID]; exists {
continue
}
seen[entry.UserID] = struct{}{}
userIDs = append(userIDs, entry.UserID)
}
profiles, err := s.gateway.MapUserProfiles(ctx, userIDs)
if err != nil {
// 排行主数据来自本库;资料服务失败时退化为 userId不阻断榜单。
return
}
for index := range entries {
if profile, exists := profiles[entries[index].UserID]; exists {
integrationProfile(&entries[index], profile)
}
}
}
func (s *Service) hydrateMyRankProfile(ctx context.Context, my *RankEntryView, entries []RankEntryView) {
if my == nil {
return
}
for _, entry := range entries {
if entry.UserID == my.UserID {
my.Account, my.Nickname, my.Avatar = entry.Account, entry.Nickname, entry.Avatar
return
}
}
values := []RankEntryView{*my}
s.hydrateRankProfiles(ctx, values)
*my = values[0]
}
func (s *Service) DrawRecords(ctx context.Context, user AuthUser, activityID int64, limit int) ([]DrawRecordView, error) {
if user.UserID <= 0 {
return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "authenticated user is required")
}
activity, err := s.resolveAppActivity(ctx, normalizeSysOrigin(user.SysOrigin), activityID, time.Now())
if err != nil {
return nil, err
}
return s.listDrawRecords(ctx, activity.ID, user.UserID, "", limit, false)
}
func (s *Service) ManageDrawRecords(ctx context.Context, activityID int64, status string, limit int) ([]DrawRecordView, error) {
if _, err := s.loadActivity(ctx, activityID); err != nil {
return nil, err
}
return s.listDrawRecords(ctx, activityID, 0, status, limit, true)
}
func (s *Service) listDrawRecords(ctx context.Context, activityID, userID int64, status string, limit int, includeDelivery bool) ([]DrawRecordView, error) {
if limit <= 0 {
limit = defaultRecordLimit
}
if limit > maxRecordLimit {
limit = maxRecordLimit
}
query := s.db.WithContext(ctx).Where("activity_id = ?", activityID)
if userID > 0 {
query = query.Where("user_id = ?", userID)
}
if strings.TrimSpace(status) != "" {
query = query.Where("delivery_status = ?", strings.ToUpper(strings.TrimSpace(status)))
}
var rows []model.YumiGameKingDrawRecord
if err := query.Order("id DESC").Limit(limit).Find(&rows).Error; err != nil {
return nil, err
}
result := make([]DrawRecordView, 0, len(rows))
for _, row := range rows {
view := drawRecordView(row)
if includeDelivery {
var deliveryErr error
view.DeliveryItems, deliveryErr = s.deliveryItems(ctx, OwnerDraw, row.ID)
if deliveryErr != nil {
return nil, deliveryErr
}
}
result = append(result, view)
}
return result, nil
}
func (s *Service) SettlementRecords(ctx context.Context, activityID int64) ([]SettlementRecordView, error) {
if _, err := s.loadActivity(ctx, activityID); err != nil {
return nil, err
}
var rows []model.YumiGameKingSettlementRecord
if err := s.db.WithContext(ctx).Where("activity_id = ?", activityID).Order("rank_no ASC").Find(&rows).Error; err != nil {
return nil, err
}
result := make([]SettlementRecordView, 0, len(rows))
for _, row := range rows {
view := settlementRecordView(row)
var deliveryErr error
view.DeliveryItems, deliveryErr = s.deliveryItems(ctx, OwnerSettlement, row.ID)
if deliveryErr != nil {
return nil, deliveryErr
}
result = append(result, view)
}
return result, nil
}
func drawRecordView(row model.YumiGameKingDrawRecord) DrawRecordView {
return DrawRecordView{
ID: row.ID, ActivityID: row.ActivityID, UserID: row.UserID, RequestID: row.RequestID,
PrizeID: row.PrizeID, PrizeName: row.PrizeName, PrizeImage: row.PrizeImage,
ResourceGroupID: row.ResourceGroupID, DeliveryStatus: row.DeliveryStatus,
RetryCount: row.RetryCount, FailureReason: row.FailureReason,
DrawTime: unixMilli(row.DrawTime), DeliverTime: unixMilliPtr(row.DeliverTime),
}
}
func settlementRecordView(row model.YumiGameKingSettlementRecord) SettlementRecordView {
return SettlementRecordView{
ID: row.ID, ActivityID: row.ActivityID, UserID: row.UserID, Rank: row.RankNo,
TotalConsumed: row.TotalConsumed, RankRewardID: row.RankRewardID,
ResourceGroupID: row.ResourceGroupID, BusinessNo: row.BusinessNo,
DeliveryStatus: row.DeliveryStatus, RetryCount: row.RetryCount,
FailureReason: row.FailureReason, CreateTime: unixMilli(row.CreateTime), DeliverTime: unixMilliPtr(row.DeliverTime),
}
}

View File

@ -1,163 +0,0 @@
package gameking
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
)
const (
settlementDispatchBatch = 4
settlementDueBatch = 10
staleDeliveryBatch = 20
)
// Settle 在结算截点后冻结 TYCOON/OVERALL Top30并发送尚未开始的 PENDING 奖励。
// 重复调用不会重建快照,也不会自动重试 FAILED/UNKNOWN。
func (s *Service) Settle(ctx context.Context, activityID int64) error {
if activityID <= 0 {
return NewAppError(http.StatusBadRequest, "invalid_activity_id", "activityId is required")
}
if err := s.freezeSettlement(ctx, activityID); err != nil {
return err
}
var items []model.YumiGameKingDeliveryItem
if err := s.db.WithContext(ctx).
Where("activity_id = ? AND owner_type = ? AND delivery_status = ?", activityID, OwnerSettlement, DeliveryPending).
Order("id ASC").Limit(settlementDispatchBatch).Find(&items).Error; err != nil {
return err
}
// 人工结算同样限制每次最多发送 4 组,最坏外部调用约 80 秒并为 DB 留出余量;
// 剩余 PENDING 由后续 settle-due 批次继续恢复FAILED/UNKNOWN 不会自动发送。
var lastErr error
for _, item := range items {
if err := s.dispatchDeliveryItem(ctx, item.ID, false); err != nil {
lastErr = err
}
}
return lastErr
}
func (s *Service) freezeSettlement(ctx context.Context, activityID int64) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var activity model.YumiGameKingActivity
// 排他活动锁与事件/抽奖共享锁构成冻结闸门:先进入的业务事务完成后,状态在同一
// 事务切为 PROCESSING此后新事件会在共享锁放行后看到非 NOT_STARTED 并拒绝入账。
if err := withWriteLock(tx).Where("id = ?", activityID).First(&activity).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return NewAppError(http.StatusNotFound, "activity_not_found", "game king activity was not found")
}
return err
}
now := time.Now()
if !activity.Enabled {
return NewAppError(http.StatusConflict, "activity_disabled", "disabled activity cannot be settled")
}
if now.Before(activity.SettlementTime) {
return NewAppError(http.StatusConflict, "settlement_not_due", "activity settlement time has not arrived")
}
if activity.SettlementStatus != SettlementNotStarted {
return nil
}
if err := tx.Model(&model.YumiGameKingActivity{}).Where("id = ?", activity.ID).
Updates(map[string]any{"settlement_status": SettlementProcessing, "update_time": now}).Error; err != nil {
return err
}
var users []model.YumiGameKingUser
if err := tx.Where("activity_id = ? AND total_consumed > 0", activity.ID).
Order("total_consumed DESC, consume_reached_time ASC, user_id ASC").Limit(30).Find(&users).Error; err != nil {
return err
}
if len(users) == 0 {
return tx.Model(&model.YumiGameKingActivity{}).Where("id = ?", activity.ID).
Updates(map[string]any{"settlement_status": SettlementCompleted, "update_time": now}).Error
}
var rewards []model.YumiGameKingRankReward
if err := tx.Where("activity_id = ?", activity.ID).Order("start_rank ASC").Find(&rewards).Error; err != nil {
return err
}
if len(rewards) != len(fixedRankRanges) {
return NewAppError(http.StatusConflict, "rank_reward_config_invalid", "six rank reward tiers are required for settlement")
}
for index, user := range users {
rank := index + 1
reward, ok := rankRewardFor(rewards, rank)
if !ok {
return NewAppError(http.StatusConflict, "rank_reward_config_invalid", "rank reward tier does not cover Top30")
}
recordID, err := utils.NextID()
if err != nil {
return err
}
itemID, err := utils.NextID()
if err != nil {
return err
}
businessNo := fmt.Sprintf("YUMI_GAME_KING_SETTLEMENT:%d:%d", activity.ID, rank)
record := model.YumiGameKingSettlementRecord{
ID: recordID, ActivityID: activity.ID, UserID: user.UserID, RankNo: rank,
TotalConsumed: user.TotalConsumed, RankRewardID: reward.ID, ResourceGroupID: reward.ResourceGroupID,
BusinessNo: businessNo, DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now,
}
if err := tx.Create(&record).Error; err != nil {
return err
}
if err := tx.Create(&model.YumiGameKingDeliveryItem{
ID: itemID, OwnerType: OwnerSettlement, OwnerID: record.ID, ActivityID: activity.ID,
UserID: user.UserID, ResourceGroupID: reward.ResourceGroupID,
DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now,
}).Error; err != nil {
return err
}
}
return nil
})
}
func rankRewardFor(rows []model.YumiGameKingRankReward, rank int) (model.YumiGameKingRankReward, bool) {
for _, row := range rows {
if rank >= row.StartRank && rank <= row.EndRank {
return row, true
}
}
return model.YumiGameKingRankReward{}, false
}
// SettleDue 是外部 cron 的唯一批量触发业务方法;本仓库不启动 ticker。
func (s *Service) SettleDue(ctx context.Context) (*SettleDueResult, error) {
// 先把过期 PROCESSING 保守转 UNKNOWN只做状态修复不盲发。
if err := s.markStaleProcessingUnknown(ctx, staleDeliveryBatch); err != nil {
return nil, err
}
var activities []model.YumiGameKingActivity
if err := s.db.WithContext(ctx).
Where("enabled = ? AND settlement_time <= ? AND settlement_status = ?", true, time.Now(), SettlementNotStarted).
Order("settlement_time ASC, id ASC").Limit(settlementDueBatch).Find(&activities).Error; err != nil {
return nil, err
}
result := &SettleDueResult{FailedIDs: []string{}}
for _, activity := range activities {
result.Scanned++
// 批量端点只快速冻结排名并生成 PENDING不在单个活动内串行发送 Top30。
if err := s.freezeSettlement(ctx, activity.ID); err != nil {
result.FailedIDs = append(result.FailedIDs, strconv.FormatInt(activity.ID, 10))
continue
}
result.Settled++
}
// 每次最多领取 4 个全局 PENDING单项 dispatch 硬超时 20 秒,外部调用上界约
// 80 秒,为冻结/状态修复留约 40 秒chatapp-cron 应配置 >=120 秒超时。
if _, err := s.RecoverPendingDeliveries(ctx, settlementDispatchBatch); err != nil {
return nil, err
}
return result, nil
}

View File

@ -1,359 +0,0 @@
package gameking
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/integration"
"gorm.io/gorm"
)
const (
defaultTimezone = "Asia/Riyadh"
defaultSettlementDelayMinutes = 30
maxSettlementDelayMinutes = 1440
maxBackfillLimit = 500
defaultBackfillLimit = 200
maxRankingLimit = 100
defaultRankingLimit = 30
maxRecordLimit = 100
defaultRecordLimit = 20
rankingTypeTycoon = "TYCOON"
rankingPeriodOverall = "OVERALL"
activityUpcoming = "UPCOMING"
activityActive = "ACTIVE"
activityEnded = "ENDED"
activityDisabled = "DISABLED"
SettlementNotStarted = "NOT_STARTED"
SettlementProcessing = "PROCESSING"
SettlementCompleted = "COMPLETED"
SettlementPartialFailed = "PARTIAL_FAILED"
DeliveryPending = "PENDING"
DeliveryProcessing = "PROCESSING"
DeliverySuccess = "SUCCESS"
DeliveryFailed = "FAILED"
DeliveryUnknown = "UNKNOWN"
OwnerDraw = "DRAW"
OwnerSettlement = "SETTLEMENT"
rewardOriginGameKing = "GAME_KING_AWARD"
)
type AppError = common.AppError
type AuthUser = common.AuthUser
var NewAppError = common.NewAppError
type gameKingDB interface {
WithContext(context.Context) *gorm.DB
}
type gameKingGateway interface {
GetRewardGroupDetail(context.Context, int64) (integration.RewardGroupDetail, error)
SendActivityReward(context.Context, integration.SendActivityRewardRequest) error
MapUserProfiles(context.Context, []int64) (map[int64]integration.UserProfile, error)
}
// Service 实现 Yumi 游戏王配置、事件入账、抽奖、排行榜与结算业务。
// 自动扫描由 chatapp-cron 通过内部接口触发,本服务不持有 ticker。
type Service struct {
db gameKingDB
gateway gameKingGateway
}
func NewService(db gameKingDB, gateway gameKingGateway) *Service {
return &Service{db: db, gateway: gateway}
}
// FlexibleInt64 同时接受 JSON 字符串和数字,响应始终输出字符串,避免前端丢失 Snowflake 精度。
type FlexibleInt64 int64
func (v *FlexibleInt64) UnmarshalJSON(data []byte) error {
raw := strings.TrimSpace(string(data))
if raw == "" || raw == "null" || raw == `""` {
*v = 0
return nil
}
if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) {
unquoted, err := strconv.Unquote(raw)
if err != nil {
return err
}
raw = strings.TrimSpace(unquoted)
}
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return fmt.Errorf("invalid int64 value: %w", err)
}
*v = FlexibleInt64(parsed)
return nil
}
func (v FlexibleInt64) Int64() int64 { return int64(v) }
func (v FlexibleInt64) MarshalJSON() ([]byte, error) {
return json.Marshal(strconv.FormatInt(int64(v), 10))
}
type RewardItemView struct {
ID int64 `json:"id,string"`
Type string `json:"type"`
DetailType string `json:"detailType,omitempty"`
Content string `json:"content,omitempty"`
Quantity int64 `json:"quantity"`
Cover string `json:"cover,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
Name string `json:"name,omitempty"`
Remark string `json:"remark,omitempty"`
Sort int `json:"sort"`
}
type ActivityView struct {
ID int64 `json:"id,string"`
ActivityCode string `json:"activityCode"`
ActivityName string `json:"activityName"`
ActivityDesc string `json:"activityDesc"`
SysOrigin string `json:"sysOrigin"`
Timezone string `json:"timeZone"`
StartTime int64 `json:"startTime"`
EndTime int64 `json:"endTime"`
SettlementDelayMinutes int `json:"settlementDelayMinutes"`
SettlementTime int64 `json:"settlementTime"`
CoinPerDraw int64 `json:"coinPerDraw"`
EventTypes []string `json:"eventTypes"`
RankingType string `json:"rankingType"`
RankingPeriod string `json:"rankingPeriod"`
Enabled bool `json:"enabled"`
SettlementStatus string `json:"settlementStatus"`
CreateTime int64 `json:"createTime"`
UpdateTime int64 `json:"updateTime"`
}
type PrizeView struct {
ID int64 `json:"id,string"`
ActivityID int64 `json:"activityId,string"`
PrizeName string `json:"prizeName"`
PrizeImage string `json:"prizeImage"`
Weight int64 `json:"weight"`
Stock int64 `json:"stock"`
ResourceGroupID int64 `json:"resourceGroupId,string"`
RewardGroupName string `json:"rewardGroupName,omitempty"`
Enabled bool `json:"enabled"`
SortOrder int `json:"sortOrder"`
RewardItems []RewardItemView `json:"rewardItems"`
}
type RankRewardView struct {
ID int64 `json:"id,string"`
ActivityID int64 `json:"activityId,string"`
StartRank int `json:"startRank"`
EndRank int `json:"endRank"`
ResourceGroupID int64 `json:"resourceGroupId,string"`
RewardName string `json:"rewardName"`
RewardItems []RewardItemView `json:"rewardItems"`
}
type DetailResponse struct {
Activity ActivityView `json:"activity"`
Prizes []PrizeView `json:"prizes"`
RankRewards []RankRewardView `json:"rankRewards"`
SupportedRankingTypes []string `json:"supportedRankingTypes"`
SupportedRankingPeriods []string `json:"supportedRankingPeriods"`
ActivityStatus string `json:"activityStatus"`
ServerTime int64 `json:"serverTime"`
}
type UserStateResponse struct {
ActivityID int64 `json:"activityId,string"`
TotalConsumed int64 `json:"totalConsumed"`
EarnedChances int64 `json:"earnedChances"`
UsedChances int64 `json:"usedChances"`
AvailableChances int64 `json:"availableChances"`
NextChanceRemaining int64 `json:"nextChanceRemaining"`
Rank *int `json:"rank"`
}
type DeliveryItemView struct {
ID int64 `json:"id,string"`
TrackID int64 `json:"trackId,string"`
OwnerType string `json:"ownerType"`
OwnerID int64 `json:"ownerId,string"`
BusinessNo string `json:"businessNo"`
UserID int64 `json:"userId,string"`
ResourceGroupID int64 `json:"resourceGroupId,string"`
DeliveryStatus string `json:"deliveryStatus"`
RetryCount int `json:"retryCount"`
FailureReason string `json:"failureReason"`
DeliverTime int64 `json:"deliverTime,omitempty"`
UpdateTime int64 `json:"updateTime"`
RewardItems []RewardItemView `json:"rewardItems"`
}
type DrawRecordView struct {
ID int64 `json:"id,string"`
ActivityID int64 `json:"activityId,string"`
UserID int64 `json:"userId,string"`
RequestID string `json:"requestId"`
PrizeID int64 `json:"prizeId,string"`
PrizeName string `json:"prizeName"`
PrizeImage string `json:"prizeImage"`
ResourceGroupID int64 `json:"resourceGroupId,string"`
DeliveryStatus string `json:"deliveryStatus"`
RetryCount int `json:"retryCount"`
FailureReason string `json:"failureReason"`
DrawTime int64 `json:"drawTime"`
DeliverTime int64 `json:"deliverTime,omitempty"`
DeliveryItems []DeliveryItemView `json:"deliveryItems,omitempty"`
}
type DrawRequest struct {
ActivityID FlexibleInt64 `json:"activityId"`
RequestID string `json:"requestId"`
}
type DrawResult struct {
Record DrawRecordView `json:"record"`
Prize PrizeView `json:"prize"`
RemainingChances int64 `json:"remainingChances"`
IdempotentReplay bool `json:"idempotentReplay"`
}
type RankEntryView struct {
Rank int `json:"rank"`
UserID int64 `json:"userId,string"`
Account string `json:"account"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Score int64 `json:"score"`
TotalConsumed int64 `json:"totalConsumed"`
TotalWon int64 `json:"totalWon"`
Me bool `json:"me"`
}
type RankingResponse struct {
RankingType string `json:"rankingType"`
Period string `json:"period"`
StatDate string `json:"statDate"`
Entries []RankEntryView `json:"entries"`
My *RankEntryView `json:"my"`
}
type SettlementRecordView struct {
ID int64 `json:"id,string"`
ActivityID int64 `json:"activityId,string"`
UserID int64 `json:"userId,string"`
Rank int `json:"rank"`
TotalConsumed int64 `json:"totalConsumed"`
RankRewardID int64 `json:"rankRewardId,string"`
ResourceGroupID int64 `json:"resourceGroupId,string"`
BusinessNo string `json:"businessNo"`
DeliveryStatus string `json:"deliveryStatus"`
RetryCount int `json:"retryCount"`
FailureReason string `json:"failureReason"`
CreateTime int64 `json:"createTime"`
DeliverTime int64 `json:"deliverTime,omitempty"`
DeliveryItems []DeliveryItemView `json:"deliveryItems,omitempty"`
}
type PrizeInput struct {
ID FlexibleInt64 `json:"id"`
PrizeName string `json:"prizeName"`
PrizeImage string `json:"prizeImage"`
Weight int64 `json:"weight"`
Stock int64 `json:"stock"`
ResourceGroupID FlexibleInt64 `json:"resourceGroupId"`
Enabled bool `json:"enabled"`
SortOrder int `json:"sortOrder"`
}
type RankRewardInput struct {
ID FlexibleInt64 `json:"id"`
StartRank int `json:"startRank"`
EndRank int `json:"endRank"`
ResourceGroupID FlexibleInt64 `json:"resourceGroupId"`
RewardName string `json:"rewardName"`
}
type SaveActivityRequest struct {
ID FlexibleInt64 `json:"id"`
ActivityCode string `json:"activityCode"`
ActivityName string `json:"activityName"`
ActivityDesc string `json:"activityDesc"`
SysOrigin string `json:"sysOrigin"`
Timezone string `json:"timeZone"`
StartTime int64 `json:"startTime"`
EndTime int64 `json:"endTime"`
SettlementDelayMinutes *int `json:"settlementDelayMinutes"`
CoinPerDraw int64 `json:"coinPerDraw"`
RankingType string `json:"rankingType"`
RankingPeriod string `json:"rankingPeriod"`
Enabled bool `json:"enabled"`
Prizes []PrizeInput `json:"prizes"`
RankRewards []RankRewardInput `json:"rankRewards"`
}
type SavePrizesRequest struct {
ActivityID FlexibleInt64 `json:"activityId"`
Prizes []PrizeInput `json:"prizes"`
}
type SaveRankRewardsRequest struct {
ActivityID FlexibleInt64 `json:"activityId"`
RankRewards []RankRewardInput `json:"rankRewards"`
}
type BackfillResult struct {
EventType string `json:"eventType"`
Scanned int `json:"scanned"`
Accepted int `json:"accepted"`
NextLastID int64 `json:"nextLastId,string"`
Finished bool `json:"finished"`
}
type SettleDueResult struct {
Scanned int `json:"scanned"`
Settled int `json:"settled"`
FailedIDs []string `json:"failedIds"`
}
type rewardGroupSnapshot struct {
GroupID int64
GroupName string
ItemsJSON string
Items []RewardItemView
}
type normalizedPrize struct {
Input PrizeInput
Snapshot rewardGroupSnapshot
}
type normalizedRankReward struct {
Input RankRewardInput
Snapshot rewardGroupSnapshot
}
func unixMilli(value time.Time) int64 {
if value.IsZero() {
return 0
}
return value.UnixMilli()
}
func unixMilliPtr(value *time.Time) int64 {
if value == nil || value.IsZero() {
return 0
}
return value.UnixMilli()
}

View File

@ -1,387 +0,0 @@
package gameopen
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/service/taskcenter"
)
const (
hotgameVendorType = "HOTGAME"
hotgameCodeTokenInvalid = 1001
hotgameCodeInsufficientBalance = 2001
hotgameCodeDuplicateOrder = 3001
hotgameCodeBadRequest = 4001
hotgameCodeSignatureError = 4002
hotgameCodeServerError = 5000
)
// HotgameGetUserInfoRequest 是热游 /getUserInfo 回调入参。
type HotgameGetUserInfoRequest struct {
GameID string `json:"gameId"`
UID string `json:"uid"`
Token string `json:"token"`
Sign string `json:"sign"`
}
// HotgameUpdateBalanceRequest 是热游 /updateBalance 回调入参roundId 文档是 int所以这里兼容数字和字符串。
type HotgameUpdateBalanceRequest struct {
OrderID string `json:"orderId"`
GameID string `json:"gameId"`
RoundID any `json:"roundId"`
UID string `json:"uid"`
Coin int64 `json:"coin"`
Type int `json:"type"`
Token string `json:"token"`
Sign string `json:"sign"`
}
// HandleHotgameGetUserInfo 按热游协议查询玩家资料和余额。
func (s *GameOpenService) HandleHotgameGetUserInfo(ctx context.Context, req HotgameGetUserInfoRequest, rawJSON string) CallbackResponse {
if err := s.validateHotgameGetUserInfoRequest(req); err != nil {
resp := failResponse(hotgameCodeBadRequest, err.Error())
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, "", req.toQueryUserRequest(), nil, gameOpenLogStatusFailed)
return resp
}
appKey := s.resolveCallbackAppKeyForVendor(ctx, hotgameVendorType, req.GameID, req.Token)
if strings.TrimSpace(appKey) == "" {
resp := failResponse(hotgameCodeBadRequest, "app key is blank")
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, "", req.toQueryUserRequest(), nil, gameOpenLogStatusFailed)
return resp
}
if !verifySign(req.Sign, buildHotgameGetUserInfoSign(req, appKey)) {
resp := failResponse(hotgameCodeSignatureError, "signature error")
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, "", req.toQueryUserRequest(), nil, gameOpenLogStatusFailed)
return resp
}
req.Token = normalizeCallbackToken(req.Token)
credential, err := s.java.AuthenticateToken(ctx, req.Token)
if err != nil {
resp := failResponse(hotgameCodeTokenInvalid, "token invalid")
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, "", req.toQueryUserRequest(), nil, gameOpenLogStatusFailed)
return resp
}
resolved, err := s.resolveUserWithToken(ctx, credential, req.UID)
if err != nil {
resp := failResponse(hotgameCodeTokenInvalid, "uid token mismatch")
userID := int64(credential.UserID)
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, credential.SysOrigin, req.toQueryUserRequest(), &userID, gameOpenLogStatusFailed)
return resp
}
userID := resolved.userID
profile := resolved.profile
if int64(profile.ID) == 0 {
profile, err = s.java.GetUserProfile(ctx, userID)
}
if err != nil {
resp := failResponse(hotgameCodeServerError, "profile load failed")
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, credential.SysOrigin, req.toQueryUserRequest(), &userID, gameOpenLogStatusFailed)
return resp
}
balance, err := s.readUserBalance(ctx, userID)
if err != nil {
resp := failResponse(hotgameCodeServerError, "balance load failed")
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, credential.SysOrigin, req.toQueryUserRequest(), &userID, gameOpenLogStatusFailed)
return resp
}
resp := successResponse(map[string]any{
"uid": req.UID,
"nickname": profile.UserNickname,
"avatar": profile.UserAvatar,
"coin": balance,
"vipLevel": 0,
})
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, credential.SysOrigin, req.toQueryUserRequest(), &userID, gameOpenLogStatusSuccess)
return resp
}
// HandleHotgameUpdateBalance 按热游协议处理实时扣加币,并用 orderId 做钱包事件幂等。
func (s *GameOpenService) HandleHotgameUpdateBalance(ctx context.Context, req HotgameUpdateBalanceRequest, rawJSON string) CallbackResponse {
updateReq := req.toUpdateCoinRequest()
if err := s.validateHotgameUpdateBalanceRequest(req); err != nil {
resp := failResponse(hotgameCodeBadRequest, err.Error())
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, "", updateReq, nil, gameOpenLogStatusFailed)
return resp
}
appKey := s.resolveCallbackAppKeyForVendor(ctx, hotgameVendorType, req.GameID, req.Token)
if strings.TrimSpace(appKey) == "" {
resp := failResponse(hotgameCodeBadRequest, "app key is blank")
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, "", updateReq, nil, gameOpenLogStatusFailed)
return resp
}
if !verifySign(req.Sign, buildHotgameUpdateBalanceSign(req, appKey)) {
resp := failResponse(hotgameCodeSignatureError, "signature error")
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, "", updateReq, nil, gameOpenLogStatusFailed)
return resp
}
req.Token = normalizeCallbackToken(req.Token)
updateReq.Token = req.Token
credential, err := s.java.AuthenticateToken(ctx, req.Token)
if err != nil {
resp := failResponse(hotgameCodeTokenInvalid, "token invalid")
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, "", updateReq, nil, gameOpenLogStatusFailed)
return resp
}
resolved, err := s.resolveUserWithToken(ctx, credential, req.UID)
if err != nil {
resp := failResponse(hotgameCodeTokenInvalid, "uid token mismatch")
userID := int64(credential.UserID)
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed)
return resp
}
userID := resolved.userID
eventID := "GAME_HOTGAME:" + strings.TrimSpace(req.OrderID)
exists, err := s.java.ExistsGoldEvent(ctx, eventID)
if err != nil {
resp := failResponse(hotgameCodeServerError, "event check failed")
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed)
return resp
}
if exists {
resp := failResponse(hotgameCodeDuplicateOrder, "duplicate orderId")
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed)
return resp
}
if err := s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{
ReceiptType: receiptTypeFromType(req.Type),
UserID: userID,
SysOrigin: credential.SysOrigin,
EventID: eventID,
Remark: fmt.Sprintf("hotgame=%s type=%d round=%s", req.GameID, req.Type, updateReq.RoundID),
Amount: integration.NewPennyAmountPayloadFromDollar(req.Coin),
CloseDelayAsset: false,
OpUserType: "APP",
CustomizeOrigin: "GAME_HOTGAME_" + strings.TrimSpace(req.GameID),
CustomizeOriginDesc: "HOTGAME[" + strings.TrimSpace(req.GameID) + "]",
}); err != nil {
// 热游文档要求扣款失败必须返回失败码;支出失败统一归到余额不足类,防止游戏继续开奖。
code := hotgameCodeServerError
if req.Type == 1 || looksLikeInsufficientBalance(err) {
code = hotgameCodeInsufficientBalance
}
resp := failResponse(code, err.Error())
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed)
return resp
}
balance, err := s.readUserBalance(ctx, userID)
if err != nil {
resp := failResponse(hotgameCodeServerError, "balance load failed")
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed)
return resp
}
resp := successResponse(map[string]any{"coin": balance, "responseId": eventID})
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusSuccess)
s.reportHotgameConsumeTaskEvent(ctx, credential.SysOrigin, userID, updateReq)
return resp
}
func (req HotgameGetUserInfoRequest) toQueryUserRequest() QueryUserRequest {
return QueryUserRequest{
GameID: req.GameID,
UID: req.UID,
Token: req.Token,
Sign: req.Sign,
}
}
func (req HotgameUpdateBalanceRequest) toUpdateCoinRequest() UpdateCoinRequest {
return UpdateCoinRequest{
OrderID: req.OrderID,
GameID: req.GameID,
RoundID: hotgameRoundIDString(req.RoundID),
UID: req.UID,
Coin: req.Coin,
Type: req.Type,
Token: req.Token,
Sign: req.Sign,
}
}
func (s *GameOpenService) validateHotgameGetUserInfoRequest(req HotgameGetUserInfoRequest) error {
switch {
case strings.TrimSpace(req.GameID) == "":
return newBadRequest("gameId is required")
case strings.TrimSpace(req.UID) == "":
return newBadRequest("uid is required")
case strings.TrimSpace(req.Token) == "":
return newBadRequest("token is required")
case strings.TrimSpace(req.Sign) == "":
return newBadRequest("sign is required")
default:
return nil
}
}
func (s *GameOpenService) validateHotgameUpdateBalanceRequest(req HotgameUpdateBalanceRequest) error {
switch {
case strings.TrimSpace(req.OrderID) == "":
return newBadRequest("orderId is required")
case strings.TrimSpace(req.GameID) == "":
return newBadRequest("gameId is required")
case strings.TrimSpace(hotgameRoundIDString(req.RoundID)) == "":
return newBadRequest("roundId is required")
case strings.TrimSpace(req.UID) == "":
return newBadRequest("uid is required")
case req.Coin <= 0:
return newBadRequest("coin is required")
case req.Type != 1 && req.Type != 2:
return newBadRequest("type must be 1 or 2")
case strings.TrimSpace(req.Token) == "":
return newBadRequest("token is required")
case strings.TrimSpace(req.Sign) == "":
return newBadRequest("sign is required")
default:
return nil
}
}
func buildHotgameGetUserInfoSign(req HotgameGetUserInfoRequest, key string) string {
return md5Hex(req.GameID + req.UID + req.Token + key)
}
func buildHotgameUpdateBalanceSign(req HotgameUpdateBalanceRequest, key string) string {
return md5Hex(
req.OrderID +
req.GameID +
hotgameRoundIDString(req.RoundID) +
req.UID +
fmt.Sprintf("%d", req.Coin) +
fmt.Sprintf("%d", req.Type) +
req.Token +
key,
)
}
func hotgameRoundIDString(value any) string {
switch typed := value.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(typed)
case json.Number:
return strings.TrimSpace(typed.String())
case float64:
if typed == float64(int64(typed)) {
return fmt.Sprintf("%d", int64(typed))
}
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%f", typed), "0"), ".")
default:
return strings.TrimSpace(fmt.Sprint(typed))
}
}
func (s *GameOpenService) resolveCallbackAppKeyForVendor(ctx context.Context, vendorType string, gameID, token string) string {
if s != nil && s.repo.DB != nil {
if sysOrigin := callbackTokenSysOrigin(token); sysOrigin != "" {
if appKey := s.resolveActiveProviderAppKey(ctx, vendorType, sysOrigin); appKey != "" {
return appKey
}
}
if appKey := s.resolveProviderAppKeyByGame(ctx, vendorType, gameID); appKey != "" {
return appKey
}
}
if s == nil {
return ""
}
return strings.TrimSpace(s.cfg.GameOpen.AppKey)
}
func (s *GameOpenService) resolveProviderAppKeyByGame(ctx context.Context, vendorType string, gameID string) string {
gameID = strings.TrimSpace(gameID)
if gameID == "" {
return ""
}
switch strings.ToUpper(strings.TrimSpace(vendorType)) {
case "LINGXIAN":
return s.resolveLingxianAppKeyByGame(ctx, gameID)
case hotgameVendorType:
var row struct {
AppKey string
}
err := s.repo.DB.WithContext(ctx).
Table("hotgame_provider_config AS cfg").
Select("cfg.app_key AS app_key").
Joins("JOIN sys_game_list_vendor_ext ext ON ext.sys_origin = cfg.sys_origin AND ext.profile = cfg.profile AND ext.vendor_type = ? AND ext.enabled = ?", hotgameVendorType, true).
Where("cfg.active = ? AND ext.vendor_game_id = ?", true, gameID).
Order("cfg.update_time DESC").
Limit(1).
Scan(&row).Error
if err != nil {
return ""
}
return strings.TrimSpace(row.AppKey)
default:
return ""
}
}
func (s *GameOpenService) resolveActiveProviderAppKey(ctx context.Context, vendorType string, sysOrigin string) string {
switch strings.ToUpper(strings.TrimSpace(vendorType)) {
case "LINGXIAN":
return s.resolveActiveLingxianAppKey(ctx, sysOrigin)
case hotgameVendorType:
sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin))
if sysOrigin == "" {
return ""
}
var row model.HotgameProviderConfig
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 ""
}
return strings.TrimSpace(row.AppKey)
default:
return ""
}
}
func looksLikeInsufficientBalance(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "insufficient") ||
strings.Contains(message, "balance not made") ||
strings.Contains(message, "余额不足")
}
func (s *GameOpenService) reportHotgameConsumeTaskEvent(ctx context.Context, sysOrigin string, userID int64, req UpdateCoinRequest) {
if s.taskReporter == nil || receiptTypeFromType(req.Type) != "EXPENDITURE" {
return
}
deltaValue := req.Coin
if deltaValue < 0 {
deltaValue = -deltaValue
}
if deltaValue <= 0 {
return
}
payload := map[string]any{
"provider": hotgameVendorType,
"orderId": strings.TrimSpace(req.OrderID),
"gameId": strings.TrimSpace(req.GameID),
"roundId": strings.TrimSpace(req.RoundID),
}
if err := s.taskReporter.ReportSimpleEvent(ctx, sysOrigin, "GAME_HOTGAME_CONSUME:"+strings.TrimSpace(req.OrderID), taskcenter.EventTypeGameConsumeGold, userID, deltaValue, payload); err != nil {
log.Printf("report hotgame task event failed: %v", err)
}
}

View File

@ -219,7 +219,20 @@ func (s *GameOpenService) GetIntegrationInfo(ctx context.Context, publicBaseURL
}
func (s *GameOpenService) resolveCallbackAppKey(ctx context.Context, gameID, token string) string {
return s.resolveCallbackAppKeyForVendor(ctx, "LINGXIAN", gameID, token)
if s != nil && s.repo.DB != nil {
if sysOrigin := callbackTokenSysOrigin(token); sysOrigin != "" {
if appKey := s.resolveActiveLingxianAppKey(ctx, sysOrigin); appKey != "" {
return appKey
}
}
if appKey := s.resolveLingxianAppKeyByGame(ctx, gameID); appKey != "" {
return appKey
}
}
if s == nil {
return ""
}
return strings.TrimSpace(s.cfg.GameOpen.AppKey)
}
func (s *GameOpenService) resolveLingxianAppKeyByGame(ctx context.Context, gameID string) string {

View File

@ -5,7 +5,6 @@ import (
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"context"
"errors"
"testing"
"time"
@ -21,7 +20,6 @@ type stubGateway struct {
accountMap map[string]integration.UserProfile
balance int64
exists bool
changeErr error
cmd integration.GoldReceiptCommand
}
@ -74,7 +72,7 @@ func (s *stubGateway) ExistsGoldEvent(context.Context, string) (bool, error) {
func (s *stubGateway) ChangeGoldBalance(_ context.Context, cmd integration.GoldReceiptCommand) error {
s.cmd = cmd
return s.changeErr
return nil
}
func TestHandleQueryUser(t *testing.T) {
@ -353,97 +351,6 @@ func TestCallbacksUseLingxianProviderConfigAppKey(t *testing.T) {
}
}
func TestHotgameGetUserInfoUsesHotgameSign(t *testing.T) {
gateway := &stubGateway{
credential: integration.UserCredential{UserID: 1234567, SysOrigin: "LIKEI"},
profile: integration.UserProfile{
ID: 1234567,
Account: "1234567",
UserNickname: "tester",
UserAvatar: "https://example.com/avatar.png",
},
balance: 88,
}
service := NewGameOpenService(config.Config{
GameOpen: config.GameOpenConfig{AppKey: "hotgame-key"},
}, nil, gateway)
req := HotgameGetUserInfoRequest{
GameID: "1",
UID: "1234567",
Token: "token",
}
req.Sign = buildHotgameGetUserInfoSign(req, "hotgame-key")
resp := service.HandleHotgameGetUserInfo(context.Background(), req, "{}")
if resp.ErrorCode != 0 {
t.Fatalf("HandleHotgameGetUserInfo() error = %+v", resp)
}
data, ok := resp.Data.(map[string]any)
if !ok {
t.Fatalf("HandleHotgameGetUserInfo() data type = %T", resp.Data)
}
if got := data["vipLevel"]; got != 0 {
t.Fatalf("HandleHotgameGetUserInfo() vipLevel = %v, want 0", got)
}
}
func TestHotgameUpdateBalanceAcceptsNumericRoundIDAndDuplicateReturns3001(t *testing.T) {
gateway := &stubGateway{
credential: integration.UserCredential{UserID: 1234567, SysOrigin: "LIKEI"},
profile: integration.UserProfile{ID: 1234567, Account: "1234567"},
balance: 99,
exists: true,
}
service := NewGameOpenService(config.Config{
GameOpen: config.GameOpenConfig{AppKey: "hotgame-key"},
}, nil, gateway)
req := HotgameUpdateBalanceRequest{
OrderID: "order-1",
GameID: "1",
RoundID: float64(3199),
UID: "1234567",
Coin: 15,
Type: 1,
Token: "token",
}
req.Sign = buildHotgameUpdateBalanceSign(req, "hotgame-key")
resp := service.HandleHotgameUpdateBalance(context.Background(), req, "{}")
if resp.ErrorCode != hotgameCodeDuplicateOrder {
t.Fatalf("HandleHotgameUpdateBalance() errorCode = %d, want %d", resp.ErrorCode, hotgameCodeDuplicateOrder)
}
if gateway.cmd.EventID != "" {
t.Fatalf("HandleHotgameUpdateBalance() should not change duplicate order, got %q", gateway.cmd.EventID)
}
}
func TestHotgameUpdateBalanceDeductFailureReturns2001(t *testing.T) {
gateway := &stubGateway{
credential: integration.UserCredential{UserID: 1234567, SysOrigin: "LIKEI"},
profile: integration.UserProfile{ID: 1234567, Account: "1234567"},
balance: 99,
changeErr: errors.New("insufficient_balance"),
}
service := NewGameOpenService(config.Config{
GameOpen: config.GameOpenConfig{AppKey: "hotgame-key"},
}, nil, gateway)
req := HotgameUpdateBalanceRequest{
OrderID: "order-1",
GameID: "1",
RoundID: float64(3199),
UID: "1234567",
Coin: 15,
Type: 1,
Token: "token",
}
req.Sign = buildHotgameUpdateBalanceSign(req, "hotgame-key")
resp := service.HandleHotgameUpdateBalance(context.Background(), req, "{}")
if resp.ErrorCode != hotgameCodeInsufficientBalance {
t.Fatalf("HandleHotgameUpdateBalance() errorCode = %d, want %d", resp.ErrorCode, hotgameCodeInsufficientBalance)
}
}
func newGameOpenTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
@ -452,7 +359,6 @@ func newGameOpenTestDB(t *testing.T) *gorm.DB {
}
if err := db.AutoMigrate(
&model.LingxianProviderConfig{},
&model.HotgameProviderConfig{},
&model.SysGameListVendorExt{},
&model.GameOpenCallbackLog{},
); err != nil {

View File

@ -14,10 +14,6 @@ type Registry struct {
ordered []Provider
}
var emptyGameListUserIDs = map[int64]struct{}{
2061394478798794754: {},
}
// NewRegistry 创建厂商注册表。
func NewRegistry(providers ...Provider) *Registry {
r := &Registry{
@ -79,9 +75,6 @@ func (r *Registry) List() ProviderListResponse {
// ListShortcutGames 返回聚合后的快捷游戏列表。
func (r *Registry) ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) {
if IsEmptyGameListUser(user.UserID) {
return []RoomGameListItem{}, nil
}
if r == nil || len(r.ordered) == 0 {
return []RoomGameListItem{}, nil
}
@ -103,9 +96,6 @@ func (r *Registry) ListShortcutGames(ctx context.Context, user AuthUser, roomID
// ListRoomGames 返回聚合后的房间游戏列表。
func (r *Registry) ListRoomGames(ctx context.Context, user AuthUser, roomID, category string) (*RoomGameListResponse, error) {
if IsEmptyGameListUser(user.UserID) {
return &RoomGameListResponse{Items: []RoomGameListItem{}}, nil
}
if r == nil || len(r.ordered) == 0 {
return &RoomGameListResponse{Items: []RoomGameListItem{}}, nil
}
@ -124,11 +114,6 @@ func (r *Registry) ListRoomGames(ctx context.Context, user AuthUser, roomID, cat
return &RoomGameListResponse{Items: items}, nil
}
func IsEmptyGameListUser(userID int64) bool {
_, ok := emptyGameListUserIDs[userID]
return ok
}
// ResolveByLaunchRequest 根据启动请求解析内部对应的厂商。
func (r *Registry) ResolveByLaunchRequest(ctx context.Context, user AuthUser, req LaunchRequest) (Provider, error) {
if r == nil {
@ -251,8 +236,6 @@ func normalizeProviderAlias(key string) string {
switch normalizeKey(key) {
case "LEADER":
return "LINGXIAN"
case "HOTGAME", "HOT_GAME", "REYOU", "LALU":
return "HOTGAME"
default:
return normalizeKey(key)
}

View File

@ -80,20 +80,6 @@ func TestRegistryResolveMapsLeaderAliasToLingxian(t *testing.T) {
}
}
func TestRegistryResolveMapsHotgameAliases(t *testing.T) {
registry := NewRegistry(&stubProvider{key: "HOTGAME", name: "热游"})
for _, alias := range []string{"HOTGAME", "hot_game", "REYOU", "lalu"} {
provider, err := registry.Resolve(alias)
if err != nil {
t.Fatalf("Resolve(%q) error = %v", alias, err)
}
if provider.Key() != "HOTGAME" {
t.Fatalf("Resolve(%q) key = %q, want HOTGAME", alias, provider.Key())
}
}
}
func TestRegistryListKeepsRegistrationOrder(t *testing.T) {
registry := NewRegistry(
&stubProvider{key: "BAISHUN", name: "百顺"},
@ -139,47 +125,6 @@ func TestRegistryListRoomGamesAggregatesAndSorts(t *testing.T) {
}
}
func TestRegistryListRoomGamesReturnsEmptyForHardcodedTestUser(t *testing.T) {
registry := NewRegistry(&stubProvider{
key: "BAISHUN",
name: "百顺",
room: []RoomGameListItem{{GameID: "a", Sort: 30}},
})
resp, err := registry.ListRoomGames(
context.Background(),
AuthUser{UserID: 2061394478798794754},
"room-1",
"",
)
if err != nil {
t.Fatalf("ListRoomGames() error = %v", err)
}
if len(resp.Items) != 0 {
t.Fatalf("ListRoomGames() items = %d, want 0", len(resp.Items))
}
}
func TestRegistryListShortcutGamesReturnsEmptyForHardcodedTestUser(t *testing.T) {
registry := NewRegistry(&stubProvider{
key: "BAISHUN",
name: "百顺",
shortcut: []RoomGameListItem{{GameID: "a", Sort: 30}},
})
items, err := registry.ListShortcutGames(
context.Background(),
AuthUser{UserID: 2061394478798794754},
"room-1",
)
if err != nil {
t.Fatalf("ListShortcutGames() error = %v", err)
}
if len(items) != 0 {
t.Fatalf("ListShortcutGames() items = %d, want 0", len(items))
}
}
func TestRegistryLaunchGameResolvesProviderByGameID(t *testing.T) {
baishunProvider := &stubProvider{
key: "BAISHUN",

View File

@ -1,57 +0,0 @@
package gameprovider
import (
"context"
"strings"
"chatapp3-golang/internal/integration"
)
const gameListTargetSysOrigin = "LIKEI"
// VisibilityGateway 聚合游戏列表门禁需要的 Java 用户区域能力。
type VisibilityGateway interface {
GetUserRegion(ctx context.Context, userID int64, authorization string) (integration.UserRegion, error)
}
// VisibilityResult 返回门禁结果,并把已经查到的区域带回路由继续参与 SQL 过滤。
type VisibilityResult struct {
Allow bool
RegionID string
RegionCode string
}
// VisibilityPolicy 判断当前用户是否可以看到游戏列表。
type VisibilityPolicy struct {
java VisibilityGateway
targetSys string
}
// NewVisibilityPolicy 创建 LIKEI 游戏列表门禁:土耳其财富等级限制已下线,现在对所有区域直接展示,
// 保留 Evaluate 是因为路由仍依赖它查询用户区域给 provider SQL 做 regions 过滤。
func NewVisibilityPolicy(java VisibilityGateway) *VisibilityPolicy {
return &VisibilityPolicy{
java: java,
targetSys: gameListTargetSysOrigin,
}
}
// Evaluate 给 LIKEI 用户补齐区域信息并放行;非 LIKEI 用户沿用自带区域直接展示。
func (p *VisibilityPolicy) Evaluate(ctx context.Context, user AuthUser) (VisibilityResult, error) {
if p == nil || !strings.EqualFold(strings.TrimSpace(user.SysOrigin), p.targetSys) {
return VisibilityResult{Allow: true, RegionID: user.RegionID, RegionCode: user.RegionCode}, nil
}
if user.UserID <= 0 || p.java == nil {
return VisibilityResult{}, nil
}
region, err := p.java.GetUserRegion(ctx, user.UserID, user.Authorization)
if err != nil {
return VisibilityResult{}, err
}
return VisibilityResult{
Allow: true,
RegionID: strings.TrimSpace(region.RegionID),
RegionCode: strings.TrimSpace(region.RegionCode),
}, nil
}

View File

@ -1,80 +0,0 @@
package gameprovider
import (
"context"
"testing"
"chatapp3-golang/internal/integration"
)
type stubVisibilityGateway struct {
region integration.UserRegion
}
func (s stubVisibilityGateway) GetUserRegion(context.Context, int64, string) (integration.UserRegion, error) {
return s.region, nil
}
const account1007500UserID int64 = 2054163533368717314
func TestVisibilityPolicyAllowsAllRegions(t *testing.T) {
tests := []struct {
name string
region integration.UserRegion
}{
{
name: "turkey region is visible without wealth gate",
region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
},
{
name: "south asia region is visible",
region: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
policy := NewVisibilityPolicy(stubVisibilityGateway{region: tt.region})
result, err := policy.Evaluate(context.Background(), AuthUser{UserID: account1007500UserID, SysOrigin: "LIKEI"})
if err != nil {
t.Fatalf("Evaluate() error = %v", err)
}
if !result.Allow {
t.Fatalf("Evaluate() allow = false, want true")
}
if result.RegionID != tt.region.RegionID || result.RegionCode != tt.region.RegionCode {
t.Fatalf("Evaluate() region = %#v, want %#v", result, tt.region)
}
})
}
}
func TestVisibilityPolicySkipsOtherSysOrigin(t *testing.T) {
policy := NewVisibilityPolicy(stubVisibilityGateway{
region: integration.UserRegion{RegionID: "2046067036517363714", RegionCode: "AR"},
})
result, err := policy.Evaluate(context.Background(), AuthUser{UserID: 1001, SysOrigin: "ASWAT", RegionID: "r-1", RegionCode: "SA"})
if err != nil {
t.Fatalf("Evaluate() error = %v", err)
}
if !result.Allow {
t.Fatalf("Evaluate() allow = false, want true")
}
if result.RegionID != "r-1" || result.RegionCode != "SA" {
t.Fatalf("Evaluate() region = %#v, want user-provided region", result)
}
}
func TestVisibilityPolicyDeniesInvalidUser(t *testing.T) {
policy := NewVisibilityPolicy(stubVisibilityGateway{})
result, err := policy.Evaluate(context.Background(), AuthUser{UserID: 0, SysOrigin: "LIKEI"})
if err != nil {
t.Fatalf("Evaluate() error = %v", err)
}
if result.Allow {
t.Fatalf("Evaluate() allow = true, want false for invalid user")
}
}

View File

@ -1,676 +0,0 @@
package highwin
import (
"context"
"fmt"
"math/rand"
"net/http"
"strconv"
"strings"
"sync"
"time"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/service/regionimgroup"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
const (
SimulationKindLuckyGift = "LUCKY_GIFT"
SimulationKindGameWin = "GAME_WIN"
adminTimeLayout = "2006-01-02 15:04:05"
)
type BroadcastSimulationRequest struct {
SysOrigin string `json:"sysOrigin"`
RegionCode string `json:"regionCode"`
Kind string `json:"kind"`
SenderUserID string `json:"senderUserId"`
RoomID string `json:"roomId"`
Multiple string `json:"multiple"`
WinAmount string `json:"winAmount"`
AcceptUserID string `json:"acceptUserId"`
GiftID string `json:"giftId"`
GiftCandy string `json:"giftCandy"`
GiftQuantity string `json:"giftQuantity"`
GameID string `json:"gameId"`
GameRoundID string `json:"gameRoundId"`
GameURL string `json:"gameUrl"`
BetAmount string `json:"betAmount"`
ProviderOrder string `json:"providerOrderId"`
}
type BroadcastSimulationResponse struct {
SysOrigin string `json:"sysOrigin"`
RegionCode string `json:"regionCode"`
GroupID string `json:"groupId"`
Type string `json:"type"`
Data map[string]any `json:"data"`
}
type BroadcastSimulationWorker struct {
db *gorm.DB
redis *redis.Client
broadcaster RegionBroadcaster
mu sync.Mutex
cancel context.CancelFunc
config ContinuousSimulationRequest
status ContinuousSimulationStatus
}
type ContinuousSimulationRequest struct {
SysOrigin string `json:"sysOrigin"`
RegionCodes []string `json:"regionCodes"`
EnableLuckyGift bool `json:"enableLuckyGift"`
EnableGameWin bool `json:"enableGameWin"`
MinDelaySeconds int `json:"minDelaySeconds"`
MaxDelaySeconds int `json:"maxDelaySeconds"`
BetAmounts []int64 `json:"betAmounts"`
Multiples []float64 `json:"multiples"`
GameWinAmounts []int64 `json:"gameWinAmounts"`
LuckyGiftWinAmounts []int64 `json:"luckyGiftWinAmounts"`
GiftCandyOptions []int64 `json:"giftCandyOptions"`
GiftQuantityOptions []int64 `json:"giftQuantityOptions"`
}
type ContinuousSimulationStatus struct {
Running bool `json:"running"`
SysOrigin string `json:"sysOrigin"`
RegionCodes []string `json:"regionCodes"`
StartedAt string `json:"startedAt,omitempty"`
LastRunAt string `json:"lastRunAt,omitempty"`
LastError string `json:"lastError,omitempty"`
NextDelaySec int `json:"nextDelaySec"`
LuckyGiftCount int64 `json:"luckyGiftCount"`
GameWinCount int64 `json:"gameWinCount"`
TotalCount int64 `json:"totalCount"`
Config any `json:"config,omitempty"`
UpdateTime time.Time `json:"-"`
}
type continuousCandidateUser struct {
ID int64
}
type continuousCandidateGame struct {
GameID string
Cover string
}
type continuousCandidateRoom struct {
RoomID int64
}
func NewBroadcastSimulationWorker(db *gorm.DB, redisClient *redis.Client, broadcaster RegionBroadcaster) *BroadcastSimulationWorker {
return &BroadcastSimulationWorker{
db: db,
redis: redisClient,
broadcaster: broadcaster,
}
}
func (w *BroadcastSimulationWorker) Start(ctx context.Context, req ContinuousSimulationRequest) (*ContinuousSimulationStatus, error) {
req = normalizeContinuousRequest(req)
if err := validateContinuousRequest(req); err != nil {
return nil, err
}
if w == nil || w.db == nil || w.redis == nil || w.broadcaster == nil {
return nil, common.NewAppError(http.StatusInternalServerError, "simulation_worker_not_configured", "simulation worker is not configured")
}
w.mu.Lock()
if w.cancel != nil {
w.cancel()
}
workerCtx, cancel := context.WithCancel(context.Background())
w.cancel = cancel
w.config = req
w.status = ContinuousSimulationStatus{
Running: true,
SysOrigin: req.SysOrigin,
RegionCodes: append([]string(nil), req.RegionCodes...),
StartedAt: time.Now().Format(adminTimeLayout),
Config: req,
}
status := w.status
w.mu.Unlock()
go w.run(workerCtx, req)
return &status, nil
}
func (w *BroadcastSimulationWorker) Stop() *ContinuousSimulationStatus {
if w == nil {
return &ContinuousSimulationStatus{}
}
w.mu.Lock()
defer w.mu.Unlock()
if w.cancel != nil {
w.cancel()
w.cancel = nil
}
w.status.Running = false
w.status.NextDelaySec = 0
w.status.UpdateTime = time.Now()
status := w.status
status.Config = w.config
return &status
}
func (w *BroadcastSimulationWorker) Status() *ContinuousSimulationStatus {
if w == nil {
return &ContinuousSimulationStatus{}
}
w.mu.Lock()
defer w.mu.Unlock()
status := w.status
status.Config = w.config
return &status
}
func (w *BroadcastSimulationWorker) run(ctx context.Context, req ContinuousSimulationRequest) {
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
for {
delay := randomDelaySeconds(rng, req.MinDelaySeconds, req.MaxDelaySeconds)
w.updateStatus(func(status *ContinuousSimulationStatus) {
status.NextDelaySec = delay
})
timer := time.NewTimer(time.Duration(delay) * time.Second)
select {
case <-ctx.Done():
timer.Stop()
w.updateStatus(func(status *ContinuousSimulationStatus) {
status.Running = false
status.NextDelaySec = 0
})
return
case <-timer.C:
}
w.runOnce(ctx, req, rng)
}
}
func (w *BroadcastSimulationWorker) runOnce(ctx context.Context, req ContinuousSimulationRequest, rng *rand.Rand) {
regionCode := req.RegionCodes[rng.Intn(len(req.RegionCodes))]
userID, err := w.randomOfflineUser(ctx, req.SysOrigin, rng)
if err != nil {
w.setLastError(err)
return
}
roomID, err := w.randomActiveRoom(ctx, req.SysOrigin, rng)
if err != nil {
w.setLastError(err)
return
}
if req.EnableLuckyGift {
winAmount := pickInt64(rng, req.LuckyGiftWinAmounts)
if winAmount <= 0 {
winAmount = pickInt64(rng, req.GameWinAmounts)
}
err := w.sendContinuous(ctx, BroadcastSimulationRequest{
SysOrigin: req.SysOrigin,
RegionCode: regionCode,
Kind: SimulationKindLuckyGift,
SenderUserID: strconv.FormatInt(userID, 10),
RoomID: strconv.FormatInt(roomID, 10),
Multiple: formatFloat(pickFloat64(rng, req.Multiples)),
WinAmount: strconv.FormatInt(winAmount, 10),
GiftCandy: strconv.FormatInt(pickInt64(rng, req.GiftCandyOptions), 10),
GiftQuantity: strconv.FormatInt(pickInt64(rng, req.GiftQuantityOptions), 10),
ProviderOrder: fmt.Sprintf("worker-lucky-%d", time.Now().UnixNano()),
})
if err != nil {
w.setLastError(err)
} else {
w.incrementCount(true)
}
}
if req.EnableGameWin {
game, err := w.randomGame(ctx, req.SysOrigin)
if err != nil {
w.setLastError(err)
return
}
err = w.sendContinuous(ctx, BroadcastSimulationRequest{
SysOrigin: req.SysOrigin,
RegionCode: regionCode,
Kind: SimulationKindGameWin,
SenderUserID: strconv.FormatInt(userID, 10),
RoomID: strconv.FormatInt(roomID, 10),
Multiple: formatFloat(pickFloat64(rng, req.Multiples)),
WinAmount: strconv.FormatInt(pickInt64(rng, req.GameWinAmounts), 10),
GameID: game.GameID,
GameURL: game.Cover,
GameRoundID: fmt.Sprintf("worker-game-%d", time.Now().UnixNano()),
BetAmount: strconv.FormatInt(pickInt64(rng, req.BetAmounts), 10),
})
if err != nil {
w.setLastError(err)
} else {
w.incrementCount(false)
}
}
}
func SendSimulationBroadcast(
ctx context.Context,
broadcaster RegionBroadcaster,
req BroadcastSimulationRequest,
) (*BroadcastSimulationResponse, error) {
if broadcaster == nil {
return nil, common.NewAppError(http.StatusInternalServerError, "region_broadcaster_missing", "region broadcaster is missing")
}
messageType, err := simulationMessageType(req.Kind)
if err != nil {
return nil, err
}
userID, err := parseRequiredInt64(req.SenderUserID, "senderUserId")
if err != nil {
return nil, err
}
roomID := strings.TrimSpace(req.RoomID)
if roomID == "" {
return nil, common.NewAppError(http.StatusBadRequest, "missing_room_id", "roomId is required")
}
amount, err := parseRequiredInt64(req.WinAmount, "winAmount")
if err != nil {
return nil, err
}
multiple, err := parseRequiredFloat64(req.Multiple, "multiple")
if err != nil {
return nil, err
}
if !ShouldBroadcast(multiple, amount) {
return nil, common.NewAppError(http.StatusBadRequest, "broadcast_threshold_not_met", "multiple must be >= 10 and winAmount must be > 0")
}
data := simulationData(messageType, req, amount)
event := BroadcastEvent{
SysOrigin: req.SysOrigin,
RegionCode: req.RegionCode,
Type: messageType,
UserID: userID,
RoomID: roomID,
Multiple: multiple,
Amount: amount,
Data: data,
}
if err := SendRegionBroadcast(ctx, broadcaster, event); err != nil {
return nil, err
}
previewData := cloneData(data)
setRequired(previewData, "userId", strconv.FormatInt(userID, 10))
setRequired(previewData, "sendUserId", strconv.FormatInt(userID, 10))
setRequired(previewData, "senderUserId", strconv.FormatInt(userID, 10))
setRequired(previewData, "roomId", roomID)
setRequired(previewData, "multiple", normalizeMultiple(multiple))
setRequired(previewData, "winMultiple", normalizeMultiple(multiple))
setRequired(previewData, "winAmount", amount)
setRequired(previewData, "awardAmount", amount)
setRequired(previewData, "msg", highWinMessage(previewData, messageType, strconv.FormatInt(userID, 10), multiple, amount))
return &BroadcastSimulationResponse{
SysOrigin: strings.ToUpper(strings.TrimSpace(req.SysOrigin)),
RegionCode: strings.TrimSpace(req.RegionCode),
Type: messageType,
Data: previewData,
}, nil
}
func (w *BroadcastSimulationWorker) sendContinuous(ctx context.Context, req BroadcastSimulationRequest) error {
_, err := SendSimulationBroadcast(ctx, w.broadcaster, req)
return err
}
func (w *BroadcastSimulationWorker) randomOfflineUser(ctx context.Context, sysOrigin string, rng *rand.Rand) (int64, error) {
onlineIDs := w.onlineUserIDs(ctx, sysOrigin)
query := w.db.WithContext(ctx).
Table("user_base_info AS user").
Select("user.id AS id").
Where("user.origin_sys = ?", sysOrigin).
Where("user.id > 0")
if len(onlineIDs) > 0 && len(onlineIDs) <= 1000 {
query = query.Where("user.id NOT IN ?", onlineIDs)
}
var users []continuousCandidateUser
if err := query.Order("RAND()").Limit(50).Scan(&users).Error; err != nil {
return 0, err
}
filtered := make([]continuousCandidateUser, 0, len(users))
onlineSet := make(map[int64]struct{}, len(onlineIDs))
for _, id := range onlineIDs {
onlineSet[id] = struct{}{}
}
for _, user := range users {
if user.ID <= 0 {
continue
}
if _, online := onlineSet[user.ID]; online {
continue
}
filtered = append(filtered, user)
}
if len(filtered) == 0 {
return 0, common.NewAppError(http.StatusNotFound, "simulation_offline_user_not_found", "offline user not found")
}
return filtered[rng.Intn(len(filtered))].ID, nil
}
func (w *BroadcastSimulationWorker) randomActiveRoom(ctx context.Context, sysOrigin string, rng *rand.Rand) (int64, error) {
rooms, err := w.activeRooms(ctx, sysOrigin, 10)
if err != nil {
return 0, err
}
if len(rooms) == 0 {
return 0, nil
}
return rooms[rng.Intn(len(rooms))].RoomID, nil
}
func (w *BroadcastSimulationWorker) randomGame(ctx context.Context, sysOrigin string) (continuousCandidateGame, error) {
var game continuousCandidateGame
err := w.db.WithContext(ctx).
Table("sys_game_list_config").
Select("game_id AS game_id, cover AS cover").
Where("sys_origin = ? AND cover <> ''", sysOrigin).
Order("RAND()").
Limit(1).
Scan(&game).Error
if err != nil {
return continuousCandidateGame{}, err
}
if strings.TrimSpace(game.GameID) == "" || strings.TrimSpace(game.Cover) == "" {
return continuousCandidateGame{}, common.NewAppError(http.StatusNotFound, "simulation_game_not_found", "game cover not found")
}
return game, nil
}
func (w *BroadcastSimulationWorker) activeRooms(ctx context.Context, sysOrigin string, minUsers int64) ([]continuousCandidateRoom, error) {
var cursor uint64
var rooms []continuousCandidateRoom
prefix := fmt.Sprintf("voice_room:online:%s:", strings.ToUpper(strings.TrimSpace(sysOrigin)))
for {
keys, next, err := w.redis.Scan(ctx, cursor, prefix+"*", 100).Result()
if err != nil {
return nil, err
}
for _, key := range keys {
count, err := w.redis.SCard(ctx, key).Result()
if err != nil || count <= minUsers {
continue
}
roomID, err := strconv.ParseInt(strings.TrimPrefix(key, prefix), 10, 64)
if err != nil || roomID <= 0 {
continue
}
rooms = append(rooms, continuousCandidateRoom{RoomID: roomID})
}
cursor = next
if cursor == 0 {
break
}
}
return rooms, nil
}
func (w *BroadcastSimulationWorker) onlineUserIDs(ctx context.Context, sysOrigin string) []int64 {
var cursor uint64
prefix := fmt.Sprintf("voice_room:online:%s:", strings.ToUpper(strings.TrimSpace(sysOrigin)))
seen := map[int64]struct{}{}
for {
keys, next, err := w.redis.Scan(ctx, cursor, prefix+"*", 100).Result()
if err != nil {
return nil
}
for _, key := range keys {
members, err := w.redis.SMembers(ctx, key).Result()
if err != nil {
continue
}
for _, raw := range members {
id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err == nil && id > 0 {
seen[id] = struct{}{}
}
}
}
cursor = next
if cursor == 0 {
break
}
}
ids := make([]int64, 0, len(seen))
for id := range seen {
ids = append(ids, id)
}
return ids
}
func (w *BroadcastSimulationWorker) updateStatus(fn func(*ContinuousSimulationStatus)) {
w.mu.Lock()
defer w.mu.Unlock()
fn(&w.status)
w.status.UpdateTime = time.Now()
}
func (w *BroadcastSimulationWorker) setLastError(err error) {
if err == nil {
return
}
w.updateStatus(func(status *ContinuousSimulationStatus) {
status.LastError = err.Error()
status.LastRunAt = time.Now().Format(adminTimeLayout)
})
}
func (w *BroadcastSimulationWorker) incrementCount(luckyGift bool) {
w.updateStatus(func(status *ContinuousSimulationStatus) {
status.LastError = ""
status.LastRunAt = time.Now().Format(adminTimeLayout)
status.TotalCount++
if luckyGift {
status.LuckyGiftCount++
} else {
status.GameWinCount++
}
})
}
func normalizeContinuousRequest(req ContinuousSimulationRequest) ContinuousSimulationRequest {
req.SysOrigin = strings.ToUpper(strings.TrimSpace(req.SysOrigin))
req.RegionCodes = normalizeStringList(req.RegionCodes)
if req.MinDelaySeconds < 0 {
req.MinDelaySeconds = 0
}
if req.MaxDelaySeconds < req.MinDelaySeconds {
req.MaxDelaySeconds = req.MinDelaySeconds
}
req.BetAmounts = positiveInt64List(req.BetAmounts, []int64{100, 200, 500, 1000})
req.Multiples = positiveFloat64List(req.Multiples, []float64{10, 20, 50})
req.GameWinAmounts = positiveInt64List(req.GameWinAmounts, []int64{1000, 2000, 5000})
req.LuckyGiftWinAmounts = positiveInt64List(req.LuckyGiftWinAmounts, []int64{1000, 2000, 5000})
req.GiftCandyOptions = positiveInt64List(req.GiftCandyOptions, []int64{100, 200, 500})
req.GiftQuantityOptions = positiveInt64List(req.GiftQuantityOptions, []int64{1, 5, 10})
return req
}
func validateContinuousRequest(req ContinuousSimulationRequest) error {
if req.SysOrigin == "" {
return common.NewAppError(http.StatusBadRequest, "missing_sys_origin", "sysOrigin is required")
}
if len(req.RegionCodes) == 0 {
return common.NewAppError(http.StatusBadRequest, "missing_region_codes", "regionCodes is required")
}
if !req.EnableLuckyGift && !req.EnableGameWin {
return common.NewAppError(http.StatusBadRequest, "missing_broadcast_type", "enable at least one broadcast type")
}
return nil
}
func normalizeStringList(values []string) []string {
seen := map[string]struct{}{}
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.ToUpper(strings.TrimSpace(value))
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func positiveInt64List(values []int64, fallback []int64) []int64 {
result := make([]int64, 0, len(values))
for _, value := range values {
if value > 0 {
result = append(result, value)
}
}
if len(result) == 0 {
return append([]int64(nil), fallback...)
}
return result
}
func positiveFloat64List(values []float64, fallback []float64) []float64 {
result := make([]float64, 0, len(values))
for _, value := range values {
if value >= ThresholdMultiple {
result = append(result, value)
}
}
if len(result) == 0 {
return append([]float64(nil), fallback...)
}
return result
}
func randomDelaySeconds(rng *rand.Rand, min, max int) int {
if max <= min {
return min
}
return min + rng.Intn(max-min+1)
}
func pickInt64(rng *rand.Rand, values []int64) int64 {
if len(values) == 0 {
return 0
}
return values[rng.Intn(len(values))]
}
func pickFloat64(rng *rand.Rand, values []float64) float64 {
if len(values) == 0 {
return 0
}
return values[rng.Intn(len(values))]
}
func formatFloat(value float64) string {
return strconv.FormatFloat(value, 'f', -1, 64)
}
func simulationMessageType(kind string) (string, error) {
switch strings.ToUpper(strings.TrimSpace(kind)) {
case SimulationKindLuckyGift:
return MessageTypeLuckyGift, nil
case SimulationKindGameWin:
return MessageTypeBaishunWin, nil
default:
return "", common.NewAppError(http.StatusBadRequest, "invalid_broadcast_kind", "kind must be LUCKY_GIFT or GAME_WIN")
}
}
func simulationData(messageType string, req BroadcastSimulationRequest, amount int64) map[string]any {
now := strconv.FormatInt(time.Now().UnixMilli(), 10)
switch messageType {
case MessageTypeLuckyGift:
data := map[string]any{
"businessId": firstNonBlank(req.ProviderOrder, "admin-sim-lucky-gift-"+now),
"orderId": firstNonBlank(req.ProviderOrder, "admin-sim-order-"+now),
"globalNews": true,
"multipleType": "WIN",
}
setOptional(data, "acceptUserId", req.AcceptUserID)
setOptional(data, "giftId", req.GiftID)
setOptionalInt64(data, "giftCandy", req.GiftCandy)
setOptionalInt64(data, "giftQuantity", req.GiftQuantity)
return data
default:
data := map[string]any{
"gameRoundId": firstNonBlank(req.GameRoundID, "admin-sim-game-round-"+now),
"currencyDiff": amount,
}
setOptional(data, "gameId", req.GameID)
setOptional(data, "gameUrl", req.GameURL)
setOptionalInt64(data, "betAmount", req.BetAmount)
return data
}
}
func setOptional(data map[string]any, key string, value string) {
if value = strings.TrimSpace(value); value != "" {
data[key] = value
}
}
func setOptionalInt64(data map[string]any, key string, value string) {
parsed, err := parseOptionalInt64(value)
if err == nil && parsed > 0 {
data[key] = parsed
}
}
func firstNonBlank(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func parseRequiredInt64(value string, field string) (int64, error) {
parsed, err := parseOptionalInt64(value)
if err != nil || parsed <= 0 {
return 0, common.NewAppError(http.StatusBadRequest, "invalid_"+field, fmt.Sprintf("%s must be a positive integer", field))
}
return parsed, nil
}
func parseOptionalInt64(value string) (int64, error) {
raw := strings.TrimSpace(value)
if raw == "" {
return 0, nil
}
return strconv.ParseInt(raw, 10, 64)
}
func parseRequiredFloat64(value string, field string) (float64, error) {
raw := strings.TrimSpace(value)
if raw == "" {
return 0, common.NewAppError(http.StatusBadRequest, "invalid_"+field, fmt.Sprintf("%s must be a positive number", field))
}
parsed, err := strconv.ParseFloat(raw, 64)
if err != nil || parsed <= 0 {
return 0, common.NewAppError(http.StatusBadRequest, "invalid_"+field, fmt.Sprintf("%s must be a positive number", field))
}
return parsed, nil
}
var _ RegionBroadcaster = (*regionimgroup.Service)(nil)

View File

@ -1,150 +0,0 @@
package highwin
import (
"context"
"fmt"
"math"
"strconv"
"strings"
"chatapp3-golang/internal/service/regionimgroup"
)
const (
ThresholdMultiple = 10
MessageTypeLuckyGift = "GAME_LUCKY_GIFT"
MessageTypeBaishunWin = "GAME_BAISHUN_WIN"
)
type RegionBroadcaster interface {
SendRegionBroadcast(ctx context.Context, req regionimgroup.RegionBroadcastRequest) (*regionimgroup.RegionBroadcastResponse, error)
}
type BroadcastEvent struct {
SysOrigin string
RegionCode string
Type string
UserID int64
RoomID string
Multiple float64
Amount int64
Data map[string]any
}
func IntegralMultiple(amount, base int64) int64 {
if amount <= 0 || base <= 0 {
return 0
}
return amount / base
}
func Multiple(amount, base int64) float64 {
if amount <= 0 || base <= 0 {
return 0
}
return float64(amount) / float64(base)
}
func ShouldBroadcast(multiple float64, amount int64) bool {
return amount > 0 && multiple >= ThresholdMultiple
}
func SendRegionBroadcast(ctx context.Context, broadcaster RegionBroadcaster, event BroadcastEvent) error {
if broadcaster == nil || !ShouldBroadcast(event.Multiple, event.Amount) {
return nil
}
messageType := strings.TrimSpace(event.Type)
if messageType == "" {
return fmt.Errorf("high win broadcast type is required")
}
if event.UserID <= 0 {
return fmt.Errorf("high win broadcast user id is required")
}
roomID := strings.TrimSpace(event.RoomID)
if roomID == "" {
return fmt.Errorf("high win broadcast room id is required")
}
data := cloneData(event.Data)
userIDText := strconv.FormatInt(event.UserID, 10)
setRequired(data, "userId", userIDText)
setRequired(data, "sendUserId", userIDText)
setRequired(data, "senderUserId", userIDText)
setRequired(data, "roomId", roomID)
setRequired(data, "multiple", normalizeMultiple(event.Multiple))
setRequired(data, "winMultiple", normalizeMultiple(event.Multiple))
setRequired(data, "winAmount", event.Amount)
setRequired(data, "awardAmount", event.Amount)
setRequired(data, "msg", highWinMessage(data, messageType, userIDText, event.Multiple, event.Amount))
_, err := broadcaster.SendRegionBroadcast(ctx, regionimgroup.RegionBroadcastRequest{
SysOrigin: strings.TrimSpace(event.SysOrigin),
RegionCode: strings.TrimSpace(event.RegionCode),
Type: messageType,
Data: data,
})
return err
}
func cloneData(data map[string]any) map[string]any {
clone := map[string]any{}
for key, value := range data {
clone[key] = value
}
return clone
}
func setRequired(data map[string]any, key string, value any) {
data[key] = value
}
func normalizeMultiple(value float64) any {
if value <= 0 {
return 0
}
if math.Trunc(value) == value {
return int64(value)
}
return math.Round(value*100) / 100
}
func highWinMessage(data map[string]any, messageType, fallbackUser string, multiple float64, amount int64) string {
user := firstTextValue(data, "userNickname", "nickname", "account", "actualAccount", "sendUserId", "senderUserId", "userId")
if user == "" {
user = fallbackUser
}
return fmt.Sprintf("%s play %s get x %s win %d coins", user, highWinPlayTarget(messageType), formatMultipleText(multiple), amount)
}
func highWinPlayTarget(messageType string) string {
switch strings.TrimSpace(messageType) {
case MessageTypeLuckyGift:
return "lucky_gift"
case MessageTypeBaishunWin:
return "game"
default:
return "game"
}
}
func firstTextValue(data map[string]any, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(fmt.Sprint(data[key])); value != "" && value != "<nil>" {
return value
}
}
return ""
}
func formatMultipleText(value float64) string {
normalized := normalizeMultiple(value)
switch typed := normalized.(type) {
case int64:
return strconv.FormatInt(typed, 10)
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
default:
return strings.TrimSpace(fmt.Sprint(typed))
}
}

View File

@ -1,71 +0,0 @@
package highwin
import (
"context"
"testing"
"chatapp3-golang/internal/service/regionimgroup"
)
func TestSendRegionBroadcastBuildsRequiredPayload(t *testing.T) {
broadcaster := &fakeRegionBroadcaster{}
err := SendRegionBroadcast(context.Background(), broadcaster, BroadcastEvent{
SysOrigin: "LIKEI",
Type: MessageTypeBaishunWin,
UserID: 1001,
RoomID: "9001",
Multiple: 10.5,
Amount: 2100,
Data: map[string]any{
"gameId": 1146,
},
})
if err != nil {
t.Fatalf("SendRegionBroadcast() error = %v", err)
}
if len(broadcaster.requests) != 1 {
t.Fatalf("requests = %d, want 1", len(broadcaster.requests))
}
req := broadcaster.requests[0]
if req.Type != MessageTypeBaishunWin {
t.Fatalf("type = %q, want %q", req.Type, MessageTypeBaishunWin)
}
if req.Data["userId"] != "1001" || req.Data["roomId"] != "9001" ||
req.Data["winAmount"] != int64(2100) || req.Data["multiple"] != 10.5 ||
req.Data["msg"] != "1001 play game get x 10.5 win 2100 coins" {
t.Fatalf("payload = %+v", req.Data)
}
}
func TestSendRegionBroadcastSkipsBelowThreshold(t *testing.T) {
broadcaster := &fakeRegionBroadcaster{}
err := SendRegionBroadcast(context.Background(), broadcaster, BroadcastEvent{
Type: MessageTypeLuckyGift,
UserID: 1001,
RoomID: "9001",
Multiple: 9,
Amount: 900,
})
if err != nil {
t.Fatalf("SendRegionBroadcast() error = %v", err)
}
if len(broadcaster.requests) != 0 {
t.Fatalf("requests = %d, want 0", len(broadcaster.requests))
}
}
type fakeRegionBroadcaster struct {
requests []regionimgroup.RegionBroadcastRequest
}
func (f *fakeRegionBroadcaster) 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

@ -1,315 +0,0 @@
package hotgame
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/service/gameprovider"
"gorm.io/gorm"
)
// Key 返回统一厂商标识,和后台游戏来源 HOTGAME 保持一致。
func (p *AppProvider) Key() string {
return vendorType
}
// DisplayName 返回后台和调试接口展示名。
func (p *AppProvider) DisplayName() string {
return displayName
}
// SupportsLaunch 判断统一启动请求是否命中热游配置。
func (p *AppProvider) SupportsLaunch(ctx context.Context, user gameprovider.AuthUser, req gameprovider.LaunchRequest) (bool, error) {
_, err := p.service.findGameRow(ctx, user.SysOrigin, req)
if err == nil {
return true, nil
}
var appErr *common.AppError
if errors.As(err, &appErr) && appErr.Code == "game_not_found" {
return false, nil
}
return false, err
}
// ListShortcutGames 返回统一快捷列表,热游沿用完整列表前 5 个。
func (p *AppProvider) ListShortcutGames(ctx context.Context, user gameprovider.AuthUser, roomID string) ([]gameprovider.RoomGameListItem, error) {
resp, err := p.ListRoomGames(ctx, user, roomID, "")
if err != nil {
return nil, err
}
if len(resp.Items) > 5 {
resp.Items = resp.Items[:5]
}
return resp.Items, nil
}
// ListRoomGames 返回用户当前区域可见的热游房间游戏列表。
func (p *AppProvider) ListRoomGames(ctx context.Context, user gameprovider.AuthUser, roomID, category string) (*gameprovider.RoomGameListResponse, error) {
items, err := p.service.listRoomGames(ctx, user.SysOrigin, user.RegionID, category)
if err != nil {
return nil, err
}
return &gameprovider.RoomGameListResponse{Items: items}, nil
}
// GetRoomState 热游 H5 不需要 Go 持久化房间状态,统一入口只返回空闲态。
func (p *AppProvider) GetRoomState(ctx context.Context, user gameprovider.AuthUser, roomID string) (*gameprovider.RoomStateResponse, error) {
return &gameprovider.RoomStateResponse{RoomID: roomID, State: "IDLE", Provider: vendorType}, nil
}
// LaunchGame 生成热游 H5 URLuid/token/lang 会被替换进热游提供的启动链接。
func (p *AppProvider) LaunchGame(ctx context.Context, user gameprovider.AuthUser, req gameprovider.LaunchRequest, clientIP string) (*gameprovider.LaunchResponse, error) {
row, err := p.service.findGameRow(ctx, user.SysOrigin, req)
if err != nil {
return nil, err
}
runtimeCfg, err := p.service.resolveRuntimeConfigForProfile(ctx, normalizeSysOrigin(user.SysOrigin), row.Profile)
if err != nil {
return nil, err
}
entryURL := buildLaunchURL(row.entryURL(), runtimeCfg, user, req)
sessionID := fmt.Sprintf("hg_%d_%s", user.UserID, row.VendorGameID)
return &gameprovider.LaunchResponse{
ID: row.ConfigID,
GameSessionID: sessionID,
Provider: vendorType,
GameID: row.InternalGameID,
ProviderGameID: row.VendorGameID,
Entry: gameprovider.LaunchEntry{
LaunchMode: defaultIfBlank(row.LaunchMode, launchModeH5),
EntryURL: entryURL,
PreviewURL: row.previewURL(),
DownloadURL: row.entryURL(),
PackageVersion: row.packageVersion(),
Orientation: row.orientation(),
SafeHeight: row.safeHeight(),
},
LaunchConfig: map[string]any{
"uid": launchUID(runtimeCfg, user),
"token": launchToken(runtimeCfg, user),
"lang": launchLang(req),
"gameId": row.VendorGameID,
},
RoomState: gameprovider.RoomStateResponse{
RoomID: req.RoomID,
State: "PLAYING",
Provider: vendorType,
GameSessionID: sessionID,
CurrentGameID: row.InternalGameID,
CurrentProviderGameID: row.VendorGameID,
CurrentGameName: row.name(),
CurrentGameCover: row.cover(),
HostUserID: user.UserID,
},
}, nil
}
// CloseGame 热游关闭只影响客户端 WebViewGo 侧不保留会话状态。
func (p *AppProvider) CloseGame(ctx context.Context, user gameprovider.AuthUser, req gameprovider.CloseRequest) (*gameprovider.RoomStateResponse, error) {
return &gameprovider.RoomStateResponse{RoomID: req.RoomID, State: "IDLE", Provider: vendorType}, nil
}
func (s *Service) listRoomGames(ctx context.Context, sysOrigin, regionID, category string) ([]gameprovider.RoomGameListItem, error) {
sysOrigin = normalizeSysOrigin(sysOrigin)
profile, err := s.activeProfile(ctx, sysOrigin)
if err != nil {
return nil, err
}
var rows []gameRow
query := s.baseGameQuery(ctx, sysOrigin, profile).
Where("cfg.sys_origin = ? AND cfg.is_showcase = 1 AND ext.vendor_type = ?", sysOrigin, vendorType)
if strings.TrimSpace(regionID) != "" {
// 统一游戏列表按运营配置的区域 ID 过滤;空 regions 表示所有区域都可见。
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), roomCategory) {
query = query.Where("cfg.category = ?", category)
}
if err := query.Order("cfg.sort DESC").Scan(&rows).Error; err != nil {
return nil, err
}
items := make([]gameprovider.RoomGameListItem, 0, len(rows))
for _, row := range rows {
items = append(items, row.toListItem())
}
return items, nil
}
func (s *Service) findGameRow(ctx context.Context, sysOrigin string, req gameprovider.LaunchRequest) (gameRow, error) {
sysOrigin = normalizeSysOrigin(sysOrigin)
profile, err := s.activeProfile(ctx, sysOrigin)
if err != nil {
return gameRow{}, err
}
var row gameRow
query := s.baseGameQuery(ctx, sysOrigin, profile).
Where("cfg.sys_origin = ? AND ext.vendor_type = ?", sysOrigin, vendorType)
if req.ID > 0 {
query = query.Where("cfg.id = ?", req.ID)
} else {
query = query.Where("cfg.game_id = ? OR ext.vendor_game_id = ?", strings.TrimSpace(req.GameID), strings.TrimSpace(req.GameID))
}
if err := query.Limit(1).Scan(&row).Error; err != nil {
return gameRow{}, err
}
if row.ConfigID > 0 {
return row, nil
}
return gameRow{}, common.NewAppError(http.StatusNotFound, "game_not_found", "hotgame game config not found")
}
func (s *Service) baseGameQuery(ctx context.Context, sysOrigin, profile string) *gorm.DB {
return s.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,
cfg.cover AS cover,
cfg.sort AS sort,
cfg.full_screen AS full_screen,
ext.vendor_game_id AS vendor_game_id,
ext.launch_mode AS launch_mode,
ext.package_version AS ext_package_version,
ext.preview_url AS ext_preview_url,
ext.package_url AS package_url,
ext.orientation AS ext_orientation,
ext.safe_height AS ext_safe_height,
ext.extra_json AS ext_extra_json,
cat.name AS catalog_name,
cat.cover AS catalog_cover,
cat.preview_url AS catalog_preview_url,
cat.download_url AS catalog_download_url,
cat.package_version AS catalog_package_version,
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 AND ext.profile = ?", profile).
Joins("LEFT JOIN hotgame_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND cat.profile = ext.profile AND cat.vendor_game_id = ext.vendor_game_id")
}
func (r gameRow) toListItem() gameprovider.RoomGameListItem {
return gameprovider.RoomGameListItem{
ID: r.ConfigID,
GameID: r.InternalGameID,
GameType: vendorType,
Provider: vendorType,
ProviderGameID: r.VendorGameID,
Name: r.name(),
Cover: r.cover(),
Category: r.Category,
Sort: r.Sort,
LaunchMode: defaultIfBlank(r.LaunchMode, launchModeH5),
FullScreen: r.FullScreen,
GameMode: 3,
SafeHeight: r.safeHeight(),
Orientation: r.orientation(),
PackageVersion: r.packageVersion(),
Status: defaultIfBlank(r.CatalogStatus, catalogEnabled),
LaunchParams: map[string]any{
"gameType": vendorType,
"lang": defaultLaunchLang,
"screenMode": defaultIfBlank(catalogRawMode(r.ExtExtraJSON), "seven"),
},
}
}
func (r gameRow) name() string {
return defaultIfBlank(r.Name, r.CatalogName)
}
func (r gameRow) cover() string {
// 热游的 preview_url 是带用户占位符的 H5 启动地址,不是图片资源;封面为空时直接返回空值,避免客户端把启动页当图片加载。
return defaultIfBlank(r.Cover, r.CatalogCover)
}
func (r gameRow) previewURL() string {
return defaultIfBlank(r.ExtPreviewURL, r.CatalogPreviewURL)
}
func (r gameRow) entryURL() string {
return defaultIfBlank(r.PackageURL, defaultIfBlank(r.CatalogDownloadURL, r.ExtPreviewURL))
}
func (r gameRow) packageVersion() string {
return defaultIfBlank(r.ExtPackageVersion, r.CatalogPackageVersion)
}
func (r gameRow) orientation() int {
if r.ExtOrientation != nil {
return *r.ExtOrientation
}
return 0
}
func (r gameRow) safeHeight() int {
return r.ExtSafeHeight
}
func buildLaunchURL(entry string, cfg runtimeConfig, user gameprovider.AuthUser, req gameprovider.LaunchRequest) string {
entry = strings.TrimSpace(entry)
uid := launchUID(cfg, user)
token := launchToken(cfg, user)
lang := launchLang(req)
replaced := strings.NewReplacer(
"{uid}", url.QueryEscape(uid),
"{token}", url.QueryEscape(token),
"{lang}", url.QueryEscape(lang),
).Replace(entry)
if replaced != entry {
return replaced
}
parsed, err := url.Parse(entry)
if err != nil {
return entry
}
query := parsed.Query()
query.Set("uid", uid)
query.Set("token", token)
query.Set("lang", lang)
parsed.RawQuery = query.Encode()
return parsed.String()
}
func launchUID(cfg runtimeConfig, user gameprovider.AuthUser) string {
return defaultIfBlank(cfg.TestUID, strconv.FormatInt(user.UserID, 10))
}
func launchToken(cfg runtimeConfig, user gameprovider.AuthUser) string {
return defaultIfBlank(cfg.TestToken, user.Token)
}
func launchLang(req gameprovider.LaunchRequest) string {
for _, key := range []string{"lang", "language", "locale"} {
if req.Params == nil {
break
}
if value := strings.TrimSpace(fmt.Sprint(req.Params[key])); value != "" && value != "<nil>" {
return normalizeLaunchLang(value)
}
}
return defaultLaunchLang
}
func normalizeLaunchLang(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if strings.Contains(value, "-") {
value = strings.SplitN(value, "-", 2)[0]
}
switch value {
case "en", "ar", "id", "tr", "ur", "vi":
return value
default:
return defaultLaunchLang
}
}

View File

@ -1,446 +0,0 @@
package hotgame
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var defaultCatalogGames = []defaultCatalogGame{
{
GameID: "1",
Name: "幸运77(Lucky77)",
TestURL: "https://hk.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}",
ProdURL: "https://cdn-res.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}",
},
{
GameID: "13",
Name: "海盗捕鱼(PirateFishing)",
TestURL: "https://hk.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
ProdURL: "https://cdn-res.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
},
{
GameID: "19",
Name: "FortuneSlot (宝石solt)",
TestURL: "https://hk.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
ProdURL: "https://cdn-res.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
},
{
GameID: "15",
Name: "美味摩天轮(Delicious)",
TestURL: "https://hk.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
ProdURL: "https://cdn-res.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
},
{
GameID: "20",
Name: "水果派对(FruitParty)",
TestURL: "https://hk.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
ProdURL: "https://cdn-res.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
},
{
GameID: "16",
Name: "太空摩天轮(GreedyStar)",
TestURL: "https://hk.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
ProdURL: "https://cdn-res.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
},
}
// SyncCatalog 把表格里确认过的热游可用游戏同步到本地目录表。
func (s *Service) SyncCatalog(ctx context.Context, req SyncCatalogRequest) (*SyncCatalogResponse, error) {
sysOrigin := normalizeSysOrigin(req.SysOrigin)
profile, err := s.requestProfile(ctx, sysOrigin, req.Profile)
if err != nil {
return nil, err
}
if _, err := s.requireRuntimeConfigForProfile(ctx, sysOrigin, profile); err != nil {
return nil, err
}
filter := buildVendorGameIDFilter(req.VendorGameIDs)
resp := &SyncCatalogResponse{}
for _, game := range defaultCatalogGames {
vendorGameID := strings.TrimSpace(game.GameID)
if vendorGameID == "" {
resp.Skipped++
continue
}
if len(filter) > 0 {
if _, ok := filter[vendorGameID]; !ok {
resp.Skipped++
continue
}
}
id, err := utils.NextID()
if err != nil {
return nil, err
}
now := time.Now()
launchURL := hotgameLaunchURLForProfile(game, profile)
record := model.HotgameGameCatalog{
ID: id,
SysOrigin: sysOrigin,
Profile: profile,
InternalGameID: internalGameID(vendorGameID),
VendorGameID: vendorGameID,
Name: game.Name,
Cover: "",
PreviewURL: launchURL,
DownloadURL: launchURL,
PackageVersion: "2026",
RawJSON: utils.MustJSONString(game, ""),
Status: catalogEnabled,
CreateTime: now,
UpdateTime: now,
}
if err := s.db.WithContext(ctx).Clauses(clause.OnConflict{
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", "raw_json", "status", "update_time",
}),
}).Create(&record).Error; err != nil {
return nil, err
}
if req.Force {
resp.Updated++
} else {
resp.Inserted++
}
}
return resp, nil
}
// PageCatalog 返回热游目录,并标记每个目录游戏是否已经进入统一游戏列表。
func (s *Service) PageCatalog(ctx context.Context, sysOrigin, profile, keyword string, cursor, limit int) (*CatalogPageResponse, error) {
sysOrigin = normalizeSysOrigin(sysOrigin)
profile, err := s.requestProfile(ctx, sysOrigin, profile)
if err != nil {
return nil, err
}
cursor = normalizePageCursor(cursor)
limit = normalizePageLimit(limit)
countQuery := s.buildCatalogQuery(ctx, sysOrigin, profile, keyword)
var total int64
if err := countQuery.Count(&total).Error; err != nil {
return nil, err
}
type row struct {
VendorGameID string
Profile string
InternalGameID string
Name string
Cover string
PreviewURL string
DownloadURL string
PackageVersion string
Status string
AddedConfigID *int64
Showcase *bool
UpdateTime string
}
var rows []row
if err := s.buildCatalogQuery(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,
cat.preview_url AS preview_url,
cat.download_url AS download_url,
cat.package_version AS package_version,
cat.status AS status,
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
`).
Order("CAST(cat.vendor_game_id AS UNSIGNED) ASC").
Limit(limit).
Offset((cursor - 1) * limit).
Scan(&rows).Error; err != nil {
return nil, err
}
items := make([]CatalogItem, 0, len(rows))
for _, row := range rows {
items = append(items, CatalogItem{
VendorGameID: row.VendorGameID,
Profile: normalizeProfile(row.Profile),
GameID: row.InternalGameID,
Name: row.Name,
Cover: row.Cover,
PreviewURL: row.PreviewURL,
DownloadURL: row.DownloadURL,
PackageVersion: row.PackageVersion,
Status: row.Status,
Added: row.AddedConfigID != nil && *row.AddedConfigID > 0,
Showcase: row.Showcase != nil && *row.Showcase,
UpdateTime: row.UpdateTime,
})
}
return &CatalogPageResponse{Cursor: cursor, Limit: limit, Records: items, Total: total}, nil
}
func (s *Service) buildCatalogQuery(ctx context.Context, sysOrigin, profile, keyword string) *gorm.DB {
query := s.db.WithContext(ctx).
Table("hotgame_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 = cat.vendor_game_id
AND ext.enabled = 1
`, vendorType).
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 = ?
`, vendorType).
Where("cat.sys_origin = ? AND cat.profile = ?", sysOrigin, profile)
keyword = strings.TrimSpace(keyword)
if keyword != "" {
like := "%" + keyword + "%"
query = query.Where("cat.name LIKE ? OR cat.internal_game_id LIKE ? OR cat.vendor_game_id LIKE ?", like, like, like)
}
return query
}
// FetchAdminCatalog 供后台按钮调用,热游没有远程目录接口,因此读取内置对接表目录。
func (s *Service) FetchAdminCatalog(ctx context.Context, req SyncCatalogRequest) (*SyncCatalogResponse, error) {
return s.SyncCatalog(ctx, req)
}
// ImportCatalogGames 把热游目录导入统一游戏列表app 侧列表随后会通过统一 provider 返回。
func (s *Service) ImportCatalogGames(ctx context.Context, sysOrigin, profile string, vendorGameIDs []string, defaultShowcase ...bool) (*ImportCatalogResponse, error) {
sysOrigin = normalizeSysOrigin(sysOrigin)
profile, err := s.requestProfile(ctx, sysOrigin, profile)
if err != nil {
return nil, err
}
tx := s.db.WithContext(ctx).Begin()
if tx.Error != nil {
return nil, tx.Error
}
resp, err := s.importCatalogGamesTx(tx, sysOrigin, profile, buildVendorGameIDFilter(vendorGameIDs), resolveImportDefaultShowcase(defaultShowcase...))
if err != nil {
tx.Rollback()
return nil, err
}
if err := tx.Commit().Error; err != nil {
return nil, err
}
s.clearJavaGameListCache(ctx)
return resp, nil
}
// SyncAdminGames 一次完成目录同步和统一游戏列表导入,便于后台首次配置。
func (s *Service) SyncAdminGames(ctx context.Context, req SyncCatalogRequest) (*SyncAdminCatalogResponse, error) {
syncResp, err := s.SyncCatalog(ctx, req)
if err != nil {
return nil, err
}
importResp, err := s.ImportCatalogGames(ctx, req.SysOrigin, req.Profile, req.VendorGameIDs, resolveBoolPtrDefault(req.DefaultShowcase, true))
if err != nil {
return nil, err
}
return &SyncAdminCatalogResponse{
Inserted: syncResp.Inserted,
Updated: syncResp.Updated,
Skipped: syncResp.Skipped,
Existing: importResp.Existing,
Imported: importResp.Imported,
}, nil
}
func (s *Service) importCatalogGamesTx(tx *gorm.DB, sysOrigin, profile string, filter map[string]struct{}, defaultShowcase bool) (*ImportCatalogResponse, error) {
query := tx.Where("sys_origin = ? AND profile = ? AND status = ?", sysOrigin, profile, catalogEnabled)
if len(filter) > 0 {
ids := make([]string, 0, len(filter))
for id := range filter {
ids = append(ids, id)
}
sort.Strings(ids)
query = query.Where("vendor_game_id IN ?", ids)
}
var catalogs []model.HotgameGameCatalog
if err := query.Order("CAST(vendor_game_id AS UNSIGNED) ASC").Find(&catalogs).Error; err != nil {
return nil, err
}
resp := &ImportCatalogResponse{}
for _, catalog := range catalogs {
vendorGameID := strings.TrimSpace(catalog.VendorGameID)
if vendorGameID == "" {
continue
}
now := time.Now()
fullScreen := false
var ext model.SysGameListVendorExt
err := tx.Where("sys_origin = ? AND profile = ? AND vendor_type = ? AND vendor_game_id = ?", sysOrigin, profile, vendorType, vendorGameID).First(&ext).Error
if err != nil && err != gorm.ErrRecordNotFound {
return nil, err
}
if err == nil && ext.GameListConfigID > 0 {
if err := s.refreshExistingGame(tx, ext.GameListConfigID, catalog, fullScreen); err != nil {
return nil, err
}
resp.Existing++
continue
}
nextCfgID, idErr := utils.NextID()
if idErr != nil {
return nil, idErr
}
cfg := model.SysGameListConfig{
ID: nextCfgID,
SysOrigin: sysOrigin,
GameOrigin: vendorType,
GameID: defaultIfBlank(catalog.InternalGameID, vendorGameID),
Name: catalog.Name,
Category: roomCategory,
GameCode: vendorGameID,
// 热游启动地址包含 uid/token/lang 占位符且整体很长,不能在缺少封面时回填到 cover封面为空时交给客户端展示默认占位真正启动地址只放在扩展表里。
Cover: hotgameCatalogCover(catalog),
Showcase: defaultShowcase,
Sort: parseSort(vendorGameID),
FullScreen: fullScreen,
ClientOrigin: "COMMON",
GameMode: "[3]",
}
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,
Profile: profile,
VendorType: vendorType,
VendorGameID: vendorGameID,
LaunchMode: launchModeH5,
PackageVersion: catalog.PackageVersion,
PackageURL: catalog.DownloadURL,
PreviewURL: catalog.PreviewURL,
BridgeSchemaVersion: "1.0",
ExtraJSON: `{"launchParams":{"gameType":"HOTGAME","screenMode":"seven"}}`,
Enabled: true,
CreateTime: now,
UpdateTime: now,
}
if err := tx.Create(&newExt).Error; err != nil {
return nil, err
}
resp.Imported++
}
return resp, nil
}
func (s *Service) refreshExistingGame(tx *gorm.DB, configID int64, catalog model.HotgameGameCatalog, fullScreen bool) error {
// 目录链接来自热游正式表格,重新同步时要同时刷新主表展示信息和扩展表启动入口,避免旧 URL 继续下发给 app。
if err := tx.Model(&model.SysGameListConfig{}).
Where("id = ? AND game_origin = ?", configID, vendorType).
Updates(map[string]any{
"name": catalog.Name,
// 这里和新导入保持一致,只刷新真实封面,避免把 H5 启动链接写入封面列导致 MySQL 长度错误。
"cover": hotgameCatalogCover(catalog),
"full_screen": fullScreen,
}).Error; err != nil {
return err
}
return tx.Model(&model.SysGameListVendorExt{}).
Where("game_list_config_id = ? AND vendor_type = ?", configID, vendorType).
Updates(map[string]any{
"package_version": catalog.PackageVersion,
"package_url": catalog.DownloadURL,
"preview_url": catalog.PreviewURL,
"extra_json": `{"launchParams":{"gameType":"HOTGAME","screenMode":"seven"}}`,
"update_time": time.Now(),
}).Error
}
func hotgameLaunchURLForProfile(game defaultCatalogGame, profile string) string {
if normalizeProfile(profile) == profileTest {
return strings.TrimSpace(game.TestURL)
}
return strings.TrimSpace(game.ProdURL)
}
func hotgameCatalogCover(catalog model.HotgameGameCatalog) string {
return strings.TrimSpace(catalog.Cover)
}
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 buildVendorGameIDFilter(vendorGameIDs []string) map[string]struct{} {
filter := make(map[string]struct{}, len(vendorGameIDs))
for _, id := range vendorGameIDs {
id = strings.TrimSpace(id)
if id != "" {
filter[id] = struct{}{}
}
}
return filter
}
func normalizePageCursor(cursor int) int {
if cursor <= 0 {
return 1
}
return cursor
}
func normalizePageLimit(limit int) int {
if limit <= 0 {
return 20
}
if limit > 200 {
return 200
}
return limit
}
func parseSort(value string) int64 {
var sortValue int64
_, _ = fmt.Sscanf(strings.TrimSpace(value), "%d", &sortValue)
return sortValue
}
func catalogRawMode(rawJSON string) string {
var raw struct {
Mode string `json:"mode"`
}
_ = json.Unmarshal([]byte(strings.TrimSpace(rawJSON)), &raw)
return strings.TrimSpace(raw.Mode)
}

View File

@ -1,135 +0,0 @@
package hotgame
import (
"context"
"strings"
"testing"
"time"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/service/gameprovider"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestSyncImportAndListRoomGames(t *testing.T) {
db := newHotgameTestDB(t)
service := NewService(config.Config{}, db, nil)
resp, err := service.SyncCatalog(context.Background(), SyncCatalogRequest{
SysOrigin: "LIKEI",
Profile: "PROD",
VendorGameIDs: []string{"1"},
})
if err != nil {
t.Fatalf("SyncCatalog() error = %v", err)
}
if resp.Inserted != 1 {
t.Fatalf("SyncCatalog() inserted = %d, want 1", resp.Inserted)
}
importResp, err := service.ImportCatalogGames(context.Background(), "LIKEI", "PROD", []string{"1"}, true)
if err != nil {
t.Fatalf("ImportCatalogGames() error = %v", err)
}
if importResp.Imported != 1 {
t.Fatalf("ImportCatalogGames() imported = %d, want 1", importResp.Imported)
}
var cfg model.SysGameListConfig
if err := db.Where("game_origin = ? AND game_id = ?", vendorType, "hg_1").First(&cfg).Error; err != nil {
t.Fatalf("load imported game config: %v", err)
}
if cfg.Cover != "" {
t.Fatalf("imported cover = %q, want blank because launch url must stay out of cover", cfg.Cover)
}
var ext model.SysGameListVendorExt
if err := db.Where("game_list_config_id = ? AND vendor_type = ?", cfg.ID, vendorType).First(&ext).Error; err != nil {
t.Fatalf("load imported vendor ext: %v", err)
}
if !strings.Contains(ext.PreviewURL, "uid={uid}") || !strings.Contains(ext.PackageURL, "token={token}") {
t.Fatalf("vendor ext lost launch url placeholders: preview=%q package=%q", ext.PreviewURL, ext.PackageURL)
}
provider := NewAppProvider(service)
list, err := provider.ListRoomGames(context.Background(), gameprovider.AuthUser{UserID: 1234567, SysOrigin: "LIKEI"}, "room-1", "")
if err != nil {
t.Fatalf("ListRoomGames() error = %v", err)
}
if len(list.Items) != 1 {
t.Fatalf("ListRoomGames() items = %d, want 1", len(list.Items))
}
item := list.Items[0]
if item.Provider != vendorType || item.ProviderGameID != "1" || item.GameID != "hg_1" {
t.Fatalf("ListRoomGames() item = %+v", item)
}
if item.Cover != "" {
t.Fatalf("ListRoomGames() cover = %q, want blank because hotgame preview url is not an image", item.Cover)
}
if item.LaunchParams["screenMode"] != "seven" {
t.Fatalf("ListRoomGames() screenMode = %v, want seven", item.LaunchParams["screenMode"])
}
}
func TestLaunchGameReplacesHotgameURLParams(t *testing.T) {
db := newHotgameTestDB(t)
service := NewService(config.Config{}, db, nil)
if _, err := service.SyncAdminGames(context.Background(), SyncCatalogRequest{
SysOrigin: "LIKEI",
Profile: "PROD",
VendorGameIDs: []string{"1"},
}); err != nil {
t.Fatalf("SyncAdminGames() error = %v", err)
}
provider := NewAppProvider(service)
resp, err := provider.LaunchGame(
context.Background(),
gameprovider.AuthUser{UserID: 1234567, SysOrigin: "LIKEI", Token: "token=="},
gameprovider.LaunchRequest{RoomID: "room-1", GameID: "hg_1", Params: map[string]any{"lang": "tr-TR"}},
"127.0.0.1",
)
if err != nil {
t.Fatalf("LaunchGame() error = %v", err)
}
if !strings.Contains(resp.Entry.EntryURL, "uid=1234567") {
t.Fatalf("LaunchGame() url missing uid: %s", resp.Entry.EntryURL)
}
if !strings.Contains(resp.Entry.EntryURL, "token=token%3D%3D") {
t.Fatalf("LaunchGame() url missing escaped token: %s", resp.Entry.EntryURL)
}
if !strings.Contains(resp.Entry.EntryURL, "lang=tr") {
t.Fatalf("LaunchGame() url missing lang: %s", resp.Entry.EntryURL)
}
}
func newHotgameTestDB(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.HotgameProviderConfig{},
&model.HotgameGameCatalog{},
&model.SysGameListConfig{},
&model.SysGameListVendorExt{},
); err != nil {
t.Fatalf("migrate sqlite: %v", err)
}
if err := db.Create(&model.HotgameProviderConfig{
ID: 1,
SysOrigin: "LIKEI",
Profile: "PROD",
Active: true,
AppKey: "hotgame-key",
CreateTime: time.Now(),
UpdateTime: time.Now(),
}).Error; err != nil {
t.Fatalf("create provider config: %v", err)
}
return db
}

View File

@ -1,203 +0,0 @@
package hotgame
import (
"context"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (s *Service) defaultRuntimeConfig(sysOrigin string) runtimeConfig {
return runtimeConfig{
SysOrigin: normalizeSysOrigin(sysOrigin),
Profile: profileProd,
AppKey: strings.TrimSpace(s.cfg.Hotgame.AppKey),
CallbackBaseURL: strings.TrimSpace(s.cfg.Hotgame.CallbackBaseURL),
}
}
func (s *Service) activeProfile(ctx context.Context, sysOrigin string) (string, error) {
sysOrigin = normalizeSysOrigin(sysOrigin)
var row model.HotgameProviderConfig
err := s.db.WithContext(ctx).
Where("sys_origin = ? AND active = ?", sysOrigin, true).
Order("update_time DESC").
Limit(1).
First(&row).Error
if err == nil {
return normalizeProfile(row.Profile), nil
}
if err == gorm.ErrRecordNotFound {
return profileProd, nil
}
return "", err
}
func (s *Service) requestProfile(ctx context.Context, sysOrigin, profile string) (string, error) {
if strings.TrimSpace(profile) != "" {
return normalizeProfile(profile), nil
}
return s.activeProfile(ctx, sysOrigin)
}
func (s *Service) resolveRuntimeConfigForProfile(ctx context.Context, sysOrigin, profile string) (runtimeConfig, error) {
sysOrigin = normalizeSysOrigin(sysOrigin)
profile = normalizeProfile(profile)
base := s.defaultRuntimeConfig(sysOrigin)
base.Profile = profile
var row model.HotgameProviderConfig
err := s.db.WithContext(ctx).
Where("sys_origin = ? AND profile = ?", sysOrigin, profile).
Limit(1).
First(&row).Error
if err == nil {
return configToRuntime(base, row), nil
}
if err == gorm.ErrRecordNotFound {
return base, nil
}
return runtimeConfig{}, err
}
func (s *Service) requireRuntimeConfigForProfile(ctx context.Context, sysOrigin, profile string) (runtimeConfig, error) {
cfg, err := s.resolveRuntimeConfigForProfile(ctx, sysOrigin, profile)
if err != nil {
return runtimeConfig{}, err
}
if strings.TrimSpace(cfg.AppKey) == "" {
return runtimeConfig{}, common.NewAppError(http.StatusBadRequest, "hotgame_config_missing", "hotgame appKey is required")
}
return cfg, nil
}
// GetProviderConfig 读取某个系统、某个环境的热游配置,并附带当前启用环境。
func (s *Service) GetProviderConfig(ctx context.Context, sysOrigin, profile string) (*ProviderConfigItem, error) {
sysOrigin = normalizeSysOrigin(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.HotgameProviderConfig
_ = s.db.WithContext(ctx).
Where("sys_origin = ? AND profile = ?", sysOrigin, profile).
Limit(1).
First(&row).Error
return &ProviderConfigItem{
ID: row.ID,
SysOrigin: runtimeCfg.SysOrigin,
Profile: runtimeCfg.Profile,
ActiveProfile: activeProfile,
Active: runtimeCfg.Profile == activeProfile,
AppKey: runtimeCfg.AppKey,
CallbackBaseURL: runtimeCfg.CallbackBaseURL,
TestUID: runtimeCfg.TestUID,
TestToken: runtimeCfg.TestToken,
UpdateTime: formatTime(row.UpdateTime),
}, nil
}
// SaveProviderConfig 保存热游配置;首个配置自动设为当前启用环境。
func (s *Service) SaveProviderConfig(ctx context.Context, req SaveProviderConfigRequest) (*ProviderConfigItem, error) {
sysOrigin := normalizeSysOrigin(req.SysOrigin)
profile := normalizeProfile(req.Profile)
appKey := strings.TrimSpace(req.AppKey)
if appKey == "" {
return nil, common.NewAppError(http.StatusBadRequest, "app_key_required", "appKey is required")
}
id, err := utils.NextID()
if err != nil {
return nil, err
}
now := time.Now()
row := model.HotgameProviderConfig{
ID: id,
SysOrigin: sysOrigin,
Profile: profile,
AppKey: appKey,
CallbackBaseURL: strings.TrimSpace(req.CallbackBaseURL),
TestUID: strings.TrimSpace(req.TestUID),
TestToken: strings.TrimSpace(req.TestToken),
CreateTime: now,
UpdateTime: now,
}
var activeCount int64
if err := s.db.WithContext(ctx).
Model(&model.HotgameProviderConfig{}).
Where("sys_origin = ? AND active = ?", sysOrigin, true).
Count(&activeCount).Error; err != nil {
return nil, err
}
row.Active = activeCount == 0
if err := s.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "sys_origin"}, {Name: "profile"}},
DoUpdates: clause.AssignmentColumns([]string{
"app_key", "callback_base_url", "test_uid", "test_token", "update_time",
}),
}).Create(&row).Error; err != nil {
return nil, err
}
if row.Active {
s.clearJavaGameListCache(ctx)
}
return s.GetProviderConfig(ctx, sysOrigin, profile)
}
// ActivateProviderProfile 切换 app 侧读取的热游环境,并清理 Java 老游戏列表缓存。
func (s *Service) ActivateProviderProfile(ctx context.Context, req ActivateProviderProfileRequest) (*ProviderConfigItem, error) {
sysOrigin := normalizeSysOrigin(req.SysOrigin)
profile := normalizeProfile(req.Profile)
var row model.HotgameProviderConfig
if err := s.db.WithContext(ctx).
Where("sys_origin = ? AND profile = ?", sysOrigin, profile).
Limit(1).
First(&row).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, common.NewAppError(http.StatusBadRequest, "profile_config_missing", "profile config is missing")
}
return nil, err
}
now := time.Now()
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.HotgameProviderConfig{}).
Where("sys_origin = ?", sysOrigin).
Updates(map[string]any{"active": false, "update_time": now}).Error; err != nil {
return err
}
return tx.Model(&model.HotgameProviderConfig{}).
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)
}
func (s *Service) clearJavaGameListCache(ctx context.Context) {
if s.cache == nil {
return
}
_ = s.cache.Del(ctx, "GAME_LIST").Err()
}

View File

@ -1,235 +0,0 @@
package hotgame
import (
"context"
"strings"
"time"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/service/gameprovider"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
const (
vendorType = "HOTGAME"
displayName = "热游"
profileProd = "PROD"
profileTest = "TEST"
launchModeH5 = "H5_REMOTE"
catalogEnabled = "ENABLED"
roomCategory = "CHAT_ROOM"
defaultLaunchLang = "en"
)
type dbHandle interface {
WithContext(ctx context.Context) *gorm.DB
}
// Service 负责热游后台配置、目录导入、app 列表和启动 URL 生成。
type Service struct {
cfg config.Config
db dbHandle
cache redis.Cmdable
}
// NewService 创建热游服务db 为空时路由仍可安全跳过。
func NewService(cfg config.Config, db dbHandle, cache redis.Cmdable) *Service {
return &Service{cfg: cfg, db: db, cache: cache}
}
// runtimeConfig 是某个系统、某个环境下实际用于启动和回调验签的配置快照。
type runtimeConfig struct {
SysOrigin string
Profile string
AppKey string
CallbackBaseURL string
TestUID string
TestToken string
}
// ProviderConfigItem 是后台热游配置页读取到的配置。
type ProviderConfigItem struct {
ID int64 `json:"id"`
SysOrigin string `json:"sysOrigin"`
Profile string `json:"profile"`
ActiveProfile string `json:"activeProfile"`
Active bool `json:"active"`
AppKey string `json:"appKey"`
CallbackBaseURL string `json:"callbackBaseUrl"`
TestUID string `json:"testUid"`
TestToken string `json:"testToken"`
UpdateTime string `json:"updateTime"`
}
// SaveProviderConfigRequest 是后台保存热游接入配置的入参。
type SaveProviderConfigRequest struct {
SysOrigin string `json:"sysOrigin"`
Profile string `json:"profile"`
AppKey string `json:"appKey"`
CallbackBaseURL string `json:"callbackBaseUrl"`
TestUID string `json:"testUid"`
TestToken string `json:"testToken"`
}
// ActivateProviderProfileRequest 是后台切换当前启用热游环境的入参。
type ActivateProviderProfileRequest struct {
SysOrigin string `json:"sysOrigin"`
Profile string `json:"profile"`
}
// SyncCatalogRequest 是后台同步或导入热游目录的入参。
type SyncCatalogRequest struct {
SysOrigin string `json:"sysOrigin"`
Profile string `json:"profile"`
VendorGameIDs []string `json:"vendorGameIds"`
Force bool `json:"force"`
DefaultShowcase *bool `json:"defaultShowcase"`
}
// SyncCatalogResponse 是热游目录同步结果。
type SyncCatalogResponse struct {
Inserted int `json:"inserted"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
}
// ImportCatalogResponse 是热游目录导入统一游戏列表的结果。
type ImportCatalogResponse struct {
Existing int `json:"existing"`
Imported int `json:"imported"`
}
// SyncAdminCatalogResponse 是“同步目录并导入游戏”的合并结果。
type SyncAdminCatalogResponse struct {
Inserted int `json:"inserted"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
Existing int `json:"existing"`
Imported int `json:"imported"`
}
// CatalogItem 是后台热游目录表格行。
type CatalogItem struct {
VendorGameID string `json:"vendorGameId"`
Profile string `json:"profile"`
GameID string `json:"gameId"`
Name string `json:"name"`
Cover string `json:"cover"`
PreviewURL string `json:"previewUrl"`
DownloadURL string `json:"downloadUrl"`
PackageVersion string `json:"packageVersion"`
Status string `json:"status"`
Added bool `json:"added"`
Showcase bool `json:"showcase"`
UpdateTime string `json:"updateTime"`
}
// CatalogPageResponse 是热游目录分页响应。
type CatalogPageResponse struct {
Cursor int `json:"cursor"`
Limit int `json:"limit"`
Records []CatalogItem `json:"records"`
Total int64 `json:"total"`
}
type defaultCatalogGame struct {
GameID string
Name string
TestURL string
ProdURL string
}
type gameRow struct {
ConfigID int64
SysOrigin string
Profile string
InternalGameID string
Name string
Category string
Cover string
Sort int64
FullScreen bool
VendorGameID string
LaunchMode string
ExtPackageVersion string
ExtPreviewURL string
PackageURL string
ExtOrientation *int
ExtSafeHeight int
ExtExtraJSON string
CatalogName string
CatalogCover string
CatalogPreviewURL string
CatalogDownloadURL string
CatalogPackageVersion string
CatalogStatus string
}
var _ gameprovider.Provider = (*AppProvider)(nil)
// AppProvider 把热游服务适配到统一游戏厂商注册表。
type AppProvider struct {
service *Service
}
// NewAppProvider 创建热游 app 侧 provider。
func NewAppProvider(service *Service) *AppProvider {
if service == nil {
return nil
}
return &AppProvider{service: service}
}
func normalizeSysOrigin(sysOrigin string) string {
sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin))
if sysOrigin == "" {
return "LIKEI"
}
return sysOrigin
}
func normalizeProfile(profile string) string {
switch strings.ToUpper(strings.TrimSpace(profile)) {
case "", "PROD", "PRODUCT", "PRODUCTION", "ONLINE", "OFFICIAL", "FORMAL":
return profileProd
case "TEST", "TESTING", "SANDBOX", "DEV":
return profileTest
default:
return strings.ToUpper(strings.TrimSpace(profile))
}
}
func defaultIfBlank(value, fallback string) string {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
return strings.TrimSpace(fallback)
}
func formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("2006-01-02 15:04:05")
}
func configToRuntime(base runtimeConfig, row model.HotgameProviderConfig) runtimeConfig {
base.SysOrigin = normalizeSysOrigin(defaultIfBlank(row.SysOrigin, base.SysOrigin))
base.Profile = normalizeProfile(defaultIfBlank(row.Profile, base.Profile))
base.AppKey = defaultIfBlank(row.AppKey, base.AppKey)
base.CallbackBaseURL = defaultIfBlank(row.CallbackBaseURL, base.CallbackBaseURL)
base.TestUID = strings.TrimSpace(row.TestUID)
base.TestToken = strings.TrimSpace(row.TestToken)
return base
}
func internalGameID(vendorGameID string) string {
vendorGameID = strings.TrimSpace(vendorGameID)
if vendorGameID == "" {
return ""
}
return "hg_" + vendorGameID
}

View File

@ -47,7 +47,7 @@ func (p *AppProvider) ListShortcutGames(ctx context.Context, user gameprovider.A
}
func (p *AppProvider) ListRoomGames(ctx context.Context, user gameprovider.AuthUser, roomID, category string) (*gameprovider.RoomGameListResponse, error) {
items, err := p.service.listRoomGames(ctx, user.SysOrigin, user.RegionID, category)
items, err := p.service.listRoomGames(ctx, user.SysOrigin, category)
if err != nil {
return nil, err
}
@ -116,7 +116,7 @@ func (p *AppProvider) CloseGame(ctx context.Context, user gameprovider.AuthUser,
return &gameprovider.RoomStateResponse{RoomID: req.RoomID, State: "IDLE", Provider: vendorType}, nil
}
func (s *Service) listRoomGames(ctx context.Context, sysOrigin, regionID, category string) ([]gameprovider.RoomGameListItem, error) {
func (s *Service) listRoomGames(ctx context.Context, sysOrigin, category string) ([]gameprovider.RoomGameListItem, error) {
sysOrigin = normalizeSysOrigin(sysOrigin)
profile, err := s.activeProfile(ctx, sysOrigin)
if err != nil {
@ -125,10 +125,6 @@ func (s *Service) listRoomGames(ctx context.Context, sysOrigin, regionID, catego
var rows []gameRow
query := s.baseGameQuery(ctx, sysOrigin, profile).
Where("cfg.sys_origin = ? AND cfg.is_showcase = 1 AND ext.vendor_type = ?", sysOrigin, vendorType)
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), roomCategory) {
query = query.Where("cfg.category = ?", category)
}

View File

@ -140,8 +140,6 @@ func (s *LuckyGiftService) Draw(ctx context.Context, req LuckyGiftDrawRequest) (
return nil, err
}
s.publishHighWinRegionBroadcasts(ctx, req, results, balanceAfter)
return resp, nil
}

View File

@ -1,51 +0,0 @@
package luckygift
import (
"context"
"log"
"strconv"
"chatapp3-golang/internal/service/highwin"
)
func (s *LuckyGiftService) publishHighWinRegionBroadcasts(ctx context.Context, req LuckyGiftDrawRequest, results []LuckyGiftDrawResult, balanceAfter int64) {
if s == nil || s.highWinBroadcaster == nil || req.GiftPrice <= 0 {
return
}
for _, result := range results {
if result.RewardNum <= 0 {
continue
}
multiple := highwin.IntegralMultiple(result.RewardNum, req.GiftPrice)
if !highwin.ShouldBroadcast(float64(multiple), result.RewardNum) {
continue
}
data := map[string]any{
"businessId": req.BusinessID,
"orderId": result.ID,
"providerOrderId": result.OrderID,
"acceptUserId": strconv.FormatInt(result.AcceptUserID, 10),
"giftId": strconv.FormatInt(req.GiftID, 10),
"giftCandy": req.GiftPrice,
"giftQuantity": req.GiftNum,
"multipleType": luckyGiftMultipleType(multiple),
"balance": balanceAfter,
"globalNews": true,
}
if err := highwin.SendRegionBroadcast(ctx, s.highWinBroadcaster, highwin.BroadcastEvent{
SysOrigin: req.SysOrigin,
Type: highwin.MessageTypeLuckyGift,
UserID: req.SendUserID,
RoomID: strconv.FormatInt(req.RoomID, 10),
Multiple: float64(multiple),
Amount: result.RewardNum,
Data: data,
}); err != nil {
log.Printf("send lucky gift high win region broadcast failed businessId=%s orderId=%s: %v", req.BusinessID, result.ID, err)
}
}
}
func luckyGiftMultipleType(_ int64) string {
return "WIN"
}

View File

@ -1,48 +0,0 @@
package luckygift
import (
"context"
"testing"
"chatapp3-golang/internal/service/regionimgroup"
)
func TestPublishHighWinRegionBroadcastsSendsOnlyTenTimesAndAbove(t *testing.T) {
broadcaster := &fakeLuckyGiftRegionBroadcaster{}
service := &LuckyGiftService{highWinBroadcaster: broadcaster}
service.publishHighWinRegionBroadcasts(context.Background(), LuckyGiftDrawRequest{
BusinessID: "biz-1",
SysOrigin: "LIKEI",
RoomID: 9001,
SendUserID: 1001,
GiftID: 5001,
GiftPrice: 100,
GiftNum: 1,
}, []LuckyGiftDrawResult{
{ID: "low", AcceptUserID: 2001, RewardNum: 900},
{ID: "high", OrderID: "LG_high", AcceptUserID: 2002, RewardNum: 1000},
}, 90000)
if len(broadcaster.requests) != 1 {
t.Fatalf("requests = %d, want 1", len(broadcaster.requests))
}
req := broadcaster.requests[0]
if req.Type != "GAME_LUCKY_GIFT" {
t.Fatalf("type = %q, want GAME_LUCKY_GIFT", req.Type)
}
if req.Data["sendUserId"] != "1001" || req.Data["roomId"] != "9001" ||
req.Data["multiple"] != int64(10) || req.Data["awardAmount"] != int64(1000) ||
req.Data["acceptUserId"] != "2002" {
t.Fatalf("payload = %+v", req.Data)
}
}
type fakeLuckyGiftRegionBroadcaster struct {
requests []regionimgroup.RegionBroadcastRequest
}
func (f *fakeLuckyGiftRegionBroadcaster) 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,7 +3,6 @@ package luckygift
import (
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/service/highwin"
"context"
"net/http"
"time"
@ -44,7 +43,6 @@ type LuckyGiftService struct {
repo luckyGiftPorts
java luckyGiftGateway
httpClient *http.Client
highWinBroadcaster highwin.RegionBroadcaster
}
// LuckyGiftDrawRequest 是幸运礼物抽奖入参。
@ -62,10 +60,6 @@ type LuckyGiftDrawRequest struct {
Orders []LuckyGiftDrawOrderInput `json:"orders"`
}
func (s *LuckyGiftService) SetHighWinBroadcaster(broadcaster highwin.RegionBroadcaster) {
s.highWinBroadcaster = broadcaster
}
// LuckyGiftDrawOrderInput 表示单个收礼人的抽奖子单。
type LuckyGiftDrawOrderInput struct {
ID string `json:"id"`

View File

@ -1,367 +0,0 @@
package managercenter
import (
"context"
"errors"
"strings"
"time"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
)
type bdLeaderRow struct {
ID int64 `gorm:"column:id;primaryKey"`
Origin string `gorm:"column:sys_origin"`
UserID int64 `gorm:"column:user_id"`
Contact string `gorm:"column:contact"`
RegionID string `gorm:"column:region_id"`
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"`
}
func (bdLeaderRow) TableName() string {
return "room_bd_lead_base_info"
}
type businessDevelopmentBaseInfoRow struct {
ID int64 `gorm:"column:id;primaryKey"`
Origin string `gorm:"column:sys_origin"`
BDLeadUserID *int64 `gorm:"column:bd_lead_user_id"`
UserID int64 `gorm:"column:user_id"`
Contact string `gorm:"column:contact"`
Region string `gorm:"column:region"`
MemberQuantity int64 `gorm:"column:member_quantity"`
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"`
}
func (businessDevelopmentBaseInfoRow) TableName() string {
return "room_business_development_base_info"
}
type userHistoryIdentityRow struct {
UserID int64 `gorm:"column:user_id;primaryKey"`
IsBD bool `gorm:"column:is_bd"`
IsAgent bool `gorm:"column:is_agent"`
IsHost bool `gorm:"column:is_host"`
IsManager bool `gorm:"column:is_manager"`
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"`
}
func (userHistoryIdentityRow) TableName() string {
return "user_history_identity"
}
type administratorRow struct {
ID int64 `gorm:"column:id;primaryKey"`
UserID int64 `gorm:"column:user_id"`
Status bool `gorm:"column:status"`
}
func (administratorRow) TableName() string {
return "sys_administrator"
}
type bdLeaderListRow struct {
ID int64 `gorm:"column:id"`
UserID int64 `gorm:"column:user_id"`
Origin string `gorm:"column:sys_origin"`
Contact string `gorm:"column:contact"`
RegionID string `gorm:"column:region_id"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
Account string `gorm:"column:account"`
UserAvatar string `gorm:"column:user_avatar"`
UserNickname string `gorm:"column:user_nickname"`
}
func (s *Service) ListBDLeaders(ctx context.Context, user AuthUser) (*BDLeaderListResponse, error) {
manager, err := s.requireManagerInfo(ctx, user)
if err != nil {
return nil, err
}
query := s.db.WithContext(ctx).
Table("room_bd_lead_base_info AS leader").
Select(strings.Join([]string{
"leader.id",
"leader.user_id",
"leader.sys_origin",
"leader.contact",
"leader.region_id",
"leader.create_time",
"leader.update_time",
"user_info.account",
"user_info.user_avatar",
"user_info.user_nickname",
}, ", ")).
Joins("LEFT JOIN user_base_info AS user_info ON user_info.id = leader.user_id AND user_info.is_del = ?", false).
Where("leader.create_user = ?", user.UserID)
if origin := managerSysOrigin(manager, user); origin != "" {
query = query.Where("leader.sys_origin = ?", origin)
}
var rows []bdLeaderListRow
if err := query.Order("leader.create_time DESC, leader.id DESC").Find(&rows).Error; err != nil {
return nil, serverError("bd_leader_query_failed", err.Error())
}
records := make([]BDLeaderView, 0, len(rows))
for _, row := range rows {
records = append(records, bdLeaderListRowToView(row))
}
return &BDLeaderListResponse{Count: len(records), Records: records}, nil
}
func (s *Service) AddBDLeader(ctx context.Context, user AuthUser, req AddBDLeaderRequest) (*AddBDLeaderResponse, error) {
manager, err := s.requireManagerFeature(ctx, user, managerFeatureAddBDLeader)
if err != nil {
return nil, err
}
if req.UserID.Int64() <= 0 {
return nil, badRequest("user_required", "userId is required")
}
target, err := s.loadUserRowByID(ctx, req.UserID.Int64())
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, notFound("user_not_found", "user not found")
}
return nil, serverError("user_query_failed", err.Error())
}
sysOrigin := managerSysOrigin(manager, user)
if sysOrigin == "" {
sysOrigin = normalizeSysOrigin(target.OriginSys)
}
if target.OriginSys != "" && sysOrigin != "" && !strings.EqualFold(target.OriginSys, sysOrigin) {
return nil, badRequest("bd_leader_origin_mismatch", "target user sysOrigin does not match manager")
}
regionID := strings.TrimSpace(req.RegionID)
if regionID == "" {
regionID = strings.TrimSpace(manager.RegionID)
}
if regionID == "" {
return nil, badRequest("manager_region_required", "manager region is required")
}
if exists, err := s.userIsAdministrator(ctx, target.ID); err != nil {
return nil, err
} else if exists {
return nil, badRequest("user_is_admin", "administrator cannot be added as BD leader")
}
if exists, err := s.userIsBDLeader(ctx, target.ID); err != nil {
return nil, err
} else if exists {
return nil, badRequest("user_already_bd_leader", "user is already a BD leader")
}
leaderID, err := utils.NextID()
if err != nil {
return nil, serverError("id_generate_failed", err.Error())
}
now := time.Now()
contact := strings.TrimSpace(req.Contact)
leader := bdLeaderRow{
ID: leaderID,
Origin: sysOrigin,
UserID: target.ID,
Contact: contact,
RegionID: regionID,
CreateTime: now,
UpdateTime: now,
CreateUser: user.UserID,
UpdateUser: user.UserID,
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&leader).Error; err != nil {
return serverError("bd_leader_create_failed", err.Error())
}
if err := upsertBDBaseInfo(tx, leader, user.UserID, now); err != nil {
return err
}
if err := upsertUserHistoryBD(tx, target.ID, user.UserID, now); err != nil {
return err
}
return nil
}); err != nil {
return nil, err
}
return &AddBDLeaderResponse{
Success: true,
Leader: bdLeaderRowToView(leader, target),
}, nil
}
func (s *Service) requireManagerInfo(ctx context.Context, user AuthUser) (teamManagerInfoRow, error) {
if user.UserID <= 0 {
return teamManagerInfoRow{}, unauthorized("invalid_user", "authenticated user is required")
}
if s.db == nil {
return teamManagerInfoRow{}, serviceUnavailable("database_unavailable", "database is unavailable")
}
var row teamManagerInfoRow
err := s.db.WithContext(ctx).
Table("team_manager_base_info").
Where("user_id = ?", user.UserID).
First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return teamManagerInfoRow{}, forbidden("not_manager", "user is not manager")
}
if err != nil {
return teamManagerInfoRow{}, serverError("manager_query_failed", err.Error())
}
return row, nil
}
func managerSysOrigin(manager teamManagerInfoRow, user AuthUser) string {
if origin := strings.TrimSpace(manager.Origin); origin != "" {
return origin
}
return strings.TrimSpace(user.SysOrigin)
}
func (s *Service) userIsAdministrator(ctx context.Context, userID int64) (bool, error) {
var count int64
if err := s.db.WithContext(ctx).
Table("sys_administrator").
Where("user_id = ? AND status = ?", userID, true).
Count(&count).Error; err != nil {
return false, serverError("administrator_query_failed", err.Error())
}
return count > 0, nil
}
func (s *Service) userIsBDLeader(ctx context.Context, userID int64) (bool, error) {
var count int64
if err := s.db.WithContext(ctx).
Table("room_bd_lead_base_info").
Where("user_id = ?", userID).
Count(&count).Error; err != nil {
return false, serverError("bd_leader_query_failed", err.Error())
}
return count > 0, nil
}
func upsertBDBaseInfo(tx *gorm.DB, leader bdLeaderRow, operatorID int64, now time.Time) error {
var row businessDevelopmentBaseInfoRow
err := tx.Where("user_id = ?", leader.UserID).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
id, idErr := utils.NextID()
if idErr != nil {
return serverError("id_generate_failed", idErr.Error())
}
leadUserID := leader.UserID
return tx.Create(&businessDevelopmentBaseInfoRow{
ID: id,
Origin: leader.Origin,
BDLeadUserID: &leadUserID,
UserID: leader.UserID,
Contact: leader.Contact,
Region: leader.RegionID,
MemberQuantity: 0,
CreateTime: now,
UpdateTime: now,
CreateUser: operatorID,
UpdateUser: operatorID,
}).Error
}
if err != nil {
return serverError("bd_info_query_failed", err.Error())
}
if row.BDLeadUserID != nil && *row.BDLeadUserID > 0 {
return badRequest("user_already_bound_bd_leader", "user already has a BD leader")
}
updates := map[string]any{
"bd_lead_user_id": leader.UserID,
"region": leader.RegionID,
"update_time": now,
"update_user": operatorID,
}
if leader.Contact != "" {
updates["contact"] = leader.Contact
}
if leader.Origin != "" {
updates["sys_origin"] = leader.Origin
}
if err := tx.Model(&businessDevelopmentBaseInfoRow{}).
Where("id = ?", row.ID).
Updates(updates).Error; err != nil {
return serverError("bd_info_update_failed", err.Error())
}
return nil
}
func upsertUserHistoryBD(tx *gorm.DB, userID int64, operatorID int64, now time.Time) error {
var row userHistoryIdentityRow
err := tx.Where("user_id = ?", userID).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return tx.Create(&userHistoryIdentityRow{
UserID: userID,
IsBD: true,
CreateTime: now,
UpdateTime: now,
CreateUser: operatorID,
UpdateUser: operatorID,
}).Error
}
if err != nil {
return serverError("history_identity_query_failed", err.Error())
}
if err := tx.Model(&userHistoryIdentityRow{}).
Where("user_id = ?", userID).
Updates(map[string]any{
"is_bd": true,
"update_time": now,
"update_user": operatorID,
}).Error; err != nil {
return serverError("history_identity_update_failed", err.Error())
}
return nil
}
func bdLeaderRowToView(leader bdLeaderRow, user userBaseInfoRow) BDLeaderView {
return BDLeaderView{
ID: ID(leader.ID),
UserID: ID(leader.UserID),
Account: user.Account,
UserAvatar: user.UserAvatar,
UserNickname: user.UserNickname,
Contact: leader.Contact,
RegionID: leader.RegionID,
OriginSys: leader.Origin,
CreateTime: leader.CreateTime,
UpdateTime: leader.UpdateTime,
}
}
func bdLeaderListRowToView(row bdLeaderListRow) BDLeaderView {
return BDLeaderView{
ID: ID(row.ID),
UserID: ID(row.UserID),
Account: row.Account,
UserAvatar: row.UserAvatar,
UserNickname: row.UserNickname,
Contact: row.Contact,
RegionID: row.RegionID,
OriginSys: row.Origin,
CreateTime: row.CreateTime,
UpdateTime: row.UpdateTime,
}
}

View File

@ -6,7 +6,6 @@ import (
"fmt"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
@ -18,15 +17,6 @@ type teamManagerInfoRow struct {
ID int64 `gorm:"column:id;primaryKey"`
UserID int64 `gorm:"column:user_id"`
Origin string `gorm:"column:sys_origin"`
Contact string `gorm:"column:contact"`
RegionID string `gorm:"column:region_id"`
CanAddBDLeader *bool `gorm:"column:can_add_bd_leader"`
CanGiftProps *bool `gorm:"column:can_gift_props"`
CanBanUser *bool `gorm:"column:can_ban_user"`
CanUnbanUser *bool `gorm:"column:can_unban_user"`
CanChangeCountry *bool `gorm:"column:can_change_country"`
CreateUser int64 `gorm:"column:create_user"`
UpdateUser int64 `gorm:"column:update_user"`
}
func (teamManagerInfoRow) TableName() string {
@ -38,34 +28,17 @@ type userBaseInfoRow struct {
Account string `gorm:"column:account"`
UserAvatar string `gorm:"column:user_avatar"`
UserNickname string `gorm:"column:user_nickname"`
CountryID int64 `gorm:"column:country_id"`
CountryCode string `gorm:"column:country_code"`
CountryName string `gorm:"column:country_name"`
OriginSys string `gorm:"column:origin_sys"`
AccountStatus string `gorm:"column:account_status"`
IsDel bool `gorm:"column:is_del"`
UpdateTime time.Time `gorm:"column:update_time"`
UpdateUser int64 `gorm:"column:update_user"`
}
func (userBaseInfoRow) TableName() string {
return "user_base_info"
}
type sysCountryCodeRow struct {
ID int64 `gorm:"column:id;primaryKey"`
AlphaTwo string `gorm:"column:alpha_two"`
CountryName string `gorm:"column:country_name"`
NationalFlag string `gorm:"column:national_flag"`
PhonePrefix int `gorm:"column:phone_prefix"`
Open bool `gorm:"column:is_open"`
Sort int `gorm:"column:sort"`
}
func (sysCountryCodeRow) TableName() string {
return "sys_country_code"
}
type propsSourceRecordRow struct {
ID int64 `gorm:"column:id;primaryKey"`
Type string `gorm:"column:type"`
@ -96,8 +69,7 @@ func (propsNobleVIPAbilityRow) TableName() string {
}
func (s *Service) GetProfile(ctx context.Context, user AuthUser) (*ProfileResponse, error) {
manager, err := s.requireManagerInfo(ctx, user)
if err != nil {
if err := s.ensureManager(ctx, user); err != nil {
return nil, err
}
@ -105,7 +77,7 @@ func (s *Service) GetProfile(ctx context.Context, user AuthUser) (*ProfileRespon
if err != nil {
return nil, err
}
return &ProfileResponse{Manager: view, Features: managerFeaturesFromRow(manager)}, nil
return &ProfileResponse{Manager: view}, nil
}
func (s *Service) SearchUser(ctx context.Context, user AuthUser, account string) (*UserSearchResponse, error) {
@ -125,34 +97,6 @@ func (s *Service) SearchUser(ctx context.Context, user AuthUser, account string)
return &UserSearchResponse{User: view}, nil
}
// ListCountries 给经理中心返回当前开放国家H5 只展示可绑定目标,避免经理手输无效国家码。
func (s *Service) ListCountries(ctx context.Context, user AuthUser) (*CountryListResponse, error) {
if err := s.ensureManager(ctx, user); err != nil {
return nil, err
}
if s.db == nil {
return nil, serviceUnavailable("database_unavailable", "database is unavailable")
}
var rows []sysCountryCodeRow
if err := s.db.WithContext(ctx).
Table("sys_country_code").
Select("id, alpha_two, country_name, national_flag, phone_prefix, is_open, sort").
Where("is_open = ?", true).
Order("sort ASC, country_name ASC, id ASC").
Find(&rows).Error; err != nil {
return nil, serverError("country_query_failed", err.Error())
}
records := make([]CountryView, 0, len(rows))
for _, row := range rows {
if view, ok := countryRowToView(row); ok {
records = append(records, view)
}
}
return &CountryListResponse{Records: records}, nil
}
func (s *Service) ListProps(ctx context.Context, user AuthUser, propsType string) (*PropsListResponse, error) {
if err := s.ensureManager(ctx, user); err != nil {
return nil, err
@ -200,7 +144,7 @@ func (s *Service) ListProps(ctx context.Context, user AuthUser, propsType string
}
func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsRequest) (*SendPropsResponse, error) {
if _, err := s.requireManagerFeature(ctx, user, managerFeatureGiftProps); err != nil {
if err := s.ensureManager(ctx, user); err != nil {
return nil, err
}
if req.AcceptUserID.Int64() <= 0 {
@ -209,10 +153,6 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
if req.PropsID.Int64() <= 0 {
return nil, badRequest("props_required", "propsId is required")
}
days, err := normalizeGiftDays(req.Days)
if err != nil {
return nil, err
}
if s.java == nil {
return nil, serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable")
}
@ -228,7 +168,7 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
prop, err := s.loadGiftableProps(ctx, req.PropsID.Int64())
if err != nil {
if isAppErrorCode(err, "prop_not_giftable") {
if resp, vipErr := s.sendVIPGift(ctx, user, target, req.PropsID.Int64(), days); vipErr != nil {
if resp, vipErr := s.sendVIPGift(ctx, user, target, req.PropsID.Int64()); vipErr != nil {
return nil, vipErr
} else if resp != nil {
return resp, nil
@ -237,6 +177,7 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
return nil, err
}
days := propsGiftDays(prop.Type)
if prop.Type == propsTypeBadge {
if err := s.java.ActivateTemporaryBadge(ctx, req.AcceptUserID.Int64(), prop.ID, days); err != nil {
return nil, serverError("send_badge_failed", err.Error())
@ -263,7 +204,7 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
if err != nil {
return nil, serverError("vip_ability_query_failed", err.Error())
}
grants = append(grants, nobleVIPAccessoryGrants(ability, days)...)
grants = append(grants, nobleVIPAccessoryGrants(ability)...)
}
for _, grant := range grants {
@ -298,7 +239,7 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
}
func (s *Service) BanUser(ctx context.Context, user AuthUser, req BanUserRequest) (*BanUserResponse, error) {
if _, err := s.requireManagerFeature(ctx, user, managerFeatureBanUser); err != nil {
if err := s.ensureManager(ctx, user); err != nil {
return nil, err
}
if req.UserID.Int64() <= 0 {
@ -345,7 +286,7 @@ func (s *Service) BanUser(ctx context.Context, user AuthUser, req BanUserRequest
}
func (s *Service) UnbanUser(ctx context.Context, user AuthUser, req UnbanUserRequest) (*UnbanUserResponse, error) {
if _, err := s.requireManagerFeature(ctx, user, managerFeatureUnbanUser); err != nil {
if err := s.ensureManager(ctx, user); err != nil {
return nil, err
}
if req.UserID.Int64() <= 0 {
@ -382,105 +323,26 @@ func (s *Service) UnbanUser(ctx context.Context, user AuthUser, req UnbanUserReq
}, nil
}
// ChangeUserCountry 只允许 admin 开启改国家权限的经理移动普通用户国家;目标用户已有业务身份时拒绝,避免跨国家后团队、币商和主播归属数据不一致。
func (s *Service) ChangeUserCountry(ctx context.Context, user AuthUser, req ChangeUserCountryRequest) (*ChangeUserCountryResponse, error) {
if _, err := s.requireManagerFeature(ctx, user, managerFeatureChangeCountry); err != nil {
return nil, err
}
if req.UserID.Int64() <= 0 {
return nil, badRequest("user_required", "userId is required")
}
if req.CountryID.Int64() <= 0 {
return nil, badRequest("country_required", "countryId is required")
func (s *Service) ensureManager(ctx context.Context, user AuthUser) error {
if user.UserID <= 0 {
return unauthorized("invalid_user", "authenticated user is required")
}
if s.db == nil {
return nil, serviceUnavailable("database_unavailable", "database is unavailable")
}
if s.java == nil {
return nil, serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable")
return serviceUnavailable("database_unavailable", "database is unavailable")
}
target, err := s.loadUserRowByID(ctx, req.UserID.Int64())
var count int64
err := s.db.WithContext(ctx).
Table("team_manager_base_info").
Where("user_id = ?", user.UserID).
Count(&count).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, notFound("user_not_found", "user not found")
return serverError("manager_query_failed", err.Error())
}
return nil, serverError("user_query_failed", err.Error())
}
country, err := s.loadOpenCountryByID(ctx, req.CountryID.Int64())
if err != nil {
return nil, err
}
if err := s.ensureUserCountryMovable(ctx, target.ID); err != nil {
return nil, err
}
// 国家写入统一交给 Java复用 country -> user 锁顺序,避免与后台国家批量同步并发时漏数据。
if err := s.java.UpdateUserCountry(ctx, target.ID, country.ID); err != nil {
return nil, serverError("user_country_update_failed", err.Error())
}
if err := s.java.RemoveUserProfileCacheAll(ctx, target.ID); err != nil {
return nil, serverError("remove_profile_cache_failed", err.Error())
}
target.CountryID = country.ID
target.CountryCode = strings.ToUpper(strings.TrimSpace(country.AlphaTwo))
target.CountryName = strings.TrimSpace(country.CountryName)
view, err := s.userRowToView(ctx, target)
if err != nil {
return nil, err
}
return &ChangeUserCountryResponse{Success: true, User: view}, nil
}
func (s *Service) ensureManager(ctx context.Context, user AuthUser) error {
_, err := s.requireManagerInfo(ctx, user)
return err
}
// requireManagerFeature 先确认调用人仍然是团队经理,再检查 admin 给这个经理打开的单项 H5 操作权限。
// 这样前端隐藏按钮和后端写操作使用同一张 team_manager_base_info 表做判断H5 被绕过时也会被服务层拦截。
func (s *Service) requireManagerFeature(ctx context.Context, user AuthUser, feature managerFeature) (teamManagerInfoRow, error) {
manager, err := s.requireManagerInfo(ctx, user)
if err != nil {
return teamManagerInfoRow{}, err
}
features := managerFeaturesFromRow(manager)
if !features.enabled(feature) {
return teamManagerInfoRow{}, forbidden("manager_feature_disabled", "manager feature is disabled")
}
return manager, nil
}
func managerFeaturesFromRow(row teamManagerInfoRow) ManagerFeatures {
return ManagerFeatures{
AddBDLeader: defaultEnabled(row.CanAddBDLeader),
GiftProps: defaultEnabled(row.CanGiftProps),
BanUser: defaultEnabled(row.CanBanUser),
UnbanUser: defaultEnabled(row.CanUnbanUser),
ChangeCountry: defaultEnabled(row.CanChangeCountry),
}
}
func defaultEnabled(value *bool) bool {
return value == nil || *value
}
func (features ManagerFeatures) enabled(feature managerFeature) bool {
switch feature {
case managerFeatureAddBDLeader:
return features.AddBDLeader
case managerFeatureGiftProps:
return features.GiftProps
case managerFeatureBanUser:
return features.BanUser
case managerFeatureUnbanUser:
return features.UnbanUser
case managerFeatureChangeCountry:
return features.ChangeCountry
default:
return false
if count == 0 {
return forbidden("not_manager", "user is not manager")
}
return nil
}
func (s *Service) findUserView(ctx context.Context, account string) (UserView, error) {
@ -548,65 +410,6 @@ func (s *Service) loadUserRowByAccount(ctx context.Context, account string) (use
return row, err
}
func (s *Service) loadOpenCountryByID(ctx context.Context, countryID int64) (sysCountryCodeRow, error) {
var row sysCountryCodeRow
err := s.db.WithContext(ctx).
Table("sys_country_code").
Select("id, alpha_two, country_name, national_flag, phone_prefix, is_open, sort").
Where("id = ? AND is_open = ?", countryID, true).
First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return sysCountryCodeRow{}, notFound("country_not_found", "country not found")
}
if err != nil {
return sysCountryCodeRow{}, serverError("country_query_failed", err.Error())
}
if strings.TrimSpace(row.AlphaTwo) == "" {
return sysCountryCodeRow{}, badRequest("country_code_required", "country code is required")
}
return row, nil
}
// ensureUserCountryMovable 串行检查所有会绑定归属关系的身份;任一命中都返回同一个业务错误,前端显示统一提示。
func (s *Service) ensureUserCountryMovable(ctx context.Context, userID int64) error {
locked, err := s.userHasLockedHistoryIdentity(ctx, userID)
if err != nil {
return err
}
if locked {
return badRequest("user_identity_locked", "user has host, agency, BD, BD leader or coin seller identity and cannot be moved")
}
locked, err = s.userIsBDLeader(ctx, userID)
if err != nil {
return err
}
if locked {
return badRequest("user_identity_locked", "user has host, agency, BD, BD leader or coin seller identity and cannot be moved")
}
if s.java == nil {
return serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable")
}
locked, err = s.java.ExistsFreightSeller(ctx, userID)
if err != nil {
return serverError("coin_seller_query_failed", err.Error())
}
if locked {
return badRequest("user_identity_locked", "user has host, agency, BD, BD leader or coin seller identity and cannot be moved")
}
return nil
}
func (s *Service) userHasLockedHistoryIdentity(ctx context.Context, userID int64) (bool, error) {
var count int64
if err := s.db.WithContext(ctx).
Table("user_history_identity").
Where("user_id = ? AND (is_host = ? OR is_agent = ? OR is_bd = ?)", userID, true, true, true).
Count(&count).Error; err != nil {
return false, serverError("history_identity_query_failed", err.Error())
}
return count > 0, nil
}
func (s *Service) userRowToView(ctx context.Context, row userBaseInfoRow) (UserView, error) {
hasRecharge, canBan := s.userRechargeEligibility(ctx, row.ID)
canBan = canBan && !isUnbannableAccountStatus(row.AccountStatus)
@ -615,7 +418,6 @@ func (s *Service) userRowToView(ctx context.Context, row userBaseInfoRow) (UserV
Account: row.Account,
UserAvatar: row.UserAvatar,
UserNickname: row.UserNickname,
CountryID: ID(row.CountryID),
CountryCode: row.CountryCode,
CountryName: row.CountryName,
OriginSys: row.OriginSys,
@ -633,7 +435,6 @@ func (s *Service) javaProfileToView(ctx context.Context, profile integration.Use
Account: profile.Account,
UserAvatar: profile.UserAvatar,
UserNickname: profile.UserNickname,
CountryID: ID(profile.CountryID),
CountryCode: profile.CountryCode,
CountryName: profile.CountryName,
OriginSys: profile.OriginSys,
@ -659,9 +460,6 @@ func overlayJavaProfile(view *UserView, profile integration.UserProfile) {
if profile.CountryName != "" {
view.CountryName = profile.CountryName
}
if int64(profile.CountryID) > 0 {
view.CountryID = ID(profile.CountryID)
}
if profile.OriginSys != "" {
view.OriginSys = profile.OriginSys
}
@ -673,21 +471,6 @@ func overlayJavaProfile(view *UserView, profile integration.UserProfile) {
}
}
func countryRowToView(row sysCountryCodeRow) (CountryView, bool) {
code := strings.ToUpper(strings.TrimSpace(row.AlphaTwo))
name := strings.TrimSpace(row.CountryName)
if row.ID <= 0 || code == "" || name == "" {
return CountryView{}, false
}
return CountryView{
ID: ID(row.ID),
CountryCode: code,
CountryName: name,
NationalFlag: strings.TrimSpace(row.NationalFlag),
PhonePrefix: row.PhonePrefix,
}, true
}
func (s *Service) userHasRecharge(ctx context.Context, userID int64) (bool, error) {
if userID <= 0 {
return false, nil
@ -753,7 +536,7 @@ func (s *Service) loadGiftableNobleVIPAbility(ctx context.Context, propsID int64
return propsNobleVIPAbilityRow{}, serverError("vip_ability_query_failed", err.Error())
}
if row.VIPLevel > maxGiftableVIPLevel {
return propsNobleVIPAbilityRow{}, badRequest("vip_not_giftable", "only noble VIP level 1-5 can be gifted")
return propsNobleVIPAbilityRow{}, badRequest("vip_not_giftable", "only noble VIP level 1-3 can be gifted")
}
return row, nil
}
@ -791,13 +574,13 @@ type propsGrant struct {
days int
}
func nobleVIPAccessoryGrants(ability integration.PropsNobleVIPAbility, days int) []propsGrant {
func nobleVIPAccessoryGrants(ability integration.PropsNobleVIPAbility) []propsGrant {
grants := make([]propsGrant, 0, 4)
appendIfPositive := func(propsID int64, typ string) {
if propsID <= 0 {
return
}
grants = append(grants, propsGrant{propsID: propsID, typ: typ, days: days})
grants = append(grants, propsGrant{propsID: propsID, typ: typ, days: defaultVIPGiftDays})
}
appendIfPositive(int64(ability.CarID), propsTypeRide)
appendIfPositive(int64(ability.AvatarFrameID), propsTypeAvatarFrame)
@ -851,7 +634,7 @@ func propsRowToView(row propsSourceRecordRow, vipLevels map[int64]int) (PropsVie
if row.Type == propsTypeNobleVIP {
var exists bool
level, exists = vipLevels[row.ID]
if !exists || level > maxGiftableVIPLevel {
if !exists || level > 3 {
return PropsView{}, false
}
}
@ -875,19 +658,6 @@ func propsGiftDays(propsType string) int {
return defaultGiftDays
}
func normalizeGiftDays(days int) (int, error) {
// days=0 兼容尚未升级的旧 H5并按新规则默认赠送 30 天。
if days == 0 {
return defaultGiftDays, nil
}
switch days {
case giftDays30, giftDays60, giftDays90:
return days, nil
default:
return 0, badRequest("invalid_gift_days", "days must be one of 30, 60, 90")
}
}
func normalizePropsType(propsType string) string {
return strings.ToUpper(strings.TrimSpace(propsType))
}

View File

@ -17,8 +17,6 @@ import (
type fakeManagerGateway struct {
recharges map[int64][]integration.UserTotalRecharge
rechargeErr error
freightSellers map[int64]bool
freightSellerErr error
abilities map[int64]integration.PropsNobleVIPAbility
maxAbility integration.PropsNobleVIPAbility
giveRequests []integration.GivePropsBackpackRequest
@ -26,16 +24,9 @@ type fakeManagerGateway struct {
unblockRequests []integration.UnblockAccountRequest
switchIDs []int64
cacheCleared []int64
countryUpdates []fakeCountryUpdate
countryUpdateErr error
badgeActivations []fakeBadgeActivation
}
type fakeCountryUpdate struct {
userID int64
countryID int64
}
type fakeBadgeActivation struct {
userID int64
badgeID int64
@ -46,14 +37,6 @@ func (f *fakeManagerGateway) GetUserProfile(context.Context, int64) (integration
return integration.UserProfile{}, nil
}
func (f *fakeManagerGateway) UpdateUserCountry(_ context.Context, userID int64, countryID int64) error {
if f.countryUpdateErr != nil {
return f.countryUpdateErr
}
f.countryUpdates = append(f.countryUpdates, fakeCountryUpdate{userID: userID, countryID: countryID})
return nil
}
func (f *fakeManagerGateway) GetUserProfileByAccountOne(context.Context, string) (integration.UserProfile, error) {
return integration.UserProfile{}, nil
}
@ -69,13 +52,6 @@ func (f *fakeManagerGateway) MapUserTotalRecharge(_ context.Context, userIDs []i
return result, nil
}
func (f *fakeManagerGateway) ExistsFreightSeller(_ context.Context, userID int64) (bool, error) {
if f.freightSellerErr != nil {
return false, f.freightSellerErr
}
return f.freightSellers[userID], nil
}
func (f *fakeManagerGateway) GivePropsBackpack(_ context.Context, req integration.GivePropsBackpackRequest) error {
f.giveRequests = append(f.giveRequests, req)
return nil
@ -122,14 +98,10 @@ func TestListPropsRequiresAdminFreeAndFiltersVIPLevels(t *testing.T) {
propsSourceRecordRow{ID: 102, Type: propsTypeRide, Name: "Paid ride", AdminFree: false},
propsSourceRecordRow{ID: 201, Type: propsTypeNobleVIP, Name: "VIP 3", AdminFree: true},
propsSourceRecordRow{ID: 204, Type: propsTypeNobleVIP, Name: "VIP 4", AdminFree: true},
propsSourceRecordRow{ID: 205, Type: propsTypeNobleVIP, Name: "VIP 5", AdminFree: true},
propsSourceRecordRow{ID: 206, Type: propsTypeNobleVIP, Name: "VIP 6", AdminFree: true},
)
seedAbilities(t, service.db.(*gorm.DB),
propsNobleVIPAbilityRow{ID: 201, VIPLevel: 3},
propsNobleVIPAbilityRow{ID: 204, VIPLevel: 4},
propsNobleVIPAbilityRow{ID: 205, VIPLevel: 5},
propsNobleVIPAbilityRow{ID: 206, VIPLevel: 6},
)
resp, err := service.ListProps(context.Background(), AuthUser{UserID: 1}, "")
@ -148,16 +120,10 @@ func TestListPropsRequiresAdminFreeAndFiltersVIPLevels(t *testing.T) {
t.Fatalf("records = %+v, non-admin-free props must be hidden", resp.Records)
}
if item, ok := got[201]; !ok || item.Days != defaultVIPGiftDays || item.VIPLevel != 3 {
t.Fatalf("vip 201 = %+v, want level 3 with default VIP days", item)
t.Fatalf("vip 201 = %+v, want level 3 with 3 days", item)
}
if item, ok := got[204]; !ok || item.VIPLevel != 4 {
t.Fatalf("vip 204 = %+v, want level 4 visible", item)
}
if item, ok := got[205]; !ok || item.VIPLevel != 5 {
t.Fatalf("vip 205 = %+v, want level 5 visible", item)
}
if _, ok := got[206]; ok {
t.Fatalf("records = %+v, VIP level 6 must be hidden", resp.Records)
if _, ok := got[204]; ok {
t.Fatalf("records = %+v, VIP level 4 must be hidden", resp.Records)
}
}
@ -170,32 +136,18 @@ func TestListPropsReturnsVIPLevelConfigs(t *testing.T) {
model.VipLevelConfig{ID: 302, SysOrigin: "LIKEI", Level: 2, LevelCode: "VIP2", DisplayName: "VIP 2", Enabled: false, DurationDays: 30},
model.VipLevelConfig{ID: 303, SysOrigin: "ATYOU", Level: 1, LevelCode: "VIP1", DisplayName: "ATYOU VIP 1", Enabled: true, DurationDays: 30},
model.VipLevelConfig{ID: 304, SysOrigin: "LIKEI", Level: 4, LevelCode: "VIP4", DisplayName: "VIP 4", Enabled: true, DurationDays: 30},
model.VipLevelConfig{ID: 305, SysOrigin: "LIKEI", Level: 5, LevelCode: "VIP5", DisplayName: "VIP 5", Enabled: true, DurationDays: 30},
model.VipLevelConfig{ID: 306, SysOrigin: "LIKEI", Level: 6, LevelCode: "VIP6", DisplayName: "VIP 6", Enabled: true, DurationDays: 30},
)
resp, err := service.ListProps(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, propsTypeNobleVIP)
if err != nil {
t.Fatalf("ListProps() error = %v", err)
}
if len(resp.Records) != 3 {
t.Fatalf("records = %+v, want enabled LIKEI vip configs up to level 5", resp.Records)
if len(resp.Records) != 1 {
t.Fatalf("records = %+v, want only one enabled LIKEI vip config", resp.Records)
}
got := map[int64]PropsView{}
for _, item := range resp.Records {
got[item.ID.Int64()] = item
}
if item := got[301]; item.Type != propsTypeNobleVIP || item.VIPLevel != 1 || item.Days != 30 {
t.Fatalf("vip props 301 = %+v, want config 301 level 1", item)
}
if item := got[304]; item.Type != propsTypeNobleVIP || item.VIPLevel != 4 || item.Days != 30 {
t.Fatalf("vip props 304 = %+v, want config 304 level 4", item)
}
if item := got[305]; item.Type != propsTypeNobleVIP || item.VIPLevel != 5 || item.Days != 30 {
t.Fatalf("vip props 305 = %+v, want config 305 level 5", item)
}
if _, ok := got[306]; ok {
t.Fatalf("records = %+v, VIP level 6 must be hidden", resp.Records)
got := resp.Records[0]
if got.ID.Int64() != 301 || got.Type != propsTypeNobleVIP || got.VIPLevel != 1 || got.Days != 30 {
t.Fatalf("vip props = %+v, want config 301 level 1", got)
}
}
@ -215,112 +167,6 @@ func TestGetProfileToleratesRechargeQueryFailure(t *testing.T) {
}
}
func TestGetProfileReturnsManagerFeatures(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManagerWithFeatures(t, db, 1, ManagerFeatures{
AddBDLeader: true,
GiftProps: false,
BanUser: true,
UnbanUser: false,
ChangeCountry: true,
})
seedUsers(t, db, userBaseInfoRow{ID: 1, Account: "1"})
resp, err := service.GetProfile(context.Background(), AuthUser{UserID: 1})
if err != nil {
t.Fatalf("GetProfile() error = %v", err)
}
if !resp.Features.AddBDLeader || resp.Features.GiftProps || !resp.Features.BanUser || resp.Features.UnbanUser || !resp.Features.ChangeCountry {
t.Fatalf("features = %+v, want row switches returned", resp.Features)
}
}
func TestAddBDLeaderCreatesLeaderAndListEntry(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedUsers(t, db,
userBaseInfoRow{ID: 2, Account: "2002", UserNickname: "leader", OriginSys: "LIKEI"},
)
resp, err := service.AddBDLeader(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, AddBDLeaderRequest{
UserID: ID(2),
Contact: "whatsapp",
})
if err != nil {
t.Fatalf("AddBDLeader() error = %v", err)
}
if !resp.Success || resp.Leader.UserID.Int64() != 2 || resp.Leader.RegionID != "SA" {
t.Fatalf("resp = %+v, want created leader for user 2 in manager region", resp)
}
var bdInfo businessDevelopmentBaseInfoRow
if err := db.Where("user_id = ?", int64(2)).First(&bdInfo).Error; err != nil {
t.Fatalf("load bd info: %v", err)
}
if bdInfo.BDLeadUserID == nil || *bdInfo.BDLeadUserID != 2 || bdInfo.Region != "SA" {
t.Fatalf("bd info = %+v, want self-linked BD leader info", bdInfo)
}
var identity userHistoryIdentityRow
if err := db.Where("user_id = ?", int64(2)).First(&identity).Error; err != nil {
t.Fatalf("load identity: %v", err)
}
if !identity.IsBD {
t.Fatalf("identity = %+v, want is_bd=true", identity)
}
list, err := service.ListBDLeaders(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"})
if err != nil {
t.Fatalf("ListBDLeaders() error = %v", err)
}
if list.Count != 1 || list.Records[0].UserID.Int64() != 2 || list.Records[0].Account != "2002" {
t.Fatalf("list = %+v, want one created leader", list)
}
}
func TestAddBDLeaderRejectsExistingLeader(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2002", OriginSys: "LIKEI"})
if err := db.Create(&bdLeaderRow{ID: 10, UserID: 2, Origin: "LIKEI", RegionID: "SA", CreateUser: 1}).Error; err != nil {
t.Fatalf("seed bd leader: %v", err)
}
_, err := service.AddBDLeader(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, AddBDLeaderRequest{UserID: ID(2)})
assertAppErrorCode(t, err, "user_already_bd_leader")
}
func TestAddBDLeaderRejectsTargetFromOtherOrigin(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2002", OriginSys: "ATYOU"})
_, err := service.AddBDLeader(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, AddBDLeaderRequest{UserID: ID(2)})
assertAppErrorCode(t, err, "bd_leader_origin_mismatch")
}
func TestAddBDLeaderRejectsDisabledFeature(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManagerWithFeatures(t, db, 1, ManagerFeatures{GiftProps: true, BanUser: true, UnbanUser: true})
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2002", OriginSys: "LIKEI"})
_, err := service.AddBDLeader(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, AddBDLeaderRequest{UserID: ID(2)})
assertAppErrorCode(t, err, "manager_feature_disabled")
var count int64
if err := db.Model(&bdLeaderRow{}).Where("user_id = ?", int64(2)).Count(&count).Error; err != nil {
t.Fatalf("count bd leaders: %v", err)
}
if count != 0 {
t.Fatalf("bd leader count = %d, want 0", count)
}
}
func TestSendPropsRejectsNonAdminFree(t *testing.T) {
service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
@ -381,23 +227,6 @@ func TestSendNobleVIPGivesAccessoriesAndSwitchesMax(t *testing.T) {
}
}
func TestSendPropsRejectsDisabledFeature(t *testing.T) {
service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManagerWithFeatures(t, db, 1, ManagerFeatures{AddBDLeader: true, BanUser: true, UnbanUser: true})
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2"})
seedProps(t, db, propsSourceRecordRow{ID: 101, Type: propsTypeRide, Name: "Giftable ride", AdminFree: true})
_, err := service.SendProps(context.Background(), AuthUser{UserID: 1}, SendPropsRequest{
AcceptUserID: ID(2),
PropsID: ID(101),
})
assertAppErrorCode(t, err, "manager_feature_disabled")
if len(fake.giveRequests) != 0 {
t.Fatalf("give requests = %+v, want none", fake.giveRequests)
}
}
func TestSendBadgeActivatesBadgeBackpack(t *testing.T) {
service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
@ -437,12 +266,12 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
seedVIPConfigs(t, db, model.VipLevelConfig{
ID: 301,
SysOrigin: "LIKEI",
Level: 5,
LevelCode: "VIP5",
DisplayName: "VIP 5",
Level: 3,
LevelCode: "VIP3",
DisplayName: "VIP 3",
Enabled: true,
DurationDays: 30,
PriceGold: 5000,
PriceGold: 3000,
})
resp, err := service.SendProps(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, SendPropsRequest{
@ -452,7 +281,7 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
if err != nil {
t.Fatalf("SendProps() error = %v", err)
}
if !resp.Success || resp.Type != propsTypeNobleVIP || resp.VIPLevel != 5 || resp.Days != 30 {
if !resp.Success || resp.Type != propsTypeNobleVIP || resp.VIPLevel != 3 || resp.Days != 30 {
t.Fatalf("resp = %+v, want vip gift success", resp)
}
@ -460,8 +289,8 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
if err := db.Where("sys_origin = ? AND user_id = ?", "LIKEI", int64(2)).First(&state).Error; err != nil {
t.Fatalf("load state: %v", err)
}
if state.Level != 5 || state.Status != vipStatusActive {
t.Fatalf("state = %+v, want vip5 active", state)
if state.Level != 3 || state.Status != vipStatusActive {
t.Fatalf("state = %+v, want vip3 active", state)
}
var orders int64
if err := db.Model(&model.VipOrderRecord{}).Where("user_id = ? AND order_type = ?", int64(2), vipOrderTypeGMGift).Count(&orders).Error; err != nil {
@ -471,7 +300,7 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
t.Fatalf("orders = %d, want 1", orders)
}
var records int64
if err := db.Model(&gmVIPGiftRecordRow{}).Where("user_id = ? AND vip_level = ?", int64(2), 5).Count(&records).Error; err != nil {
if err := db.Model(&gmVIPGiftRecordRow{}).Where("user_id = ? AND vip_level = ?", int64(2), 3).Count(&records).Error; err != nil {
t.Fatalf("count gift records: %v", err)
}
if records != 1 {
@ -479,48 +308,6 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
}
}
func TestVIPGiftCleanupExpiresOldVIPResourcesOnly(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
if err := db.AutoMigrate(&managerVIPCleanupPropsRow{}, &managerVIPCleanupBadgeRow{}); err != nil {
t.Fatalf("auto migrate cleanup tables: %v", err)
}
now := time.Date(2026, 5, 30, 10, 0, 0, 0, time.UTC)
future := now.AddDate(0, 0, 20)
userID := int64(2)
seedManagerVIPCleanupProps(t, db,
managerVIPCleanupPropsRow{ID: 1, UserID: userID, PropsID: 101, Type: propsTypeAvatarFrame, ExpireTime: future, UseProps: true, AllowGive: true},
managerVIPCleanupPropsRow{ID: 2, UserID: userID, PropsID: 201, Type: propsTypeAvatarFrame, ExpireTime: future, UseProps: true, AllowGive: true},
managerVIPCleanupPropsRow{ID: 3, UserID: userID, PropsID: 999, Type: propsTypeAvatarFrame, ExpireTime: future, UseProps: true, AllowGive: true},
managerVIPCleanupPropsRow{ID: 4, UserID: userID, PropsID: 103, Type: propsTypeDataCard, ExpireTime: future, UseProps: true, AllowGive: true},
)
seedManagerVIPCleanupBadges(t, db,
managerVIPCleanupBadgeRow{ID: 1, UserID: userID, BadgeID: 100, ExpireTime: future, UseProps: true},
managerVIPCleanupBadgeRow{ID: 2, UserID: userID, BadgeID: 200, ExpireTime: future, UseProps: true},
)
target := vipGiftResourcesFromConfig(model.VipLevelConfig{
BadgeResourceID: 200,
AvatarFrameResourceID: 201,
})
all := append(vipGiftResourcesFromConfig(model.VipLevelConfig{
BadgeResourceID: 100,
AvatarFrameResourceID: 101,
BackgroundCardResourceID: 103,
}), target...)
if err := service.cleanupSupersededVIPGiftResources(context.Background(), userID, target, all, now); err != nil {
t.Fatalf("cleanupSupersededVIPGiftResources() error = %v", err)
}
assertManagerVIPCleanupProps(t, db, 101, now, false, false)
assertManagerVIPCleanupProps(t, db, 103, now, false, false)
assertManagerVIPCleanupProps(t, db, 201, future, true, true)
assertManagerVIPCleanupProps(t, db, 999, future, true, true)
assertManagerVIPCleanupBadge(t, db, 100, now, false)
assertManagerVIPCleanupBadge(t, db, 200, future, true)
}
func TestSendVIPRejectsLevelAboveGiftLimit(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
@ -532,9 +319,9 @@ func TestSendVIPRejectsLevelAboveGiftLimit(t *testing.T) {
seedVIPConfigs(t, db, model.VipLevelConfig{
ID: 304,
SysOrigin: "LIKEI",
Level: 6,
LevelCode: "VIP6",
DisplayName: "VIP 6",
Level: 4,
LevelCode: "VIP4",
DisplayName: "VIP 4",
Enabled: true,
DurationDays: 30,
})
@ -546,90 +333,6 @@ func TestSendVIPRejectsLevelAboveGiftLimit(t *testing.T) {
assertAppErrorCode(t, err, "vip_not_giftable")
}
type managerVIPCleanupPropsRow struct {
ID int64 `gorm:"column:id;primaryKey"`
UserID int64 `gorm:"column:user_id"`
PropsID int64 `gorm:"column:props_id"`
Type string `gorm:"column:type"`
ExpireTime time.Time `gorm:"column:expire_time"`
UseProps bool `gorm:"column:is_use_props"`
AllowGive bool `gorm:"column:allow_give"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (managerVIPCleanupPropsRow) TableName() string {
return "user_props_backpack"
}
type managerVIPCleanupBadgeRow struct {
ID int64 `gorm:"column:id;primaryKey"`
UserID int64 `gorm:"column:user_id"`
BadgeID int64 `gorm:"column:badge_id"`
ExpireType string `gorm:"column:expire_type"`
ExpireTime time.Time `gorm:"column:expire_time"`
UseProps bool `gorm:"column:is_use_props"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (managerVIPCleanupBadgeRow) TableName() string {
return "user_badge_backpack"
}
func seedManagerVIPCleanupProps(t *testing.T, db *gorm.DB, rows ...managerVIPCleanupPropsRow) {
t.Helper()
for _, row := range rows {
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seed manager cleanup props: %v", err)
}
}
}
func seedManagerVIPCleanupBadges(t *testing.T, db *gorm.DB, rows ...managerVIPCleanupBadgeRow) {
t.Helper()
for _, row := range rows {
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seed manager cleanup badge: %v", err)
}
}
}
func assertManagerVIPCleanupProps(t *testing.T, db *gorm.DB, propsID int64, expireTime time.Time, useProps bool, allowGive bool) {
t.Helper()
var row managerVIPCleanupPropsRow
if err := db.Where("props_id = ?", propsID).First(&row).Error; err != nil {
t.Fatalf("load manager cleanup props %d: %v", propsID, err)
}
if !row.ExpireTime.Equal(expireTime) || row.UseProps != useProps || row.AllowGive != allowGive {
t.Fatalf("props %d = expire:%s use:%v allow:%v, want expire:%s use:%v allow:%v",
propsID, row.ExpireTime, row.UseProps, row.AllowGive, expireTime, useProps, allowGive)
}
}
func assertManagerVIPCleanupBadge(t *testing.T, db *gorm.DB, badgeID int64, expireTime time.Time, useProps bool) {
t.Helper()
var row managerVIPCleanupBadgeRow
if err := db.Where("badge_id = ?", badgeID).First(&row).Error; err != nil {
t.Fatalf("load manager cleanup badge %d: %v", badgeID, err)
}
if !row.ExpireTime.Equal(expireTime) || row.UseProps != useProps {
t.Fatalf("badge %d = expire:%s use:%v, want expire:%s use:%v",
badgeID, row.ExpireTime, row.UseProps, expireTime, useProps)
}
}
func TestBanUserRejectsDisabledFeature(t *testing.T) {
service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManagerWithFeatures(t, db, 1, ManagerFeatures{AddBDLeader: true, GiftProps: true, UnbanUser: true})
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2"})
_, err := service.BanUser(context.Background(), AuthUser{UserID: 1}, BanUserRequest{UserID: ID(2)})
assertAppErrorCode(t, err, "manager_feature_disabled")
if len(fake.punishRequests) != 0 {
t.Fatalf("punish requests = %+v, want none", fake.punishRequests)
}
}
func TestBanUserRejectsRechargedUser(t *testing.T) {
service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
@ -709,19 +412,6 @@ func TestBanUserWithoutRechargeCallsPunishment(t *testing.T) {
}
}
func TestUnbanUserRejectsDisabledFeature(t *testing.T) {
service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManagerWithFeatures(t, db, 1, ManagerFeatures{AddBDLeader: true, GiftProps: true, BanUser: true})
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", AccountStatus: accountStatusArchive})
_, err := service.UnbanUser(context.Background(), AuthUser{UserID: 1}, UnbanUserRequest{UserID: ID(2)})
assertAppErrorCode(t, err, "manager_feature_disabled")
if len(fake.unblockRequests) != 0 {
t.Fatalf("unblock requests = %+v, want none", fake.unblockRequests)
}
}
func TestUnbanUserRejectsNormalUser(t *testing.T) {
service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
@ -781,120 +471,6 @@ func TestUnbanUserAllowsFrozenUser(t *testing.T) {
}
}
func TestListCountriesReturnsOpenCountries(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedCountries(t, db,
sysCountryCodeRow{ID: 10, AlphaTwo: "SA", CountryName: "Saudi Arabia", Open: true, Sort: 2},
sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true, Sort: 1},
sysCountryCodeRow{ID: 12, AlphaTwo: "XX", CountryName: "Closed", Open: false, Sort: 3},
)
resp, err := service.ListCountries(context.Background(), AuthUser{UserID: 1})
if err != nil {
t.Fatalf("ListCountries() error = %v", err)
}
if len(resp.Records) != 2 {
t.Fatalf("countries = %+v, want two open rows", resp.Records)
}
if resp.Records[0].CountryCode != "AE" || resp.Records[1].CountryCode != "SA" {
t.Fatalf("countries = %+v, want sort order AE then SA", resp.Records)
}
}
func TestChangeUserCountryDelegatesToJavaAndClearsCache(t *testing.T) {
service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA", CountryName: "Saudi Arabia"})
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
resp, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{
UserID: ID(2),
CountryID: ID(11),
})
if err != nil {
t.Fatalf("ChangeUserCountry() error = %v", err)
}
if !resp.Success || resp.User.CountryCode != "AE" || resp.User.CountryName != "United Arab Emirates" {
t.Fatalf("resp = %+v, want changed country", resp)
}
if len(fake.countryUpdates) != 1 || fake.countryUpdates[0] != (fakeCountryUpdate{userID: 2, countryID: 11}) {
t.Fatalf("countryUpdates = %+v, want user 2 country 11", fake.countryUpdates)
}
var row userBaseInfoRow
if err := db.Where("id = ?", int64(2)).First(&row).Error; err != nil {
t.Fatalf("load user: %v", err)
}
if row.CountryID != 10 || row.CountryCode != "SA" || row.CountryName != "Saudi Arabia" {
t.Fatalf("user row = %+v, want Go service not to write country directly", row)
}
if !equalInt64Slice(fake.cacheCleared, []int64{2}) {
t.Fatalf("cacheCleared = %+v, want [2]", fake.cacheCleared)
}
}
func TestChangeUserCountryRejectsDisabledFeature(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManagerWithFeatures(t, db, 1, ManagerFeatures{AddBDLeader: true, GiftProps: true, BanUser: true, UnbanUser: true})
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA", CountryName: "Saudi Arabia"})
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
_, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{UserID: ID(2), CountryID: ID(11)})
assertAppErrorCode(t, err, "manager_feature_disabled")
var row userBaseInfoRow
if err := db.Where("id = ?", int64(2)).First(&row).Error; err != nil {
t.Fatalf("load user: %v", err)
}
if row.CountryID != 10 || row.CountryCode != "SA" {
t.Fatalf("user row = %+v, want unchanged country when feature disabled", row)
}
}
func TestChangeUserCountryRejectsHistoryIdentity(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA"})
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
if err := db.Create(&userHistoryIdentityRow{UserID: 2, IsHost: true}).Error; err != nil {
t.Fatalf("seed identity: %v", err)
}
_, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{UserID: ID(2), CountryID: ID(11)})
assertAppErrorCode(t, err, "user_identity_locked")
}
func TestChangeUserCountryRejectsBDLeader(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA"})
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
if err := db.Create(&bdLeaderRow{ID: 20, UserID: 2, Origin: "LIKEI", RegionID: "SA"}).Error; err != nil {
t.Fatalf("seed bd leader: %v", err)
}
_, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{UserID: ID(2), CountryID: ID(11)})
assertAppErrorCode(t, err, "user_identity_locked")
}
func TestChangeUserCountryRejectsCoinSeller(t *testing.T) {
service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA"})
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
fake.freightSellers[2] = true
_, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{UserID: ID(2), CountryID: ID(11)})
assertAppErrorCode(t, err, "user_identity_locked")
}
func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
@ -904,11 +480,6 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
if err := db.AutoMigrate(
&teamManagerInfoRow{},
&userBaseInfoRow{},
&sysCountryCodeRow{},
&bdLeaderRow{},
&businessDevelopmentBaseInfoRow{},
&userHistoryIdentityRow{},
&administratorRow{},
&propsSourceRecordRow{},
&propsNobleVIPAbilityRow{},
&model.VipLevelConfig{},
@ -920,7 +491,6 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
}
fake := &fakeManagerGateway{
recharges: map[int64][]integration.UserTotalRecharge{},
freightSellers: map[int64]bool{},
abilities: map[int64]integration.PropsNobleVIPAbility{},
}
return NewService(db, fake), fake
@ -928,36 +498,11 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
func seedManager(t *testing.T, db *gorm.DB, userID int64) {
t.Helper()
seedManagerWithFeatures(t, db, userID, ManagerFeatures{
AddBDLeader: true,
GiftProps: true,
BanUser: true,
UnbanUser: true,
ChangeCountry: true,
})
}
func seedManagerWithFeatures(t *testing.T, db *gorm.DB, userID int64, features ManagerFeatures) {
t.Helper()
if err := db.Create(&teamManagerInfoRow{
ID: userID,
UserID: userID,
Origin: "LIKEI",
RegionID: "SA",
CanAddBDLeader: boolPtr(features.AddBDLeader),
CanGiftProps: boolPtr(features.GiftProps),
CanBanUser: boolPtr(features.BanUser),
CanUnbanUser: boolPtr(features.UnbanUser),
CanChangeCountry: boolPtr(features.ChangeCountry),
}).Error; err != nil {
if err := db.Create(&teamManagerInfoRow{ID: userID, UserID: userID, Origin: "LIKEI"}).Error; err != nil {
t.Fatalf("seed manager: %v", err)
}
}
func boolPtr(value bool) *bool {
return &value
}
func seedUsers(t *testing.T, db *gorm.DB, rows ...userBaseInfoRow) {
t.Helper()
for _, row := range rows {
@ -970,15 +515,6 @@ func seedUsers(t *testing.T, db *gorm.DB, rows ...userBaseInfoRow) {
}
}
func seedCountries(t *testing.T, db *gorm.DB, rows ...sysCountryCodeRow) {
t.Helper()
for _, row := range rows {
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seed country: %v", err)
}
}
}
func seedProps(t *testing.T, db *gorm.DB, rows ...propsSourceRecordRow) {
t.Helper()
for _, row := range rows {

View File

@ -6,7 +6,6 @@ import (
"net/http"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/integration"
@ -28,14 +27,9 @@ const (
vipGiftOrigin = "VIP_PURCHASE"
vipGiftOriginDesc = "VIP purchase"
defaultGiftDays = 30
defaultGiftDays = 7
defaultVIPGiftDays = 30
// 经理赠送统一限制为运营确认的三个有效期,避免客户端提交任意天数。
giftDays30 = 30
giftDays60 = 60
giftDays90 = 90
// 经理中心 VIP 赠送按当前运营范围放开 VIP1-VIP5列表和发放校验共用该上限避免展示可选但提交失败。
maxGiftableVIPLevel = 5
maxGiftableVIPLevel = 3
accountStatusNormal = "NORMAL"
accountStatusFreeze = "FREEZE"
@ -43,16 +37,6 @@ const (
accountStatusArchiveDevice = "ARCHIVE_DEVICE"
)
type managerFeature string
const (
managerFeatureAddBDLeader managerFeature = "add_bd_leader"
managerFeatureGiftProps managerFeature = "gift_props"
managerFeatureBanUser managerFeature = "ban_user"
managerFeatureUnbanUser managerFeature = "unban_user"
managerFeatureChangeCountry managerFeature = "change_country"
)
type AppError = common.AppError
type AuthUser = common.AuthUser
@ -64,10 +48,8 @@ type managerDB interface {
type managerGateway interface {
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error)
UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error
GetUserProfileByAccountOne(ctx context.Context, account string) (integration.UserProfile, error)
MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error)
ExistsFreightSeller(ctx context.Context, userID int64) (bool, error)
GivePropsBackpack(ctx context.Context, req integration.GivePropsBackpackRequest) error
GetNobleVIPAbility(ctx context.Context, sourceID int64) (integration.PropsNobleVIPAbility, error)
GetUserMaxNobleVIPAbility(ctx context.Context, userID int64) (integration.PropsNobleVIPAbility, error)
@ -124,39 +106,17 @@ func (id *ID) UnmarshalJSON(data []byte) error {
type ProfileResponse struct {
Manager UserView `json:"manager"`
Features ManagerFeatures `json:"features"`
}
type ManagerFeatures struct {
AddBDLeader bool `json:"addBdLeader"`
GiftProps bool `json:"giftProps"`
BanUser bool `json:"banUser"`
UnbanUser bool `json:"unbanUser"`
ChangeCountry bool `json:"changeCountry"`
}
type UserSearchResponse struct {
User UserView `json:"user"`
}
type CountryListResponse struct {
Records []CountryView `json:"records"`
}
type CountryView struct {
ID ID `json:"id"`
CountryCode string `json:"countryCode"`
CountryName string `json:"countryName"`
NationalFlag string `json:"nationalFlag,omitempty"`
PhonePrefix int `json:"phonePrefix,omitempty"`
}
type UserView struct {
UserID ID `json:"userId"`
Account string `json:"account"`
UserAvatar string `json:"userAvatar"`
UserNickname string `json:"userNickname"`
CountryID ID `json:"countryId,omitempty"`
CountryCode string `json:"countryCode,omitempty"`
CountryName string `json:"countryName,omitempty"`
OriginSys string `json:"originSys,omitempty"`
@ -185,7 +145,6 @@ type PropsView struct {
type SendPropsRequest struct {
AcceptUserID ID `json:"acceptUserId"`
PropsID ID `json:"propsId"`
Days int `json:"days"`
}
type SendPropsResponse struct {
@ -219,45 +178,6 @@ type UnbanUserResponse struct {
AccountStatus string `json:"accountStatus"`
}
type ChangeUserCountryRequest struct {
UserID ID `json:"userId"`
CountryID ID `json:"countryId"`
}
type ChangeUserCountryResponse struct {
Success bool `json:"success"`
User UserView `json:"user"`
}
type BDLeaderListResponse struct {
Count int `json:"count"`
Records []BDLeaderView `json:"records"`
}
type BDLeaderView struct {
ID ID `json:"id"`
UserID ID `json:"userId"`
Account string `json:"account"`
UserAvatar string `json:"userAvatar"`
UserNickname string `json:"userNickname"`
Contact string `json:"contact,omitempty"`
RegionID string `json:"regionId,omitempty"`
OriginSys string `json:"originSys,omitempty"`
CreateTime time.Time `json:"createTime,omitempty"`
UpdateTime time.Time `json:"updateTime,omitempty"`
}
type AddBDLeaderRequest struct {
UserID ID `json:"userId"`
Contact string `json:"contact"`
RegionID string `json:"regionId"`
}
type AddBDLeaderResponse struct {
Success bool `json:"success"`
Leader BDLeaderView `json:"leader"`
}
func badRequest(code, message string) error {
return NewAppError(http.StatusBadRequest, code, message)
}

View File

@ -79,7 +79,7 @@ func (s *Service) listVIPProps(ctx context.Context, user AuthUser) (*PropsListRe
}, nil
}
func (s *Service) sendVIPGift(ctx context.Context, manager AuthUser, target userBaseInfoRow, configID int64, days int) (*SendPropsResponse, error) {
func (s *Service) sendVIPGift(ctx context.Context, manager AuthUser, target userBaseInfoRow, configID int64) (*SendPropsResponse, error) {
sysOrigin := normalizeSysOrigin(target.OriginSys)
config, found, err := s.loadVIPConfigByID(ctx, sysOrigin, configID)
if err != nil || !found {
@ -91,8 +91,9 @@ func (s *Service) sendVIPGift(ctx context.Context, manager AuthUser, target user
if config.Level < 1 || config.Level > maxGiftableVIPLevel {
return nil, badRequest("vip_not_giftable", "vip level is invalid")
}
// 经理选择的赠送天数覆盖 VIP 售卖配置时长,订单、状态和附属权益保持同一到期口径。
config.DurationDays = days
if config.DurationDays <= 0 {
config.DurationDays = defaultVIPGiftDays
}
now := time.Now()
orderID, err := utils.NextID()
@ -170,7 +171,6 @@ func (s *Service) sendVIPGift(ctx context.Context, manager AuthUser, target user
CreateTime: now,
}
}
previousResources := vipGiftResourcesFromState(state)
applyVIPGiftConfigToState(state, config, startAt, expireAt, now)
if err := tx.Save(state).Error; err != nil {
return nil, serverError("vip_state_save_failed", err.Error())
@ -209,7 +209,7 @@ func (s *Service) sendVIPGift(ctx context.Context, manager AuthUser, target user
}
committed = true
if err := s.grantVIPGiftResources(ctx, sysOrigin, target.ID, manager.UserID, config, expireAt, previousResources, now); err != nil {
if err := s.grantVIPGiftResources(ctx, target.ID, manager.UserID, config, expireAt, now); err != nil {
return nil, serverError("vip_resource_grant_failed", err.Error())
}
@ -301,8 +301,11 @@ func applyVIPGiftConfigToState(state *model.UserVipState, config model.VipLevelC
}
}
func (s *Service) grantVIPGiftResources(ctx context.Context, sysOrigin string, userID int64, managerID int64, config model.VipLevelConfig, expireAt time.Time, additionalOldResources []vipGiftResource, now time.Time) error {
func (s *Service) grantVIPGiftResources(ctx context.Context, userID int64, managerID int64, config model.VipLevelConfig, expireAt time.Time, now time.Time) error {
resources := vipGiftResourcesFromConfig(config)
if len(resources) == 0 {
return nil
}
days := config.DurationDays
if days <= 0 {
days = defaultVIPGiftDays
@ -334,14 +337,6 @@ func (s *Service) grantVIPGiftResources(ctx context.Context, sysOrigin string, u
return err
}
}
allResources, err := s.vipGiftResourcesFromSysOrigin(ctx, sysOrigin)
if err != nil {
return err
}
allResources = append(allResources, additionalOldResources...)
if err := s.cleanupSupersededVIPGiftResources(ctx, userID, resources, allResources, now); err != nil {
return err
}
if err := s.java.RemoveUserProfileCacheAll(ctx, userID); err != nil {
return fmt.Errorf("remove vip user profile cache: %w", err)
}
@ -379,7 +374,6 @@ func vipGiftResourcesFromConfig(config model.VipLevelConfig) []vipGiftResource {
{resourceID: config.EntryEffectResourceID, propsType: propsTypeRide},
{resourceID: config.ChatBubbleResourceID, propsType: propsTypeChatBubble},
{resourceID: config.FloatPictureResourceID, propsType: propsTypeFloatPicture},
{resourceID: config.BackgroundCardResourceID, propsType: propsTypeDataCard},
}
out := make([]vipGiftResource, 0, len(resources))
for _, resource := range resources {
@ -390,104 +384,6 @@ func vipGiftResourcesFromConfig(config model.VipLevelConfig) []vipGiftResource {
return out
}
func vipGiftResourcesFromState(state *model.UserVipState) []vipGiftResource {
if state == nil {
return nil
}
return compactVIPGiftResources([]vipGiftResource{
{resourceID: state.BadgeResourceID, propsType: propsTypeBadge, badge: true},
{resourceID: state.ShortBadgeResourceID, propsType: propsTypeBadge, badge: true},
{resourceID: state.AvatarFrameResourceID, propsType: propsTypeAvatarFrame},
{resourceID: state.EntryEffectResourceID, propsType: propsTypeRide},
{resourceID: state.ChatBubbleResourceID, propsType: propsTypeChatBubble},
{resourceID: state.FloatPictureResourceID, propsType: propsTypeFloatPicture},
{resourceID: state.BackgroundCardResourceID, propsType: propsTypeDataCard},
})
}
func compactVIPGiftResources(resources []vipGiftResource) []vipGiftResource {
out := make([]vipGiftResource, 0, len(resources))
for _, resource := range resources {
if resource.resourceID > 0 {
out = append(out, resource)
}
}
return out
}
func (s *Service) vipGiftResourcesFromSysOrigin(ctx context.Context, sysOrigin string) ([]vipGiftResource, error) {
var configs []model.VipLevelConfig
if err := s.db.WithContext(ctx).
Where("sys_origin = ?", normalizeSysOrigin(sysOrigin)).
Find(&configs).Error; err != nil {
return nil, err
}
resources := make([]vipGiftResource, 0, len(configs)*7)
for _, config := range configs {
resources = append(resources, vipGiftResourcesFromConfig(config)...)
}
return resources, nil
}
func (s *Service) cleanupSupersededVIPGiftResources(ctx context.Context, userID int64, targetResources []vipGiftResource, allVIPResources []vipGiftResource, now time.Time) error {
targets := make(map[string]struct{}, len(targetResources))
for _, resource := range targetResources {
targets[vipGiftResourceKey(resource)] = struct{}{}
}
badgeIDs := make([]int64, 0)
propsIDsByType := map[string][]int64{}
seen := make(map[string]struct{}, len(allVIPResources))
for _, resource := range allVIPResources {
if resource.resourceID <= 0 {
continue
}
key := vipGiftResourceKey(resource)
if _, exists := targets[key]; exists {
continue
}
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
if resource.badge {
badgeIDs = append(badgeIDs, resource.resourceID)
continue
}
if strings.TrimSpace(resource.propsType) != "" {
propsIDsByType[resource.propsType] = append(propsIDsByType[resource.propsType], resource.resourceID)
}
}
if len(badgeIDs) > 0 {
if err := s.db.WithContext(ctx).Exec(
"UPDATE user_badge_backpack SET expire_type = ?, expire_time = ?, is_use_props = ?, update_time = ? WHERE user_id = ? AND badge_id IN ? AND expire_time > ?",
"TEMPORARY", now, false, now, userID, badgeIDs, now,
).Error; err != nil {
return err
}
}
for propsType, propsIDs := range propsIDsByType {
if len(propsIDs) == 0 {
continue
}
if err := s.db.WithContext(ctx).Exec(
"UPDATE user_props_backpack SET expire_time = ?, is_use_props = ?, allow_give = ?, update_time = ? WHERE user_id = ? AND props_id IN ? AND type = ? AND expire_time > ?",
now, false, false, now, userID, propsIDs, propsType, now,
).Error; err != nil {
return err
}
}
return nil
}
func vipGiftResourceKey(resource vipGiftResource) string {
if resource.badge {
return fmt.Sprintf("BADGE:%d", resource.resourceID)
}
return fmt.Sprintf("%s:%d", resource.propsType, resource.resourceID)
}
func (s *Service) managerApplicantName(ctx context.Context, managerID int64) string {
row, err := s.loadUserRowByID(ctx, managerID)
if err != nil {

View File

@ -117,7 +117,7 @@ ORDER BY claims.last_claim_time DESC, claims.user_id ASC`
if err != nil {
return nil, err
}
rewardItems := s.loadRewardItemsMap(ctx, sysOrigin, claimDetailLevels(details))
rewardItems := s.loadRewardItemsMap(ctx, claimDetailLevels(details))
records = make([]RechargeRewardClaimRecordView, 0, len(rows))
for _, row := range rows {
records = append(records, rechargeRewardClaimRecordView(row, displayMonth, rechargeDate, details[row.UserID], rewardItems))
@ -283,9 +283,6 @@ func claimDetailPayloads(
if detail.ResourceGroupID != nil {
items = rewardItems[*detail.ResourceGroupID]
}
if items == nil {
items = []RechargeRewardRewardItem{}
}
result = append(result, RechargeRewardLevelPayload{
ID: detail.ActivityID,
Level: level,
@ -316,9 +313,6 @@ func reachedRechargeRewardLevels(
if level.RewardGroupID != nil {
items = rewardItems[*level.RewardGroupID]
}
if items == nil {
items = []RechargeRewardRewardItem{}
}
result = append(result, RechargeRewardLevelPayload{
ID: level.ID,
Level: level.Level,
@ -338,10 +332,6 @@ func reachedRechargeRewardLevels(
func rechargeRewardClaimedRewards(levels []RechargeRewardLevelPayload) []RechargeRewardClaimedRewardPayload {
result := make([]RechargeRewardClaimedRewardPayload, 0, len(levels))
for _, level := range levels {
items := level.RewardItems
if items == nil {
items = []RechargeRewardRewardItem{}
}
result = append(result, RechargeRewardClaimedRewardPayload{
Level: level.Level,
RechargeAmount: level.RechargeAmount,
@ -349,7 +339,7 @@ func rechargeRewardClaimedRewards(levels []RechargeRewardLevelPayload) []Recharg
RewardGold: level.RewardGold,
RewardGroupID: level.RewardGroupID,
RewardGroupName: level.RewardGroupName,
RewardItems: items,
RewardItems: level.RewardItems,
})
}
return result

View File

@ -39,7 +39,7 @@ func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*RechargeRew
if err != nil {
return nil, err
}
rewardItems := s.loadRewardItemsMap(ctx, configRow.SysOrigin, levels)
rewardItems := s.loadRewardItemsMap(ctx, levels)
return &RechargeRewardConfigResponse{
Configured: true,
ID: configRow.ID,
@ -60,7 +60,7 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveRechargeRewardConfigRe
return nil, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error())
}
levels, err := s.normalizeLevelInputs(ctx, sysOrigin, req.LevelConfigs)
levels, err := s.normalizeLevelInputs(req.LevelConfigs)
if err != nil {
return nil, err
}
@ -133,7 +133,7 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveRechargeRewardConfigRe
return s.GetConfig(ctx, rechargeRewardSysOriginBySavedID(ctx, s.db, savedConfigID, sysOrigin))
}
func (s *Service) normalizeLevelInputs(ctx context.Context, sysOrigin string, inputs []RechargeRewardLevelInput) ([]model.RechargeRewardLevel, error) {
func (s *Service) normalizeLevelInputs(inputs []RechargeRewardLevelInput) ([]model.RechargeRewardLevel, error) {
if len(inputs) == 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_level_configs", "levelConfigs is required")
}
@ -152,7 +152,7 @@ func (s *Service) normalizeLevelInputs(ctx context.Context, sysOrigin string, in
if amountCents <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_recharge_amount", "rechargeAmount must be greater than 0")
}
rewardGroupID := s.resolveRewardGroupID(ctx, sysOrigin, item.RewardGroupID.Int64Ptr(), item.RewardGroupName)
rewardGroupID := item.RewardGroupID.Int64Ptr()
if item.RewardGold <= 0 && (rewardGroupID == nil || *rewardGroupID <= 0) {
return nil, NewAppError(http.StatusBadRequest, "invalid_reward", "rewardGold or rewardGroupId is required")
}
@ -188,22 +188,6 @@ func (s *Service) normalizeLevelInputs(ctx context.Context, sysOrigin string, in
return rows, nil
}
func (s *Service) resolveRewardGroupID(ctx context.Context, sysOrigin string, rewardGroupID *int64, rewardGroupName string) *int64 {
normalized := normalizeRewardGroupID(rewardGroupID)
if normalized == nil || s.java == nil || strings.TrimSpace(rewardGroupName) == "" {
return normalized
}
if detail, err := s.java.GetRewardGroupDetail(ctx, *normalized); err == nil && rewardGroupDetailHasItems(detail) {
return normalized
}
detail, err := s.java.FindRewardGroupDetail(ctx, sysOrigin, rewardGroupName)
if err != nil || int64(detail.ID) <= 0 {
return normalized
}
resolved := int64(detail.ID)
return &resolved
}
func (s *Service) loadConfigBundle(ctx context.Context, sysOrigin string) (*rechargeRewardConfigBundle, error) {
sysOrigin = s.normalizeSysOrigin(sysOrigin)

View File

@ -2,9 +2,7 @@ package rechargereward
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
@ -115,7 +113,7 @@ func TestSaveConfigUpsertsBySysOrigin(t *testing.T) {
}
func TestGetHomeUsesJavaRechargeAndMarksLevels(t *testing.T) {
groupID := int64(2056981527522242600)
groupID := int64(9001)
java := rechargeRewardTestGateway{
total: map[int64][]integration.UserTotalRecharge{
42: {
@ -134,14 +132,7 @@ func TestGetHomeUsesJavaRechargeAndMarksLevels(t *testing.T) {
ID: integration.Int64Value(groupID),
Name: "Gift Box",
RewardConfigList: []integration.RewardGroupItem{
{
ID: integration.Int64Value(1),
Type: "PROPS",
DetailType: "AVATAR_FRAME",
Name: "Frame",
Quantity: integration.Int64Value(1),
SourceURL: "https://example.com/frame.svga",
},
{ID: integration.Int64Value(1), Type: "PROP", Name: "Frame", Quantity: integration.Int64Value(1), Cover: "https://example.com/frame.png"},
},
},
},
@ -205,63 +196,6 @@ INSERT INTO likei_wallet.user_freight_balance_running_water (accept_user_id, sys
if len(resp.LevelConfigs[0].RewardItems) != 1 || resp.LevelConfigs[0].RewardItems[0].Name != "Frame" {
t.Fatalf("reward items = %+v", resp.LevelConfigs[0].RewardItems)
}
if got := resp.LevelConfigs[0].RewardItems[0]; got.Type != "AVATAR_FRAME" || got.Cover != "https://example.com/frame.svga" {
t.Fatalf("reward item display fields = %+v", got)
}
if resp.LevelConfigs[1].RewardItems == nil {
t.Fatalf("gold-only level RewardItems = nil, want empty slice")
}
encoded, err := json.Marshal(resp)
if err != nil {
t.Fatalf("marshal response: %v", err)
}
body := string(encoded)
if !strings.Contains(body, `"rewardGroupId":"2056981527522242600"`) {
t.Fatalf("encoded rewardGroupId is not a string: %s", body)
}
if strings.Contains(body, `"rewardItems":null`) {
t.Fatalf("encoded rewardItems contains null: %s", body)
}
}
func TestGetHomeFallsBackToRewardGroupNameWhenStoredIDLostPrecision(t *testing.T) {
actualGroupID := int64(2056723577461338114)
roundedGroupID := int64(2056723577461338000)
service, _ := newTestService(t, rechargeRewardTestGateway{
groups: map[int64]integration.RewardGroupDetail{
actualGroupID: {
ID: integration.Int64Value(actualGroupID),
Name: "累充活动$50",
RewardConfigList: []integration.RewardGroupItem{
{ID: integration.Int64Value(1), Type: "BADGE", Name: "recharge50$", Quantity: integration.Int64Value(30), Cover: "https://example.com/badge.png"},
},
},
},
})
if _, err := service.SaveConfig(context.Background(), SaveRechargeRewardConfigRequest{
SysOrigin: "LIKEI",
Enabled: true,
Timezone: "Asia/Riyadh",
LevelConfigs: []RechargeRewardLevelInput{
{Level: 1, RechargeAmount: mustAmount(t, "50"), RewardGroupID: ptrFlexibleInt64(roundedGroupID), RewardGroupName: "累充活动$50", Enabled: true},
},
}); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
resp, err := service.GetConfig(context.Background(), "LIKEI")
if err != nil {
t.Fatalf("GetConfig() error = %v", err)
}
if len(resp.LevelConfigs) != 1 || len(resp.LevelConfigs[0].RewardItems) != 1 {
t.Fatalf("reward items = %+v, want fallback by reward group name", resp.LevelConfigs)
}
if resp.LevelConfigs[0].RewardGroupID == nil || *resp.LevelConfigs[0].RewardGroupID != actualGroupID {
t.Fatalf("rewardGroupId = %v, want resolved actual group id %d", resp.LevelConfigs[0].RewardGroupID, actualGroupID)
}
if got := resp.LevelConfigs[0].RewardItems[0].Name; got != "recharge50$" {
t.Fatalf("reward item name = %q, want recharge50$", got)
}
}
func TestPageClaimRecordsUsesActualReceiveRecords(t *testing.T) {
@ -448,15 +382,6 @@ func (g rechargeRewardTestGateway) GetRewardGroupDetail(_ context.Context, group
return integration.RewardGroupDetail{}, fmt.Errorf("group not found")
}
func (g rechargeRewardTestGateway) FindRewardGroupDetail(_ context.Context, _ string, name string) (integration.RewardGroupDetail, error) {
for _, detail := range g.groups {
if detail.Name == name {
return detail, nil
}
}
return integration.RewardGroupDetail{}, fmt.Errorf("group not found")
}
func (g rechargeRewardTestGateway) MapUserTotalRecharge(context.Context, []int64) (map[int64][]integration.UserTotalRecharge, error) {
return g.total, nil
}

View File

@ -83,7 +83,7 @@ func (s *Service) GetHome(ctx context.Context, user AuthUser) (*RechargeRewardHo
resp.NextRechargeAmount = formatAmountCents(next.RechargeAmountCents)
resp.NextRechargeAmountCents = next.RechargeAmountCents
}
rewardItems := s.loadRewardItemsMap(ctx, resp.SysOrigin, bundle.Levels)
rewardItems := s.loadRewardItemsMap(ctx, bundle.Levels)
matchedLevel := 0
if matchedOK {
matchedLevel = matched.Level

View File

@ -47,9 +47,6 @@ func buildLevelPayloads(
if level.RewardGroupID != nil {
items = rewardItems[*level.RewardGroupID]
}
if items == nil {
items = []RechargeRewardRewardItem{}
}
payloads = append(payloads, RechargeRewardLevelPayload{
ID: level.ID,
Level: level.Level,
@ -77,9 +74,6 @@ func buildAdminLevelPayloads(
if level.RewardGroupID != nil {
items = rewardItems[*level.RewardGroupID]
}
if items == nil {
items = []RechargeRewardRewardItem{}
}
payloads = append(payloads, RechargeRewardLevelPayload{
ID: level.ID,
Level: level.Level,
@ -131,7 +125,6 @@ func sortLevels(levels []rechargeRewardLevelSnapshot) {
func (s *Service) loadRewardItemsMap(
ctx context.Context,
sysOrigin string,
levels []rechargeRewardLevelSnapshot,
) map[int64][]RechargeRewardRewardItem {
if s.java == nil {
@ -139,14 +132,10 @@ func (s *Service) loadRewardItemsMap(
}
groupIDs := make([]int64, 0)
seen := make(map[int64]struct{})
groupNames := make(map[int64]string)
for _, level := range levels {
if level.RewardGroupID == nil || *level.RewardGroupID <= 0 {
continue
}
if strings.TrimSpace(level.RewardGroupName) != "" {
groupNames[*level.RewardGroupID] = level.RewardGroupName
}
if _, exists := seen[*level.RewardGroupID]; exists {
continue
}
@ -158,69 +147,24 @@ func (s *Service) loadRewardItemsMap(
result := make(map[int64][]RechargeRewardRewardItem, len(groupIDs))
for _, groupID := range groupIDs {
detail, err := s.java.GetRewardGroupDetail(ctx, groupID)
if err != nil || !rewardGroupDetailHasItems(detail) {
if fallback, fallbackErr := s.java.FindRewardGroupDetail(ctx, sysOrigin, groupNames[groupID]); fallbackErr == nil && rewardGroupDetailHasItems(fallback) {
detail = fallback
} else {
result[groupID] = []RechargeRewardRewardItem{}
if err != nil {
continue
}
}
result[groupID] = rewardItemsFromGroup(detail)
}
return result
}
func rewardGroupDetailHasItems(detail integration.RewardGroupDetail) bool {
return len(detail.RewardConfigList) > 0
}
func rewardItemsFromGroup(detail integration.RewardGroupDetail) []RechargeRewardRewardItem {
items := make([]RechargeRewardRewardItem, 0, len(detail.RewardConfigList))
for _, item := range detail.RewardConfigList {
items = append(items, RechargeRewardRewardItem{
ID: int64(item.ID),
Type: rewardItemDisplayType(item),
Type: strings.TrimSpace(item.Type),
Name: strings.TrimSpace(item.Name),
Content: strings.TrimSpace(item.Content),
Quantity: int64(item.Quantity),
Cover: firstNonEmpty(item.Cover, item.SourceURL),
Cover: strings.TrimSpace(item.Cover),
Remark: strings.TrimSpace(item.Remark),
})
}
return items
result[groupID] = 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 firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
return result
}
func totalRechargeCents(entries []integration.UserTotalRecharge) int64 {

View File

@ -35,7 +35,6 @@ type rechargeRewardDB interface {
type rechargeRewardJavaGateway interface {
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error)
GetRewardGroupDetail(ctx context.Context, groupID int64) (integration.RewardGroupDetail, error)
FindRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (integration.RewardGroupDetail, error)
MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error)
GetThisMonthTotalPersonalRecharge(ctx context.Context, userID int64) (integration.DecimalString, error)
}
@ -76,7 +75,7 @@ type RechargeRewardLevelPayload struct {
RechargeAmount string `json:"rechargeAmount"`
RechargeAmountCents int64 `json:"rechargeAmountCents"`
RewardGold int64 `json:"rewardGold"`
RewardGroupID *int64 `json:"rewardGroupId,string,omitempty"`
RewardGroupID *int64 `json:"rewardGroupId,omitempty"`
RewardGroupName string `json:"rewardGroupName,omitempty"`
RewardItems []RechargeRewardRewardItem `json:"rewardItems"`
Enabled bool `json:"enabled"`
@ -176,7 +175,7 @@ type RechargeRewardClaimedRewardPayload struct {
RechargeAmount string `json:"rechargeAmount"`
RechargeAmountCents int64 `json:"rechargeAmountCents"`
RewardGold int64 `json:"rewardGold"`
RewardGroupID *int64 `json:"rewardGroupId,string,omitempty"`
RewardGroupID *int64 `json:"rewardGroupId,omitempty"`
RewardGroupName string `json:"rewardGroupName,omitempty"`
RewardItems []RechargeRewardRewardItem `json:"rewardItems"`
}

View File

@ -66,8 +66,6 @@ func (s *Service) SendRegionBroadcast(ctx context.Context, req RegionBroadcastRe
return nil, err
}
}
refreshHighWinMessage(data, messageType)
refreshCPRelationMessage(data, messageType)
body := map[string]any{
"type": messageType,
@ -184,60 +182,6 @@ func firstInt64Value(values ...any) int64 {
return 0
}
func refreshHighWinMessage(data map[string]any, messageType string) {
target := highWinPlayTarget(messageType)
if target == "" {
return
}
user := firstTextValue(data["userNickname"], data["nickname"], data["account"], data["actualAccount"], data["userId"])
multiple := firstTextValue(data["multiple"], data["winMultiple"])
amount := firstTextValue(data["winAmount"], data["awardAmount"])
if user == "" || multiple == "" || amount == "" {
return
}
data["msg"] = fmt.Sprintf("%s play %s get x %s win %s coins", user, target, multiple, amount)
}
func highWinPlayTarget(messageType string) string {
switch strings.TrimSpace(messageType) {
case "GAME_LUCKY_GIFT":
return "lucky_gift"
case "GAME_BAISHUN_WIN":
return "game"
default:
return ""
}
}
func refreshCPRelationMessage(data map[string]any, messageType string) {
if strings.TrimSpace(messageType) != "CP_RELATION_BROADCAST" {
return
}
user := firstTextValue(data["userNickname"], data["nickname"], data["account"], data["actualAccount"], data["userId"])
cpUser := firstTextValue(data["cpUserNickname"], data["acceptNickname"], data["toUserName"], data["cpUserAccount"], data["acceptAccount"], data["cpUserId"], data["acceptUserId"])
if user == "" || cpUser == "" {
return
}
switch strings.ToUpper(strings.TrimSpace(firstTextValue(data["relationType"]))) {
case "BROTHER", "BROTHERS":
data["msg"] = fmt.Sprintf("%s与%s结为兄弟", user, cpUser)
case "SISTERS", "SISTER":
data["msg"] = fmt.Sprintf("%s与%s结为姐妹", user, cpUser)
default:
data["msg"] = fmt.Sprintf("%s与%s get cp relation", user, cpUser)
}
}
func firstTextValue(values ...any) string {
for _, value := range values {
text := strings.TrimSpace(fmt.Sprint(value))
if text != "" && text != "<nil>" {
return text
}
}
return ""
}
func firstInt64FromList(value any) int64 {
switch typed := value.(type) {
case []any:

View File

@ -133,97 +133,6 @@ func TestSendRegionBroadcastResolvesRegionAndEnrichesGiftPayload(t *testing.T) {
}
}
func TestSendRegionBroadcastRefreshesHighWinMessageWithProfile(t *testing.T) {
service, db := newRegionIMGroupTestService(t)
fakeIM := &fakeIMGateway{}
service.im = fakeIM
now := time.Now()
if err := db.Create(&model.VoiceRoomRegionIMGroup{
ID: 3010,
SysOrigin: "LIKEI",
RegionCode: "AR",
RegionName: "Arab",
GroupID: "@region-ar",
GroupName: "region-ar",
Status: imGroupStatusActive,
CreateTime: now,
UpdateTime: now,
}).Error; err != nil {
t.Fatalf("seed im group: %v", err)
}
_, err := service.SendRegionBroadcast(context.Background(), RegionBroadcastRequest{
SysOrigin: "LIKEI",
Type: "GAME_BAISHUN_WIN",
Data: map[string]any{
"userId": "1001",
"roomId": "9001",
"multiple": 10,
"winAmount": 1000,
"msg": "1001 play game get x 10 win 1000 coins",
},
})
if err != nil {
t.Fatalf("SendRegionBroadcast() error = %v", err)
}
body, ok := fakeIM.sent[0].body.(map[string]any)
if !ok {
t.Fatalf("sent body type = %T", fakeIM.sent[0].body)
}
data, ok := body["data"].(map[string]any)
if !ok {
t.Fatalf("sent data type = %T", body["data"])
}
if data["msg"] != "sender play game get x 10 win 1000 coins" {
t.Fatalf("msg = %v", data["msg"])
}
}
func TestSendRegionBroadcastRefreshesCPRelationMessageWithProfiles(t *testing.T) {
service, db := newRegionIMGroupTestService(t)
fakeIM := &fakeIMGateway{}
service.im = fakeIM
now := time.Now()
if err := db.Create(&model.VoiceRoomRegionIMGroup{
ID: 3011,
SysOrigin: "LIKEI",
RegionCode: "AR",
RegionName: "Arab",
GroupID: "@region-ar",
GroupName: "region-ar",
Status: imGroupStatusActive,
CreateTime: now,
UpdateTime: now,
}).Error; err != nil {
t.Fatalf("seed im group: %v", err)
}
_, err := service.SendRegionBroadcast(context.Background(), RegionBroadcastRequest{
SysOrigin: "LIKEI",
Type: "CP_RELATION_BROADCAST",
Data: map[string]any{
"userId": "1001",
"acceptUserId": "1002",
"relationType": "SISTERS",
"msg": "1001与1002结为姐妹",
},
})
if err != nil {
t.Fatalf("SendRegionBroadcast() error = %v", err)
}
body, ok := fakeIM.sent[0].body.(map[string]any)
if !ok {
t.Fatalf("sent body type = %T", fakeIM.sent[0].body)
}
data, ok := body["data"].(map[string]any)
if !ok {
t.Fatalf("sent data type = %T", body["data"])
}
if data["msg"] != "sender与claimer结为姐妹" {
t.Fatalf("msg = %v", data["msg"])
}
}
func TestSendRegionBroadcastPrefersJavaRegionResolver(t *testing.T) {
service, db := newRegionIMGroupTestService(t)
service.regionResolver = fakeRegionResolver{

View File

@ -20,11 +20,6 @@ import (
"gorm.io/gorm/clause"
)
const (
defaultTaskCenterArchiveBatchSize = 500
maxTaskCenterArchiveBatchSize = 1000
)
// StartArchiveWorker 启动任务中心事件 COS 归档 worker。
func (s *Service) StartArchiveWorker(ctx context.Context) error {
if !s.cfg.TaskCenter.Archive.Enabled {
@ -73,7 +68,10 @@ func (s *Service) runArchiveWorker(ctx context.Context, workerIndex int) {
}
func (s *Service) archivePendingEvents(ctx context.Context) error {
batchSize := normalizeTaskCenterArchiveBatchSize(s.cfg.TaskCenter.Archive.BatchSize)
batchSize := s.cfg.TaskCenter.Archive.BatchSize
if batchSize <= 0 {
batchSize = 10000
}
rows, err := s.claimArchiveRows(ctx, batchSize)
if err != nil {
return err
@ -99,47 +97,17 @@ func (s *Service) claimArchiveRows(ctx context.Context, batchSize int) ([]model.
var rows []model.TaskCenterEventArchiveOutbox
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
staleProcessingBefore := time.Now().Add(-10 * time.Minute)
staleLimit, _ := taskCenterArchiveClaimLimits(batchSize)
if staleLimit > 0 {
var staleRows []model.TaskCenterEventArchiveOutbox
// PROCESSING 超时行保留固定接管额度,避免 PENDING/FAILED 持续堆积时旧锁永久饥饿。
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
Where("status = ? AND update_time < ?", ArchiveStatusProcessing, staleProcessingBefore).
Order("update_time asc, id asc").
Limit(staleLimit).
Find(&staleRows).Error; err != nil {
return err
}
rows = append(rows, staleRows...)
}
if remaining := batchSize - len(rows); remaining > 0 {
pendingLimit := taskCenterArchivePendingClaimLimit(remaining)
for _, retryable := range []struct {
status string
limit int
}{
{status: ArchiveStatusPending, limit: pendingLimit},
{status: ArchiveStatusFailed, limit: remaining},
} {
if retryable.limit <= 0 || remaining <= 0 {
continue
}
if retryable.limit > remaining {
retryable.limit = remaining
}
var retryableRows []model.TaskCenterEventArchiveOutbox
// PENDING 和 FAILED 分开抢占,避免 IN 跨 status 排序触发 filesort同时给 FAILED 保留重试额度。
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
Where("status = ?", retryable.status).
Where("status IN ? OR (status = ? AND update_time < ?)",
[]string{ArchiveStatusPending, ArchiveStatusFailed},
ArchiveStatusProcessing,
staleProcessingBefore,
).
Order("create_time asc, id asc").
Limit(retryable.limit).
Find(&retryableRows).Error; err != nil {
Limit(batchSize).
Find(&rows).Error; err != nil {
return err
}
rows = append(rows, retryableRows...)
remaining -= len(retryableRows)
}
}
if len(rows) == 0 {
return nil
}
@ -156,44 +124,6 @@ func (s *Service) claimArchiveRows(ctx context.Context, batchSize int) ([]model.
return rows, nil
}
func normalizeTaskCenterArchiveBatchSize(batchSize int) int {
if batchSize <= 0 {
return defaultTaskCenterArchiveBatchSize
}
if batchSize > maxTaskCenterArchiveBatchSize {
return maxTaskCenterArchiveBatchSize
}
return batchSize
}
func taskCenterArchiveClaimLimits(batchSize int) (int, int) {
if batchSize <= 1 {
return batchSize, 0
}
staleLimit := batchSize / 10
if staleLimit < 1 {
staleLimit = 1
}
if staleLimit > 100 {
staleLimit = 100
}
return staleLimit, batchSize - staleLimit
}
func taskCenterArchivePendingClaimLimit(retryableLimit int) int {
if retryableLimit <= 1 {
return retryableLimit
}
failedReserve := retryableLimit / 10
if failedReserve < 1 {
failedReserve = 1
}
if failedReserve > 100 {
failedReserve = 100
}
return retryableLimit - failedReserve
}
func (s *Service) newCOSClient() (*cos.Client, error) {
bucketURL, err := url.Parse(fmt.Sprintf("https://%s.cos.%s.myqcloud.com",
strings.TrimSpace(s.cfg.TaskCenter.Archive.Bucket),

View File

@ -67,22 +67,6 @@ func (s *Service) ProcessEvent(ctx context.Context, req EventRequest) (*EventRes
}
occurredAt := parseOccurredAt(req.OccurredAt, time.Now())
receivedAt := time.Now()
// 游戏王以任务中心已经归一化的 GAME_CONSUME_GOLD 为唯一事实来源。这里必须位于
// task-center 自身去重事务之前即使当前没有配置任务活动仍要统计sink 失败时也
// 不能提前写入任务去重表,否则 MQ 重投会永久跳过活动入账。
if eventType == EventTypeGameConsumeGold && s.gameConsumeSink != nil {
if err := s.gameConsumeSink.ConsumeGameEvent(ctx, ValidatedEvent{
SysOrigin: sysOrigin,
EventID: eventID,
EventType: eventType,
UserID: userID,
DeltaValue: req.DeltaValue,
OccurredAt: occurredAt,
Payload: req.Payload,
}); err != nil {
return nil, err
}
}
rawJSON := buildArchiveRawJSON(req, sysOrigin, eventID, eventType, userID, req.DeltaValue, occurredAt, receivedAt, "", "")
bundle, err := s.loadEnabledBundle(ctx, sysOrigin)

View File

@ -42,73 +42,6 @@ func newTestService(t *testing.T, java taskCenterJavaGateway) (*Service, *gorm.D
return service, db
}
func TestNormalizeTaskCenterArchiveBatchSize(t *testing.T) {
tests := []struct {
name string
input int
want int
}{
{name: "default", input: 0, want: defaultTaskCenterArchiveBatchSize},
{name: "negative default", input: -1, want: defaultTaskCenterArchiveBatchSize},
{name: "keeps configured value", input: 200, want: 200},
{name: "caps large batch", input: 10000, want: maxTaskCenterArchiveBatchSize},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeTaskCenterArchiveBatchSize(tt.input); got != tt.want {
t.Fatalf("normalizeTaskCenterArchiveBatchSize(%d) = %d, want %d", tt.input, got, tt.want)
}
})
}
}
func TestTaskCenterArchiveClaimLimitsReserveStaleRows(t *testing.T) {
tests := []struct {
name string
batchSize int
wantStale int
wantRetryable int
}{
{name: "single row", batchSize: 1, wantStale: 1, wantRetryable: 0},
{name: "small batch keeps one stale slot", batchSize: 5, wantStale: 1, wantRetryable: 4},
{name: "normal batch uses ten percent", batchSize: 500, wantStale: 50, wantRetryable: 450},
{name: "large batch caps stale slot", batchSize: 1000, wantStale: 100, wantRetryable: 900},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotStale, gotRetryable := taskCenterArchiveClaimLimits(tt.batchSize)
if gotStale != tt.wantStale || gotRetryable != tt.wantRetryable {
t.Fatalf("taskCenterArchiveClaimLimits(%d) = (%d, %d), want (%d, %d)",
tt.batchSize, gotStale, gotRetryable, tt.wantStale, tt.wantRetryable)
}
})
}
}
func TestTaskCenterArchivePendingClaimLimitReservesFailedRows(t *testing.T) {
tests := []struct {
name string
retryableLimit int
want int
}{
{name: "empty", retryableLimit: 0, want: 0},
{name: "single row stays pending first", retryableLimit: 1, want: 1},
{name: "small retry batch keeps one failed slot", retryableLimit: 4, want: 3},
{name: "normal retry batch reserves ten percent", retryableLimit: 450, want: 405},
{name: "large retry batch caps failed reserve", retryableLimit: 900, want: 810},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := taskCenterArchivePendingClaimLimit(tt.retryableLimit); got != tt.want {
t.Fatalf("taskCenterArchivePendingClaimLimit(%d) = %d, want %d", tt.retryableLimit, got, tt.want)
}
})
}
}
func TestDailyTaskConfigEventListAndClaim(t *testing.T) {
java := &taskCenterTestGateway{events: map[string]bool{}}
service, db := newTestService(t, java)

View File

@ -62,24 +62,6 @@ type taskCenterJavaGateway interface {
ExistsGoldEvent(ctx context.Context, eventID string) (bool, error)
}
// ValidatedEvent 是任务中心完成身份、事件类型和正数金额校验后的稳定事件视图。
// 下游活动只依赖该视图,避免重复解释 HTTP/MQ 两种输入格式。
type ValidatedEvent struct {
SysOrigin string
EventID string
EventType string
UserID int64
DeltaValue int64
OccurredAt time.Time
Payload map[string]any
}
// GameConsumeSink 接收权威 GAME_CONSUME_GOLD 事件。
// sink 自己必须通过业务唯一键幂等,因为 HTTP 与 MQ 可能同时送达同一事件。
type GameConsumeSink interface {
ConsumeGameEvent(context.Context, ValidatedEvent) error
}
// Service 负责任务配置、进度、领取和事件入库。
type Service struct {
cfg config.Config
@ -87,7 +69,6 @@ type Service struct {
java taskCenterJavaGateway
location *time.Location
rocketConsumer rmq.PushConsumer
gameConsumeSink GameConsumeSink
}
func NewService(cfg config.Config, db taskCenterDB, java taskCenterJavaGateway) *Service {
@ -102,13 +83,6 @@ func NewService(cfg config.Config, db taskCenterDB, java taskCenterJavaGateway)
return &Service{cfg: cfg, db: db, java: java, location: location}
}
// SetGameConsumeSink 把固定游戏消耗事件扇出给活动模块。
func (s *Service) SetGameConsumeSink(sink GameConsumeSink) {
if s != nil {
s.gameConsumeSink = sink
}
}
// TaskConfigPayload 是后台配置页的任务读模型。
type TaskConfigPayload struct {
ID int64 `json:"id,string"`

View File

@ -170,7 +170,7 @@ func (s *Service) Purchase(ctx context.Context, user AuthUser, req PurchaseReque
if stateErr != nil {
return nil, stateErr
}
if err := s.ensureStateResourcesGranted(ctx, sysOrigin, user.UserID, state, now); err != nil {
if err := s.ensureStateResourcesGranted(ctx, user.UserID, state, now); err != nil {
return nil, NewAppError(http.StatusBadGateway, "vip_resource_grant_failed", err.Error())
}
resources, resourceErr := s.loadResourceLookup(ctx, appendResourceIDsFromState(nil, state))
@ -274,7 +274,6 @@ func (s *Service) Purchase(ctx context.Context, user AuthUser, req PurchaseReque
}
}
previousResources := vipGrantResourcesFromState(state)
applyPlanToState(state, plan, now)
order.Status = orderStatusSuccess
order.ErrorMessage = ""
@ -290,7 +289,7 @@ func (s *Service) Purchase(ctx context.Context, user AuthUser, req PurchaseReque
}
committed = true
if err := s.ensurePlanResourcesGranted(ctx, sysOrigin, user.UserID, plan, configs, previousResources, now); err != nil {
if err := s.ensurePlanResourcesGranted(ctx, user.UserID, plan, now); err != nil {
return nil, NewAppError(http.StatusBadGateway, "vip_resource_grant_failed", err.Error())
}
@ -537,28 +536,15 @@ func expireState(state *model.UserVipState, now time.Time) {
state.UpdateTime = now
}
func (s *Service) ensurePlanResourcesGranted(ctx context.Context, sysOrigin string, userID int64, plan purchasePlan, configs map[int]model.VipLevelConfig, additionalOldResources []vipGrantResource, now time.Time) error {
targetResources := vipGrantResourcesFromConfig(plan.TargetConfig)
if err := s.ensureVIPResourcesGranted(ctx, userID, targetResources, plan.ExpireAt, plan.DurationDays, now); err != nil {
return err
}
allVIPResources := append(vipGrantResourcesFromConfigs(configs), additionalOldResources...)
return s.cleanupSupersededVIPResources(ctx, userID, targetResources, allVIPResources, now)
func (s *Service) ensurePlanResourcesGranted(ctx context.Context, userID int64, plan purchasePlan, now time.Time) error {
return s.ensureVIPResourcesGranted(ctx, userID, vipGrantResourcesFromConfig(plan.TargetConfig), plan.ExpireAt, plan.DurationDays, now)
}
func (s *Service) ensureStateResourcesGranted(ctx context.Context, sysOrigin string, userID int64, state *model.UserVipState, now time.Time) error {
func (s *Service) ensureStateResourcesGranted(ctx context.Context, userID int64, state *model.UserVipState, now time.Time) error {
if !isActiveState(state, now) {
return nil
}
targetResources := vipGrantResourcesFromState(state)
if err := s.ensureVIPResourcesGranted(ctx, userID, targetResources, state.ExpireAt, vipGrantDaysUntil(now, state.ExpireAt), now); err != nil {
return err
}
configs, err := s.loadConfigMap(ctx, sysOrigin)
if err != nil {
return err
}
return s.cleanupSupersededVIPResources(ctx, userID, targetResources, vipGrantResourcesFromConfigs(configs), now)
return s.ensureVIPResourcesGranted(ctx, userID, vipGrantResourcesFromState(state), state.ExpireAt, vipGrantDaysUntil(now, state.ExpireAt), now)
}
func (s *Service) ensureVIPResourcesGranted(ctx context.Context, userID int64, resources []vipGrantResource, expireAt time.Time, days int, now time.Time) error {
@ -699,17 +685,6 @@ func vipGrantResourcesFromState(state *model.UserVipState) []vipGrantResource {
})
}
func vipGrantResourcesFromConfigs(configs map[int]model.VipLevelConfig) []vipGrantResource {
if len(configs) == 0 {
return nil
}
resources := make([]vipGrantResource, 0, len(configs)*7)
for _, config := range configs {
resources = append(resources, vipGrantResourcesFromConfig(config)...)
}
return compactVIPGrantResources(resources)
}
func compactVIPGrantResources(resources []vipGrantResource) []vipGrantResource {
compacted := make([]vipGrantResource, 0, len(resources))
for _, resource := range resources {
@ -720,65 +695,6 @@ func compactVIPGrantResources(resources []vipGrantResource) []vipGrantResource {
return compacted
}
func (s *Service) cleanupSupersededVIPResources(ctx context.Context, userID int64, targetResources []vipGrantResource, allVIPResources []vipGrantResource, now time.Time) error {
targets := make(map[string]struct{}, len(targetResources))
for _, resource := range targetResources {
targets[vipGrantResourceKey(resource)] = struct{}{}
}
badgeIDs := make([]int64, 0)
propsIDsByType := map[string][]int64{}
seen := make(map[string]struct{}, len(allVIPResources))
for _, resource := range allVIPResources {
if resource.ResourceID <= 0 {
continue
}
key := vipGrantResourceKey(resource)
if _, exists := targets[key]; exists {
continue
}
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
if resource.Badge {
badgeIDs = append(badgeIDs, resource.ResourceID)
continue
}
if strings.TrimSpace(resource.PropsType) != "" {
propsIDsByType[resource.PropsType] = append(propsIDsByType[resource.PropsType], resource.ResourceID)
}
}
if len(badgeIDs) > 0 {
if err := s.db.WithContext(ctx).Exec(
"UPDATE user_badge_backpack SET expire_type = ?, expire_time = ?, is_use_props = ?, update_time = ? WHERE user_id = ? AND badge_id IN ? AND expire_time > ?",
"TEMPORARY", now, false, now, userID, badgeIDs, now,
).Error; err != nil {
return err
}
}
for propsType, propsIDs := range propsIDsByType {
if len(propsIDs) == 0 {
continue
}
if err := s.db.WithContext(ctx).Exec(
"UPDATE user_props_backpack SET expire_time = ?, is_use_props = ?, allow_give = ?, update_time = ? WHERE user_id = ? AND props_id IN ? AND type = ? AND expire_time > ?",
now, false, false, now, userID, propsIDs, propsType, now,
).Error; err != nil {
return err
}
}
return nil
}
func vipGrantResourceKey(resource vipGrantResource) string {
if resource.Badge {
return fmt.Sprintf("BADGE:%d", resource.ResourceID)
}
return fmt.Sprintf("%s:%d", resource.PropsType, resource.ResourceID)
}
func vipGrantDaysUntil(now time.Time, expireAt time.Time) int {
if !expireAt.After(now) {
return 1

View File

@ -1,18 +1,13 @@
package vip
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"time"
"chatapp3-golang/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestBuildPurchasePlanNewPurchase(t *testing.T) {
@ -134,33 +129,6 @@ func TestVipResourcePayloadAcceptsStringResourceID(t *testing.T) {
}
}
func TestVipLevelPayloadMarshalsIDAsString(t *testing.T) {
payload := payloadFromConfig(model.VipLevelConfig{
ID: 2049402425177018368,
SysOrigin: "LIKEI",
Level: 1,
LevelCode: "VIP1",
DisplayName: "VIP 1",
Enabled: true,
})
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal vip level payload: %v", err)
}
if !strings.Contains(string(body), `"id":"2049402425177018368"`) {
t.Fatalf("vip level id was not emitted as string: %s", body)
}
var decoded VipLevelPayload
if err := json.Unmarshal(body, &decoded); err != nil {
t.Fatalf("unmarshal vip level payload: %v", err)
}
if decoded.ID.Int64() != 2049402425177018368 {
t.Fatalf("decoded id = %d, want 2049402425177018368", decoded.ID.Int64())
}
}
func TestResourcePayloadUsesResourceGroupDetails(t *testing.T) {
row := model.VipLevelConfig{
Level: 2,
@ -326,47 +294,6 @@ func TestVIPGrantResourcesFromConfigMapsBackpackTypes(t *testing.T) {
assertGrant(6, 16, vipPropsTypeDataCard, false)
}
func TestCleanupSupersededVIPResourcesExpiresOnlyOldVIPResources(t *testing.T) {
db := openVIPCleanupTestDB(t)
service := &Service{db: db}
now := time.Date(2026, 5, 30, 10, 0, 0, 0, time.UTC)
future := now.AddDate(0, 0, 20)
oldTime := now.AddDate(0, 0, -1)
userID := int64(1001)
seedVIPCleanupRows(t, db,
vipCleanupPropsRow{ID: 1, UserID: userID, PropsID: 101, Type: vipPropsTypeAvatarFrame, ExpireTime: future, UseProps: true, AllowGive: true, UpdateTime: oldTime},
vipCleanupPropsRow{ID: 2, UserID: userID, PropsID: 201, Type: vipPropsTypeAvatarFrame, ExpireTime: future, UseProps: true, AllowGive: true, UpdateTime: oldTime},
vipCleanupPropsRow{ID: 3, UserID: userID, PropsID: 999, Type: vipPropsTypeAvatarFrame, ExpireTime: future, UseProps: true, AllowGive: true, UpdateTime: oldTime},
vipCleanupPropsRow{ID: 4, UserID: userID, PropsID: 102, Type: vipPropsTypeRide, ExpireTime: future, UseProps: true, AllowGive: true, UpdateTime: oldTime},
)
seedVIPCleanupBadges(t, db,
vipCleanupBadgeRow{ID: 1, UserID: userID, BadgeID: 100, ExpireTime: future, UseProps: true, UpdateTime: oldTime},
vipCleanupBadgeRow{ID: 2, UserID: userID, BadgeID: 200, ExpireTime: future, UseProps: true, UpdateTime: oldTime},
)
target := vipGrantResourcesFromConfig(model.VipLevelConfig{
BadgeResourceID: 200,
AvatarFrameResourceID: 201,
})
all := append(vipGrantResourcesFromConfig(model.VipLevelConfig{
BadgeResourceID: 100,
AvatarFrameResourceID: 101,
EntryEffectResourceID: 102,
}), target...)
if err := service.cleanupSupersededVIPResources(context.Background(), userID, target, all, now); err != nil {
t.Fatalf("cleanupSupersededVIPResources() error = %v", err)
}
assertVIPCleanupProps(t, db, 101, now, false, false)
assertVIPCleanupProps(t, db, 102, now, false, false)
assertVIPCleanupProps(t, db, 201, future, true, true)
assertVIPCleanupProps(t, db, 999, future, true, true)
assertVIPCleanupBadge(t, db, 100, now, false)
assertVIPCleanupBadge(t, db, 200, future, true)
}
func TestVIPGrantDaysUntilCeilsPartialDay(t *testing.T) {
now := time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC)
expireAt := now.Add((24 * time.Hour) + time.Second)
@ -379,89 +306,6 @@ func TestVIPGrantDaysUntilCeilsPartialDay(t *testing.T) {
}
}
type vipCleanupPropsRow struct {
ID int64 `gorm:"column:id;primaryKey"`
UserID int64 `gorm:"column:user_id"`
PropsID int64 `gorm:"column:props_id"`
Type string `gorm:"column:type"`
ExpireTime time.Time `gorm:"column:expire_time"`
UseProps bool `gorm:"column:is_use_props"`
AllowGive bool `gorm:"column:allow_give"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (vipCleanupPropsRow) TableName() string {
return "user_props_backpack"
}
type vipCleanupBadgeRow struct {
ID int64 `gorm:"column:id;primaryKey"`
UserID int64 `gorm:"column:user_id"`
BadgeID int64 `gorm:"column:badge_id"`
ExpireType string `gorm:"column:expire_type"`
ExpireTime time.Time `gorm:"column:expire_time"`
UseProps bool `gorm:"column:is_use_props"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (vipCleanupBadgeRow) TableName() string {
return "user_badge_backpack"
}
func openVIPCleanupTestDB(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(&vipCleanupPropsRow{}, &vipCleanupBadgeRow{}); err != nil {
t.Fatalf("auto migrate cleanup tables: %v", err)
}
return db
}
func seedVIPCleanupRows(t *testing.T, db *gorm.DB, rows ...vipCleanupPropsRow) {
t.Helper()
for _, row := range rows {
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seed props row: %v", err)
}
}
}
func seedVIPCleanupBadges(t *testing.T, db *gorm.DB, rows ...vipCleanupBadgeRow) {
t.Helper()
for _, row := range rows {
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seed badge row: %v", err)
}
}
}
func assertVIPCleanupProps(t *testing.T, db *gorm.DB, propsID int64, expireTime time.Time, useProps bool, allowGive bool) {
t.Helper()
var row vipCleanupPropsRow
if err := db.Where("props_id = ?", propsID).First(&row).Error; err != nil {
t.Fatalf("load props %d: %v", propsID, err)
}
if !row.ExpireTime.Equal(expireTime) || row.UseProps != useProps || row.AllowGive != allowGive {
t.Fatalf("props %d = expire:%s use:%v allow:%v, want expire:%s use:%v allow:%v",
propsID, row.ExpireTime, row.UseProps, row.AllowGive, expireTime, useProps, allowGive)
}
}
func assertVIPCleanupBadge(t *testing.T, db *gorm.DB, badgeID int64, expireTime time.Time, useProps bool) {
t.Helper()
var row vipCleanupBadgeRow
if err := db.Where("badge_id = ?", badgeID).First(&row).Error; err != nil {
t.Fatalf("load badge %d: %v", badgeID, err)
}
if !row.ExpireTime.Equal(expireTime) || row.UseProps != useProps {
t.Fatalf("badge %d = expire:%s use:%v, want expire:%s use:%v",
badgeID, row.ExpireTime, row.UseProps, expireTime, useProps)
}
}
func vipTestConfigs() map[int]model.VipLevelConfig {
configs := make(map[int]model.VipLevelConfig, levelCount)
for level := 1; level <= levelCount; level++ {

View File

@ -83,7 +83,7 @@ type ConfigResponse struct {
}
type VipLevelPayload struct {
ID ResourceID `json:"id"`
ID int64 `json:"id"`
Level int `json:"level"`
LevelCode string `json:"levelCode"`
DisplayName string `json:"displayName"`
@ -365,7 +365,7 @@ func payloadFromConfigWithResources(row model.VipLevelConfig, resources vipResou
code = levelCode(row.Level)
}
return VipLevelPayload{
ID: ResourceID(row.ID),
ID: row.ID,
Level: row.Level,
LevelCode: code,
DisplayName: displayName,

View File

@ -182,12 +182,6 @@ func (s *Service) completeClaimWallet(ctx context.Context, claim model.VoiceRoom
}
now := time.Now()
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var lockedPacket model.VoiceRoomRedPacket
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", claim.PacketID).
First(&lockedPacket).Error; err != nil {
return err
}
if err := tx.Model(&model.VoiceRoomRedPacketClaim{}).
Where("id = ?", claim.ID).
Updates(map[string]any{
@ -206,7 +200,7 @@ func (s *Service) completeClaimWallet(ctx context.Context, claim model.VoiceRoom
return err
}
return tx.Model(&model.VoiceRoomRedPacket{}).
Where("id = ? AND remain_count = 0 AND status = ?", lockedPacket.ID, packetStatusActive).
Where("id = ? AND remain_count = 0 AND status = ?", claim.PacketID, packetStatusActive).
Updates(map[string]any{
"status": packetStatusFinished,
"update_time": now,

View File

@ -399,9 +399,6 @@ func TestInRoomRewardWorkerSnapshotsSelectsAndGrants(t *testing.T) {
if err := rdb.SAdd(ctx, onlineRoomKey("LIKEI", roomID), userID).Err(); err != nil {
t.Fatalf("sadd online: %v", err)
}
if err := rdb.Set(ctx, userRoomKey("LIKEI", userID), strconv.FormatInt(roomID, 10), time.Minute).Err(); err != nil {
t.Fatalf("set user room: %v", err)
}
if err := rdb.Set(ctx, userSeenKey("LIKEI", userID), strconv.FormatInt(time.Now().UnixMilli(), 10), time.Minute).Err(); err != nil {
t.Fatalf("set seen: %v", err)
}
@ -474,92 +471,6 @@ func TestInRoomRewardWorkerSnapshotsSelectsAndGrants(t *testing.T) {
}
}
func TestInRoomRewardWorkerSkipsUsersNoLongerInRoom(t *testing.T) {
gateway := newFakeRocketGateway()
service, db, rdb, mr := newTestServiceWithRedis(t, gateway)
defer mr.Close()
ctx := context.Background()
now := time.Now()
seedRocketConfig(t, db)
seedLevelConfigs(t, db, []int64{100, 200, 300, 400, 500, 600})
seedRewardConfig(t, db, 1, rewardSceneTop1, rewardTypeGold, 10)
seedRewardConfig(t, db, 1, rewardSceneIgnite, rewardTypeGold, 5)
seedRewardConfigWithCopies(t, db, 1, rewardSceneInRoom, rewardTypeGold, 1, 4)
roomID := int64(9001)
for _, userID := range []int64{1001, 1002} {
if err := rdb.SAdd(ctx, onlineRoomKey("LIKEI", roomID), userID).Err(); err != nil {
t.Fatalf("sadd active online: %v", err)
}
if err := rdb.Set(ctx, userRoomKey("LIKEI", userID), strconv.FormatInt(roomID, 10), time.Minute).Err(); err != nil {
t.Fatalf("set active room: %v", err)
}
if err := rdb.Set(ctx, userSeenKey("LIKEI", userID), strconv.FormatInt(now.UnixMilli(), 10), time.Minute).Err(); err != nil {
t.Fatalf("set active seen: %v", err)
}
}
staleUsers := []int64{1003, 1004}
for _, userID := range staleUsers {
if err := rdb.SAdd(ctx, onlineRoomKey("LIKEI", roomID), userID).Err(); err != nil {
t.Fatalf("sadd stale online: %v", err)
}
if err := rdb.Set(ctx, userSeenKey("LIKEI", userID), strconv.FormatInt(now.UnixMilli(), 10), time.Minute).Err(); err != nil {
t.Fatalf("set stale seen: %v", err)
}
}
if err := rdb.Set(ctx, userRoomKey("LIKEI", staleUsers[1]), strconv.FormatInt(roomID+1, 10), time.Minute).Err(); err != nil {
t.Fatalf("set stale room: %v", err)
}
payload := `{
"trackId":"track-launch-in-room-stale",
"sysOrigin":"LIKEI",
"sendUserId":"1001",
"roomId":"9001",
"quantity":1,
"giftConfig":{"id":"11","giftCandy":100,"type":"GOLD","giftTab":"NORMAL"},
"createTime":` + strconvFormatMillis(now) + `
}`
resp, err := service.ProcessGiftPayload(ctx, payload)
if err != nil {
t.Fatalf("ProcessGiftPayload() error = %v", err)
}
if !resp.Processed {
t.Fatalf("response = %+v, want processed", resp)
}
if _, err := service.ProcessDueLaunches(ctx, now.Add(11*time.Second), 10); err != nil {
t.Fatalf("ProcessDueLaunches() error = %v", err)
}
if _, err := service.ProcessDueInRoomRewards(ctx, now.Add(30*time.Second), 10); err != nil {
t.Fatalf("ProcessDueInRoomRewards() error = %v", err)
}
var snapshotCount int64
if err := db.Model(&model.VoiceRoomRocketAudienceSnapshot{}).Count(&snapshotCount).Error; err != nil {
t.Fatalf("count snapshot: %v", err)
}
if snapshotCount != 2 {
t.Fatalf("snapshot count = %d, want 2 active users only", snapshotCount)
}
for _, userID := range staleUsers {
var userSnapshotCount int64
if err := db.Model(&model.VoiceRoomRocketAudienceSnapshot{}).Where("user_id = ?", userID).Count(&userSnapshotCount).Error; err != nil {
t.Fatalf("count stale snapshot: %v", err)
}
if userSnapshotCount != 0 {
t.Fatalf("stale user %d snapshot count = %d, want 0", userID, userSnapshotCount)
}
var rewardCount int64
if err := db.Model(&model.VoiceRoomRocketRewardRecord{}).Where("user_id = ? AND reward_scene = ?", userID, rewardSceneInRoom).Count(&rewardCount).Error; err != nil {
t.Fatalf("count stale reward: %v", err)
}
if rewardCount != 0 {
t.Fatalf("stale user %d in-room reward count = %d, want 0", userID, rewardCount)
}
}
}
func TestProcessPendingRewardsGrantsBadgeThroughBadgeGateway(t *testing.T) {
gateway := newFakeRocketGateway()
service, db := newTestService(t)

View File

@ -73,16 +73,16 @@ func pointerTimeValue(value *time.Time) time.Time {
return *value
}
func decimalPercent(current, need int64) (string, float64) {
func decimalPercent(current, need int64) (string, int) {
if need <= 0 || current <= 0 {
return "0.0000", 0
}
if current >= need {
return "1.0000", 1
percent := float64(current) * 100 / float64(need)
display := int(math.Floor(percent))
if display > 100 {
display = 100
}
// App 端按 0-1 比例换算百分比;截断而非四舍五入,避免未充满时提前显示 100%。
ratio := math.Floor(float64(current)/float64(need)*10000) / 10000
return fmt.Sprintf("%.4f", ratio), ratio
return fmt.Sprintf("%.4f", percent), display
}
func parsePercent(value string) float64 {

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