Compare commits

..

No commits in common. "main" and "test" have entirely different histories.
main ... test

106 changed files with 271 additions and 14317 deletions

View File

@ -18,11 +18,9 @@ import (
"chatapp3-golang/internal/service/cprelationbroadcast" "chatapp3-golang/internal/service/cprelationbroadcast"
"chatapp3-golang/internal/service/errorlog" "chatapp3-golang/internal/service/errorlog"
"chatapp3-golang/internal/service/firstrechargereward" "chatapp3-golang/internal/service/firstrechargereward"
"chatapp3-golang/internal/service/gameking"
"chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameopen"
"chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/gameprovider"
"chatapp3-golang/internal/service/hostcenter" "chatapp3-golang/internal/service/hostcenter"
"chatapp3-golang/internal/service/hotgame"
"chatapp3-golang/internal/service/invite" "chatapp3-golang/internal/service/invite"
"chatapp3-golang/internal/service/lingxian" "chatapp3-golang/internal/service/lingxian"
"chatapp3-golang/internal/service/luckygift" "chatapp3-golang/internal/service/luckygift"
@ -42,7 +40,6 @@ import (
"chatapp3-golang/internal/service/voiceroomrocket" "chatapp3-golang/internal/service/voiceroomrocket"
"chatapp3-golang/internal/service/weekstar" "chatapp3-golang/internal/service/weekstar"
"chatapp3-golang/internal/service/wheel" "chatapp3-golang/internal/service/wheel"
"chatapp3-golang/internal/service/yumigiftchallenge"
) )
func main() { func main() {
@ -60,7 +57,6 @@ func main() {
baishunService := baishun.NewBaishunService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) baishunService := baishun.NewBaishunService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
binanceRechargeService := binancerecharge.NewService(app.Config, app.Repository.DB, &app.Gateways) binanceRechargeService := binancerecharge.NewService(app.Config, app.Repository.DB, &app.Gateways)
lingxianService := lingxian.NewService(app.Config, app.Repository.DB, app.Repository.Redis) 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) gameOpenService := gameopen.NewGameOpenService(app.Config, app.Repository.DB, &app.Gateways)
luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet) luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet)
rechargeAgencyService := rechargeagency.NewService(app.Config, app.Repository.DB, &app.Gateways) rechargeAgencyService := rechargeagency.NewService(app.Config, app.Repository.DB, &app.Gateways)
@ -69,7 +65,6 @@ func main() {
firstRechargeRewardService := firstrechargereward.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) signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
taskCenterService := taskcenter.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)
userBadgeService := userbadge.NewService(app.Repository.DB) userBadgeService := userbadge.NewService(app.Repository.DB)
vipService := vip.NewService(app.Config, app.Repository.DB, &app.Gateways) vipService := vip.NewService(app.Config, app.Repository.DB, &app.Gateways)
wheelService := wheel.NewService(app.Config, app.Repository.DB, &app.Gateways) wheelService := wheel.NewService(app.Config, app.Repository.DB, &app.Gateways)
@ -83,7 +78,6 @@ func main() {
voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
voiceRoomRocketService.SetRegionBroadcaster(regionIMGroupService) voiceRoomRocketService.SetRegionBroadcaster(regionIMGroupService)
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
yumiGiftChallengeService := yumigiftchallenge.NewService(app.Config, app.Repository.DB, &app.Gateways)
roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways) roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways)
hostCenterService := hostcenter.NewService(app.Config, &app.Gateways) hostCenterService := hostcenter.NewService(app.Config, &app.Gateways)
managerCenterService := managercenter.NewService(app.Repository.DB, &app.Gateways) managerCenterService := managercenter.NewService(app.Repository.DB, &app.Gateways)
@ -92,8 +86,6 @@ func main() {
baishunService.SetTaskEventReporter(taskCenterService) baishunService.SetTaskEventReporter(taskCenterService)
gameOpenService.SetTaskEventReporter(taskCenterService) gameOpenService.SetTaskEventReporter(taskCenterService)
weekStarService.SetTaskEventReporter(taskCenterService) weekStarService.SetTaskEventReporter(taskCenterService)
// GAME_CONSUME_GOLD 在任务中心完成统一校验后扇出HTTP 与 MQ 重投由活动账本幂等。
taskCenterService.SetGameConsumeSink(gameKingService)
workerCtx, workerCancel := context.WithCancel(context.Background()) workerCtx, workerCancel := context.WithCancel(context.Background())
defer workerCancel() defer workerCancel()
@ -103,9 +95,6 @@ func main() {
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil { if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start week star message consumer failed: %v", err) log.Fatalf("start week star message consumer failed: %v", err)
} }
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 { if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start task center message consumer failed: %v", err) log.Fatalf("start task center message consumer failed: %v", err)
} }
@ -126,9 +115,7 @@ func main() {
gameProviders := gameprovider.NewRegistry( gameProviders := gameprovider.NewRegistry(
baishun.NewAppProvider(baishunService), baishun.NewAppProvider(baishunService),
lingxian.NewAppProvider(lingxianService), lingxian.NewAppProvider(lingxianService),
hotgame.NewAppProvider(hotgameService),
) )
gameVisibility := gameprovider.NewVisibilityPolicy(&app.Gateways)
engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{ engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{
AppPopup: appPopupService, AppPopup: appPopupService,
ErrorLog: errorLogService, ErrorLog: errorLogService,
@ -138,14 +125,11 @@ func main() {
FirstRechargeReward: firstRechargeRewardService, FirstRechargeReward: firstRechargeRewardService,
Lingxian: lingxianService, Lingxian: lingxianService,
GameOpen: gameOpenService, GameOpen: gameOpenService,
GameKing: gameKingService,
GameProviders: gameProviders, GameProviders: gameProviders,
GameVisibility: gameVisibility,
LuckyGift: luckyGiftService, LuckyGift: luckyGiftService,
ManagerCenter: managerCenterService, ManagerCenter: managerCenterService,
PropsStore: propsStoreService, PropsStore: propsStoreService,
HostCenter: hostCenterService, HostCenter: hostCenterService,
Hotgame: hotgameService,
RechargeAgency: rechargeAgencyService, RechargeAgency: rechargeAgencyService,
RechargeReward: rechargeRewardService, RechargeReward: rechargeRewardService,
RegionIMGroup: regionIMGroupService, RegionIMGroup: regionIMGroupService,
@ -160,7 +144,6 @@ func main() {
VoiceRoomRedPacket: voiceRoomRedPacketService, VoiceRoomRedPacket: voiceRoomRedPacketService,
VoiceRoomRocket: voiceRoomRocketService, VoiceRoomRocket: voiceRoomRocketService,
WeekStar: weekStarService, WeekStar: weekStarService,
YumiGiftChallenge: yumiGiftChallengeService,
}) })
server := &http.Server{ server := &http.Server{
Addr: app.Config.HTTP.ListenAddr, Addr: app.Config.HTTP.ListenAddr,

View File

@ -4,14 +4,12 @@ import (
"chatapp3-golang/internal/bootstrap" "chatapp3-golang/internal/bootstrap"
"chatapp3-golang/internal/service/cprelationbroadcast" "chatapp3-golang/internal/service/cprelationbroadcast"
"chatapp3-golang/internal/service/firstrechargereward" "chatapp3-golang/internal/service/firstrechargereward"
"chatapp3-golang/internal/service/gameking"
"chatapp3-golang/internal/service/regionimgroup" "chatapp3-golang/internal/service/regionimgroup"
"chatapp3-golang/internal/service/registerreward" "chatapp3-golang/internal/service/registerreward"
"chatapp3-golang/internal/service/taskcenter" "chatapp3-golang/internal/service/taskcenter"
"chatapp3-golang/internal/service/voiceroomredpacket" "chatapp3-golang/internal/service/voiceroomredpacket"
"chatapp3-golang/internal/service/voiceroomrocket" "chatapp3-golang/internal/service/voiceroomrocket"
"chatapp3-golang/internal/service/weekstar" "chatapp3-golang/internal/service/weekstar"
"chatapp3-golang/internal/service/yumigiftchallenge"
"context" "context"
"log" "log"
"os/signal" "os/signal"
@ -30,11 +28,7 @@ func main() {
registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
yumiGiftChallengeService := yumigiftchallenge.NewService(app.Config, app.Repository.DB, &app.Gateways)
taskCenterService := taskcenter.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) firstRechargeRewardService := firstrechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways)
regionIMGroupService := regionimgroup.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) cpRelationBroadcastService := cprelationbroadcast.NewService(app.Config, app.Repository.DB, regionIMGroupService)
@ -52,9 +46,6 @@ func main() {
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil { if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start week star message consumer failed: %v", err) log.Fatalf("start week star message consumer failed: %v", err)
} }
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 { if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start task center message consumer failed: %v", err) log.Fatalf("start task center message consumer 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 SysOrigin string
Token string Token string
Authorization string Authorization string
RegionID string
RegionCode string
} }

View File

@ -25,7 +25,6 @@ type Config struct {
FirstRechargeReward FirstRechargeRewardConfig FirstRechargeReward FirstRechargeRewardConfig
TaskCenter TaskCenterConfig TaskCenter TaskCenterConfig
WeekStar WeekStarConfig WeekStar WeekStarConfig
YumiGiftChallenge YumiGiftChallengeConfig
RoomTurnoverReward RoomTurnoverRewardConfig RoomTurnoverReward RoomTurnoverRewardConfig
VoiceRoomRedPacket VoiceRoomRedPacketConfig VoiceRoomRedPacket VoiceRoomRedPacketConfig
VoiceRoomRocket VoiceRoomRocketConfig VoiceRoomRocket VoiceRoomRocketConfig
@ -33,7 +32,6 @@ type Config struct {
TencentIM TencentIMConfig TencentIM TencentIMConfig
Baishun BaishunConfig Baishun BaishunConfig
Lingxian LingxianConfig Lingxian LingxianConfig
Hotgame HotgameConfig
GameOpen GameOpenConfig GameOpen GameOpenConfig
LuckyGift LuckyGiftConfig LuckyGift LuckyGiftConfig
BinanceRecharge BinanceRechargeConfig BinanceRecharge BinanceRechargeConfig
@ -150,13 +148,6 @@ type TaskCenterArchiveConfig struct {
BatchSize int BatchSize int
FlushSeconds int FlushSeconds int
Workers int Workers int
GC TaskCenterArchiveGCConfig
}
// TaskCenterArchiveGCConfig 只保存清理总开关。cutoff 和批量必须由一次管理预检冻结,
// 不能由多节点各自读取易漂移的 NOW()/环境变量后直接删数据。
type TaskCenterArchiveGCConfig struct {
Enabled bool
} }
// WeekStarConfig 保存周榜模块配置。 // WeekStarConfig 保存周榜模块配置。
@ -180,13 +171,6 @@ type WeekStarRocketMQConfig struct {
Tag string Tag string
} }
// YumiGiftChallengeConfig 保存 Yumi 礼物挑战的独立送礼消费者和发奖租期配置。
type YumiGiftChallengeConfig struct {
DefaultSysOrigin string
DeliveryProcessingLeaseSeconds int
RocketMQ WeekStarRocketMQConfig
}
// RoomTurnoverRewardConfig 保存房间流水奖励模块配置。 // RoomTurnoverRewardConfig 保存房间流水奖励模块配置。
type RoomTurnoverRewardConfig struct { type RoomTurnoverRewardConfig struct {
DefaultSysOrigin string DefaultSysOrigin string
@ -284,12 +268,6 @@ type LingxianConfig struct {
AppKey string AppKey string
} }
// HotgameConfig 保存热游模块的环境兜底配置。
type HotgameConfig struct {
AppKey string
CallbackBaseURL string
}
// GameOpenConfig 保存统一三方游戏回调配置。 // GameOpenConfig 保存统一三方游戏回调配置。
type GameOpenConfig struct { type GameOpenConfig struct {
PublicBaseURL string PublicBaseURL string
@ -330,12 +308,6 @@ func Load() Config {
weekStarRocketMQEnabledDefault := strings.TrimSpace(weekStarRocketMQEndpoint) != "" && weekStarRocketMQEnabledDefault := strings.TrimSpace(weekStarRocketMQEndpoint) != "" &&
strings.TrimSpace(weekStarRocketMQAccessKey) != "" && strings.TrimSpace(weekStarRocketMQAccessKey) != "" &&
strings.TrimSpace(weekStarRocketMQAccessSecret) != "" 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"}, "") 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"}, "") 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"}, "") taskCenterMQAccessSecret := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ACCESS_SECRET", "TASK_CENTER_EVENT_MQ_SECRET_KEY", "LIKEI_ROCKETMQ_SECRET_KEY"}, "")
@ -460,13 +432,9 @@ func Load() Config {
SecretID: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_SECRET_ID", "TASK_CENTER_ARCHIVE_COS_SECRET_ID", "LIKEI_TENCENT_COS_SECRET_ID"}, ""), 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"}, ""), 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"), 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), 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), Workers: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_WORKERS", "TASK_CENTER_ARCHIVE_WORKERS"}, 2),
GC: TaskCenterArchiveGCConfig{
// 清理涉及不可逆数据库删除;新环境必须显式开启,避免仅部署代码就开始工作。
Enabled: getEnvBoolAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_GC_ENABLED", "TASK_CENTER_ARCHIVE_GC_ENABLED"}, false),
},
}, },
}, },
WeekStar: WeekStarConfig{ WeekStar: WeekStarConfig{
@ -485,22 +453,6 @@ func Load() Config {
Tag: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_TAG", "WEEK_STAR_ROCKETMQ_TAG"}, "give_gift_v3"), 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{ RoomTurnoverReward: RoomTurnoverRewardConfig{
DefaultSysOrigin: strings.ToUpper(getEnvAny( 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"}, []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"},
@ -665,10 +617,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"), 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"}, ""), 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{ GameOpen: GameOpenConfig{
PublicBaseURL: getEnvAny([]string{"CHATAPP_GAME_OPEN_PUBLIC_BASE_URL", "GAME_OPEN_PUBLIC_BASE_URL"}, ""), 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"), AppKey: getEnvAny([]string{"CHATAPP_GAME_OPEN_APP_KEY", "GAME_OPEN_APP_KEY"}, "game-open-test-key"),

View File

@ -56,16 +56,6 @@ func (g *Gateways) AuthenticateConsoleToken(ctx context.Context, authorization s
return g.Auth.AuthenticateConsoleToken(ctx, authorization) 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 透传到设备网关。 // GetDeviceFingerprint 透传到设备网关。
func (g *Gateways) GetDeviceFingerprint(ctx context.Context, userID int64) (string, error) { func (g *Gateways) GetDeviceFingerprint(ctx context.Context, userID int64) (string, error) {
return g.Device.GetDeviceFingerprint(ctx, userID) 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) return g.RewardDispatch.SendActivityReward(ctx, req)
} }
// SendFrozenActivityReward 透传活动冻结奖励项发放网关。
func (g *Gateways) SendFrozenActivityReward(ctx context.Context, req SendFrozenActivityRewardRequest) error {
return g.RewardDispatch.SendFrozenActivityReward(ctx, req)
}
// GetRewardGroupDetail 透传到奖励查询网关。 // GetRewardGroupDetail 透传到奖励查询网关。
func (g *Gateways) GetRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) { func (g *Gateways) GetRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) {
return g.RewardQuery.GetRewardGroupDetail(ctx, groupID) 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 透传到资料网关。 // GetUserProfile 透传到资料网关。
func (g *Gateways) GetUserProfile(ctx context.Context, userID int64) (UserProfile, error) { func (g *Gateways) GetUserProfile(ctx context.Context, userID int64) (UserProfile, error) {
return g.Profile.GetUserProfile(ctx, userID) 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 透传到用户地区网关。 // GetUserRegion 透传到用户地区网关。
func (g *Gateways) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) { func (g *Gateways) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) {
return g.Profile.GetUserRegion(ctx, userID, authorization) 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 区域配置网关。 // ResolveRegionCodeByCountryCode 透传到 Java 区域配置网关。
func (g *Gateways) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) { func (g *Gateways) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
return g.Profile.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode) 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) 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 负责设备指纹相关接口。 // DeviceGateway 负责设备指纹相关接口。
type DeviceGateway struct { type DeviceGateway struct {
client *Client client *Client
@ -405,11 +355,6 @@ func (g *RewardDispatchGateway) SendActivityReward(ctx context.Context, req Send
return g.client.SendActivityReward(ctx, req) return g.client.SendActivityReward(ctx, req)
} }
// SendFrozenActivityReward 发放一个已经冻结的活动奖励项。
func (g *RewardDispatchGateway) SendFrozenActivityReward(ctx context.Context, req SendFrozenActivityRewardRequest) error {
return g.client.SendFrozenActivityReward(ctx, req)
}
// RewardQueryGateway 负责奖励配置查询接口。 // RewardQueryGateway 负责奖励配置查询接口。
type RewardQueryGateway struct { type RewardQueryGateway struct {
client *Client client *Client
@ -420,11 +365,6 @@ func (g *RewardQueryGateway) GetRewardGroupDetail(ctx context.Context, groupID i
return g.client.GetRewardGroupDetail(ctx, groupID) 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 能力配置。 // GetNobleVIPAbility 查询贵族 VIP 能力配置。
func (g *RewardQueryGateway) GetNobleVIPAbility(ctx context.Context, sourceID int64) (PropsNobleVIPAbility, error) { func (g *RewardQueryGateway) GetNobleVIPAbility(ctx context.Context, sourceID int64) (PropsNobleVIPAbility, error) {
return g.client.GetNobleVIPAbility(ctx, sourceID) 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) 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 查询用户当前地区。 // GetUserRegion 查询用户当前地区。
func (g *ProfileGateway) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) { func (g *ProfileGateway) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) {
return g.client.GetUserRegion(ctx, userID, authorization) 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 区域配置把国家码解析成区域编码。 // ResolveRegionCodeByCountryCode 根据 Java 区域配置把国家码解析成区域编码。
func (g *ProfileGateway) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) { func (g *ProfileGateway) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
return g.client.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode) return g.client.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode)

View File

@ -41,11 +41,6 @@ type ConsoleAccount struct {
Token string `json:"token"` Token string `json:"token"`
} }
type ConsoleMenu struct {
Alias string `json:"alias"`
Childrens []ConsoleMenu `json:"childrens"`
}
type UserProfile struct { type UserProfile struct {
ID Int64Value `json:"id"` ID Int64Value `json:"id"`
Account string `json:"account"` Account string `json:"account"`
@ -64,12 +59,6 @@ type UserRegion struct {
RegionCode string `json:"regionCode"` RegionCode string `json:"regionCode"`
} }
type UserLevel struct {
UserID Int64Value `json:"userId"`
WealthLevel int `json:"wealthLevel"`
CharmLevel int `json:"charmLevel"`
}
type RegionConfig struct { type RegionConfig struct {
RegionCode string `json:"regionCode"` RegionCode string `json:"regionCode"`
SysOrigin string `json:"sysOrigin"` SysOrigin string `json:"sysOrigin"`
@ -204,30 +193,6 @@ type SendActivityRewardRequest struct {
AcceptUserID int64 `json:"acceptUserId"` 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 { type TeamPolicyReleaseQuery struct {
SysOrigin string SysOrigin string
Region string Region string
@ -274,30 +239,20 @@ type RewardGroupItem struct {
Type string `json:"type"` Type string `json:"type"`
DetailType string `json:"detailType"` DetailType string `json:"detailType"`
Name string `json:"name"` Name string `json:"name"`
BadgeName string `json:"badgeName"`
Content string `json:"content"` Content string `json:"content"`
Quantity Int64Value `json:"quantity"` Quantity Int64Value `json:"quantity"`
Cover string `json:"cover"` Cover string `json:"cover"`
SourceURL string `json:"sourceUrl"` SourceURL string `json:"sourceUrl"`
Remark string `json:"remark"` Remark string `json:"remark"`
Sort int `json:"sort"`
} }
type RewardGroupDetail struct { type RewardGroupDetail struct {
ID Int64Value `json:"id"` ID Int64Value `json:"id"`
Name string `json:"name"` Name string `json:"name"`
SysOrigin string `json:"sysOrigin"` SysOrigin string `json:"sysOrigin"`
ShelfStatus *bool `json:"shelfStatus,omitempty"`
RewardConfigList []RewardGroupItem `json:"rewardConfigList"` 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 { type UserTotalRecharge struct {
ID Int64Value `json:"id"` ID Int64Value `json:"id"`
UserID Int64Value `json:"userId"` UserID Int64Value `json:"userId"`
@ -380,11 +335,8 @@ type freightSellerPageResponse struct {
type resultResponse[T any] struct { type resultResponse[T any] struct {
Code int `json:"code"` Code int `json:"code"`
Message string `json:"message"` Message string `json:"message"`
ErrorMsg string `json:"errorMsg"`
Body T `json:"body"` Body T `json:"body"`
Data T `json:"data"`
Success *bool `json:"success"` Success *bool `json:"success"`
Status *bool `json:"status"`
} }
type Int64Value int64 type Int64Value int64
@ -547,63 +499,6 @@ func (c *Client) AuthenticateConsoleToken(ctx context.Context, authorization str
return resp.Body, nil 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 { func javaConsoleHeaders(authorization string) http.Header {
headers := http.Header{} headers := http.Header{}
headers.Set("Authorization", strings.TrimSpace(authorization)) headers.Set("Authorization", strings.TrimSpace(authorization))
@ -723,22 +618,6 @@ func javaAppHeadersFromAuthorization(authorization string) http.Header {
return headers 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 { func trimBearerToken(authorization string) string {
token := strings.TrimSpace(authorization) token := strings.TrimSpace(authorization)
if len(token) >= 7 && strings.EqualFold(token[:7], "Bearer ") { if len(token) >= 7 && strings.EqualFold(token[:7], "Bearer ") {
@ -756,40 +635,7 @@ func (c *Client) GrantProps(ctx context.Context, req GrantPropsRequest) error {
} }
func (c *Client) SendActivityReward(ctx context.Context, req SendActivityRewardRequest) error { func (c *Client) SendActivityReward(ctx context.Context, req SendActivityRewardRequest) error {
var resp resultResponse[any] return c.postJSON(ctx, c.cfg.Java.OtherBaseURL+"/props-activity-cnf/client/sendActivityReward", req, nil, nil)
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
} }
func (c *Client) GivePropsBackpack(ctx context.Context, req GivePropsBackpackRequest) error { func (c *Client) GivePropsBackpack(ctx context.Context, req GivePropsBackpackRequest) error {
@ -852,154 +698,15 @@ func (c *Client) SendOfficialNoticeCustomize(ctx context.Context, req OfficialNo
} }
func (c *Client) GetRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) { func (c *Client) GetRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) {
detail, err := c.getInnerRewardGroupDetail(ctx, groupID) endpoint := c.cfg.Java.OtherBaseURL + "/props-activity/reward/group/client/getByGroupId?id=" + url.QueryEscape(strconv.FormatInt(groupID, 10))
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))
var resp resultResponse[RewardGroupDetail] var resp resultResponse[RewardGroupDetail]
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
return RewardGroupDetail{}, err return RewardGroupDetail{}, err
} }
detail := resp.Body if int64(resp.Body.ID) == 0 {
if int64(detail.ID) == 0 {
detail = resp.Data
}
if int64(detail.ID) == 0 {
return RewardGroupDetail{}, fmt.Errorf("empty reward group response") return RewardGroupDetail{}, fmt.Errorf("empty reward group response")
} }
return detail, nil return resp.Body, 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
} }
func (c *Client) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]UserTotalRecharge, error) { func (c *Client) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]UserTotalRecharge, error) {
@ -1114,8 +821,8 @@ func (c *Client) ChangeFreightBalance(ctx context.Context, req FreightBalanceCha
if err := c.postJSON(ctx, endpoint, req, nil, &resp); err != nil { if err := c.postJSON(ctx, endpoint, req, nil, &resp); err != nil {
return err return err
} }
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) { if resp.Success != nil && !*resp.Success {
message := strings.TrimSpace(firstNonEmpty(resp.ErrorMsg, resp.Message)) message := strings.TrimSpace(resp.Message)
if message == "" { if message == "" {
message = "freight balance change failed" message = "freight balance change failed"
} }
@ -1174,90 +881,6 @@ func (c *Client) GetUserProfile(ctx context.Context, userID int64) (UserProfile,
return resp.Body, nil 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) { func (c *Client) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) {
if userID <= 0 { if userID <= 0 {
return UserRegion{}, fmt.Errorf("userId is required") return UserRegion{}, fmt.Errorf("userId is required")
@ -1274,21 +897,6 @@ func (c *Client) GetUserRegion(ctx context.Context, userID int64, authorization
return resp.Body, nil 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) { func (c *Client) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
countryCode = strings.ToUpper(strings.TrimSpace(countryCode)) countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
if countryCode == "" { if countryCode == "" {

View File

@ -77,131 +77,3 @@ func TestResolveRegionCodeByCountryCodeUsesRegionConfig(t *testing.T) {
t.Fatalf("region config calls = %d, want cached 1", calls) 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

@ -7,7 +7,6 @@ type FirstRechargeRewardConfig struct {
ID int64 `gorm:"column:id;primaryKey"` ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_first_recharge_reward_sys_origin"` 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"` 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"` CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"` UpdateTime time.Time `gorm:"column:update_time"`
} }
@ -20,7 +19,6 @@ type FirstRechargeRewardLevel struct {
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"` 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"` 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"` 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"` RewardGroupID int64 `gorm:"column:reward_group_id"`
RewardGroupName string `gorm:"column:reward_group_name;size:255"` RewardGroupName string `gorm:"column:reward_group_name;size:255"`
Enabled bool `gorm:"column:enabled"` Enabled bool `gorm:"column:enabled"`
@ -48,7 +46,6 @@ type FirstRechargeRewardGrantRecord struct {
Level int `gorm:"column:level"` Level int `gorm:"column:level"`
PayPlatform string `gorm:"column:pay_platform;size:64"` PayPlatform string `gorm:"column:pay_platform;size:64"`
PaymentMethod string `gorm:"column:payment_method;size:32"` 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"` SourceOrderID string `gorm:"column:source_order_id;size:128;index:idx_first_recharge_reward_order"`
RewardGroupID int64 `gorm:"column:reward_group_id"` RewardGroupID int64 `gorm:"column:reward_group_id"`
RewardGroupName string `gorm:"column:reward_group_name;size:255"` RewardGroupName string `gorm:"column:reward_group_name;size:255"`

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 的原始事件。 // TaskCenterEventArchiveOutbox 保存待归档到 COS 的原始事件。
type TaskCenterEventArchiveOutbox struct { 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"` ID int64 `gorm:"column:id;primaryKey"`
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"` 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"` 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"` 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"` 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"` RetryCount int `gorm:"column:retry_count"`
COSKey string `gorm:"column:cos_key;size:512"` COSKey string `gorm:"column:cos_key;size:512"`
FailureReason string `gorm:"column:failure_reason;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"` CreateTime time.Time `gorm:"column:create_time;index:idx_task_center_archive_status_time,priority:2"`
UpdateTime time.Time `gorm:"column:update_time;index:idx_task_center_archive_claim_stale,priority:2"` UpdateTime time.Time `gorm:"column:update_time"`
ArchivedAt *time.Time `gorm:"column:archived_at"` ArchivedAt *time.Time `gorm:"column:archived_at"`
} }
@ -136,54 +136,6 @@ func (TaskCenterEventArchiveOutbox) TableName() string {
return "task_center_event_archive_outbox" return "task_center_event_archive_outbox"
} }
// TaskCenterArchiveManifest 保存 COS 对象的可验证清单。GC 只有在源对象和恢复包都达到
// VERIFIED 后才允许删行避免把“PUT 返回成功”错误地当成可恢复承诺。
type TaskCenterArchiveManifest struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
ObjectType string `gorm:"column:object_type;size:32;index:idx_task_center_archive_manifest_run,priority:2"`
RunID string `gorm:"column:run_id;size:64;index:idx_task_center_archive_manifest_run,priority:1"`
COSKey string `gorm:"column:cos_key;size:512;uniqueIndex:uk_task_center_archive_manifest_cos_key"`
ParentCOSKey string `gorm:"column:parent_cos_key;size:512"`
SHA256 string `gorm:"column:sha256;size:64"`
ETag string `gorm:"column:etag;size:128"`
CompressedSize int64 `gorm:"column:compressed_size"`
RowCount int `gorm:"column:row_count"`
MinOutboxID *int64 `gorm:"column:min_outbox_id"`
MaxOutboxID *int64 `gorm:"column:max_outbox_id"`
MinOccurredAt *time.Time `gorm:"column:min_occurred_at"`
MaxOccurredAt *time.Time `gorm:"column:max_occurred_at"`
VerificationStatus string `gorm:"column:verification_status;size:32"`
VerifiedAt *time.Time `gorm:"column:verified_at"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (TaskCenterArchiveManifest) TableName() string { return "task_center_archive_manifest" }
// TaskCenterArchiveGCRun 是固定 id=1 的全局控制行。管理命令、cron tick 和 Game King
// 启用门禁都先锁这一行,从数据库层串行化多节点状态变化。
type TaskCenterArchiveGCRun struct {
ID uint8 `gorm:"column:id;primaryKey"`
RunID string `gorm:"column:run_id;size:64"`
Status string `gorm:"column:status;size:32"`
CutoffTime *time.Time `gorm:"column:cutoff_time"`
BatchSize int `gorm:"column:batch_size"`
ScannedRows int64 `gorm:"column:scanned_rows"`
EligibleRows int64 `gorm:"column:eligible_rows"`
ProtectedRows int64 `gorm:"column:protected_rows"`
DeletedRows int64 `gorm:"column:deleted_rows"`
RecoveryObjectCount int64 `gorm:"column:recovery_object_count"`
CursorCreateTime *time.Time `gorm:"column:cursor_create_time"`
CursorID int64 `gorm:"column:cursor_id"`
LeaseOwner string `gorm:"column:lease_owner;size:160"`
LeaseUntil *time.Time `gorm:"column:lease_until"`
LastError string `gorm:"column:last_error;size:1000"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (TaskCenterArchiveGCRun) TableName() string { return "task_center_archive_gc_run" }
// TaskCenterClaimRecord 保存任务奖励领取记录。 // TaskCenterClaimRecord 保存任务奖励领取记录。
type TaskCenterClaimRecord struct { type TaskCenterClaimRecord struct {
ID int64 `gorm:"column:id;primaryKey"` ID int64 `gorm:"column:id;primaryKey"`

View File

@ -1,185 +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" }
// YumiGameKingBackfillState 持久化复合游标。调用方重启后从该水位继续GC 不依赖
// webconsole/chatapp-cron 的内存 lastId 来判断历史事件是否已经确认。
type YumiGameKingBackfillState struct {
ActivityID int64 `gorm:"column:activity_id;primaryKey"`
LastOutboxID int64 `gorm:"column:last_outbox_id"`
LastOccurredAt *time.Time `gorm:"column:last_occurred_at"`
ScannedRows int64 `gorm:"column:scanned_rows"`
AcceptedRows int64 `gorm:"column:accepted_rows"`
Completed bool `gorm:"column:completed"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (YumiGameKingBackfillState) TableName() string { return "yumi_game_king_backfill_state" }
// 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

@ -2,7 +2,6 @@ package router
import ( import (
"net/http" "net/http"
"strings"
"chatapp3-golang/internal/service/firstrechargereward" "chatapp3-golang/internal/service/firstrechargereward"
@ -61,7 +60,7 @@ func registerFirstRechargeRewardRoutes(engine *gin.Engine, javaClient authGatewa
func registerFirstRechargeRewardAppGroup(group *gin.RouterGroup, javaClient authGateway, service *firstrechargereward.Service) { func registerFirstRechargeRewardAppGroup(group *gin.RouterGroup, javaClient authGateway, service *firstrechargereward.Service) {
group.Use(authMiddleware(javaClient)) group.Use(authMiddleware(javaClient))
group.GET("/home", func(c *gin.Context) { group.GET("/home", func(c *gin.Context) {
resp, err := service.GetHome(c.Request.Context(), mustAuthUser(c), firstRechargeRewardRequestVersion(c)) resp, err := service.GetHome(c.Request.Context(), mustAuthUser(c))
if err != nil { if err != nil {
writeError(c, err) writeError(c, err)
return return
@ -69,33 +68,3 @@ func registerFirstRechargeRewardAppGroup(group *gin.RouterGroup, javaClient auth
writeOK(c, resp) 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))) c.JSON(http.StatusOK, service.HandleSupplement(c.Request.Context(), req, string(raw)))
}) })
registerHotgameCallbackRoutes(engine, service)
internalGroup := engine.Group("/internal/game/open") internalGroup := engine.Group("/internal/game/open")
internalGroup.Use(internalSecretMiddleware(cfg.HTTP.InternalCallbackSecret)) internalGroup.Use(internalSecretMiddleware(cfg.HTTP.InternalCallbackSecret))
internalGroup.GET("/info", func(c *gin.Context) { 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 { func resolvePublicBaseURL(c *gin.Context, cfg config.Config) string {
if value := strings.TrimRight(strings.TrimSpace(cfg.GameOpen.PublicBaseURL), "/"); value != "" { if value := strings.TrimRight(strings.TrimSpace(cfg.GameOpen.PublicBaseURL), "/"); value != "" {
return value return value

View File

@ -1,7 +1,6 @@
package router package router
import ( import (
"errors"
"net/http" "net/http"
"strings" "strings"
@ -12,7 +11,7 @@ import (
) )
// registerGameProviderRoutes 注册统一游戏厂商 app 路由。 // 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 { if registry == nil {
return return
} }
@ -20,15 +19,7 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
appGroup := engine.Group("/app/game") appGroup := engine.Group("/app/game")
appGroup.Use(authMiddleware(javaClient)) appGroup.Use(authMiddleware(javaClient))
appGroup.GET("/room/shortcut", func(c *gin.Context) { appGroup.GET("/room/shortcut", func(c *gin.Context) {
user, visible, ok := gameListAccess(c, visibility) resp, err := registry.ListShortcutGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"))
if !ok {
return
}
if !visible {
writeOK(c, []publicRoomGameItem{})
return
}
resp, err := registry.ListShortcutGames(c.Request.Context(), user, c.Query("roomId"))
if err != nil { if err != nil {
writeError(c, err) writeError(c, err)
return return
@ -36,15 +27,7 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
writeOK(c, publicRoomGameItems(resp)) writeOK(c, publicRoomGameItems(resp))
}) })
appGroup.GET("/room/list", func(c *gin.Context) { appGroup.GET("/room/list", func(c *gin.Context) {
user, visible, ok := gameListAccess(c, visibility) resp, err := registry.ListRoomGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"), c.Query("category"))
if !ok {
return
}
if !visible {
writeOK(c, publicRoomGameListResponse(&gameprovider.RoomGameListResponse{Items: []gameprovider.RoomGameListItem{}}))
return
}
resp, err := registry.ListRoomGames(c.Request.Context(), user, c.Query("roomId"), c.Query("category"))
if err != nil { if err != nil {
writeError(c, err) writeError(c, err)
return return
@ -89,30 +72,14 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
providerGroup := engine.Group("/app/game/providers") providerGroup := engine.Group("/app/game/providers")
providerGroup.Use(authMiddleware(javaClient)) providerGroup.Use(authMiddleware(javaClient))
providerGroup.GET("", func(c *gin.Context) { 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()) writeOK(c, registry.List())
}) })
providerGroup.GET("/:provider/room/shortcut", func(c *gin.Context) { 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) provider, ok := resolveGameProvider(c, registry)
if !ok { if !ok {
return 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 { if err != nil {
writeError(c, err) writeError(c, err)
return return
@ -120,19 +87,11 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
writeOK(c, gin.H{"items": publicRoomGameItems(resp)}) writeOK(c, gin.H{"items": publicRoomGameItems(resp)})
}) })
providerGroup.GET("/:provider/room/list", func(c *gin.Context) { 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) provider, ok := resolveGameProvider(c, registry)
if !ok { if !ok {
return 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 { if err != nil {
writeError(c, err) writeError(c, err)
return return
@ -161,7 +120,7 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return return
} }
resp, err := launchProviderGame(c, registry, provider, req) resp, err := provider.LaunchGame(c.Request.Context(), mustAuthUser(c), req, resolveClientIP(c))
if err != nil { if err != nil {
writeError(c, err) writeError(c, err)
return 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) { func resolveGameProvider(c *gin.Context, registry *gameprovider.Registry) (gameprovider.Provider, bool) {
provider, err := registry.Resolve(c.Param("provider")) provider, err := registry.Resolve(c.Param("provider"))
if err != nil { if err != nil {
@ -223,25 +155,6 @@ func resolveGameProvider(c *gin.Context, registry *gameprovider.Registry) (gamep
return provider, true 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 { type publicRoomGameItem struct {
ID int64 `json:"id"` ID int64 `json:"id"`
GameID string `json:"gameId"` GameID string `json:"gameId"`
@ -398,14 +311,8 @@ func publicLaunch(resp *gameprovider.LaunchResponse) *publicLaunchResponse {
} }
func appCompatProviderName(provider string) string { func appCompatProviderName(provider string) string {
// 客户端历史上只认识 LEADER不认识内部厂商名 LINGXIAN所以出参继续保持旧字段值。
if strings.EqualFold(strings.TrimSpace(provider), "LINGXIAN") { if strings.EqualFold(strings.TrimSpace(provider), "LINGXIAN") {
return "LEADER" return "LEADER"
} }
// 旧客户端没有 HOTGAME 页面;热游先按 LEADER 展示并进入已有 WebViewlaunch 路由再按 hg_* 分发回真实热游 provider。
switch strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(provider), "_", "")) {
case "HOTGAME", "REYOU", "LALU":
return "LEADER"
}
return provider return provider
} }

View File

@ -1,18 +1,9 @@
package router package router
import ( import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing" "testing"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/gameprovider"
"github.com/gin-gonic/gin"
) )
func TestPublicRoomGameListResponseKeepsLegacyProviderFields(t *testing.T) { 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) { func TestPublicLaunchKeepsLegacyBridgeAndProviderFields(t *testing.T) {
launchConfig := map[string]any{"code": "launch-code"} launchConfig := map[string]any{"code": "launch-code"}
resp := publicLaunch(&gameprovider.LaunchResponse{ resp := publicLaunch(&gameprovider.LaunchResponse{
@ -185,325 +141,3 @@ func TestPublicLaunchMapsLingxianToLeaderForOldClients(t *testing.T) {
t.Fatalf("roomState.provider = %q, want LEADER", resp.RoomState.Provider) 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) 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) { group.GET("/props", func(c *gin.Context) {
resp, err := service.ListProps(c.Request.Context(), mustAuthUser(c), c.Query("type")) resp, err := service.ListProps(c.Request.Context(), mustAuthUser(c), c.Query("type"))
if err != nil { if err != nil {
@ -119,18 +110,4 @@ func registerManagerCenterRoutes(engine *gin.Engine, javaClient authGateway, ser
} }
writeOK(c, resp) 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 ( import (
"context" "context"
"crypto/subtle"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@ -20,14 +19,6 @@ type authGateway interface {
AuthenticateConsoleToken(ctx context.Context, authorization string) (integration.ConsoleAccount, error) 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并把认证后的用户信息写入上下文。 // authMiddleware 校验用户 token并把认证后的用户信息写入上下文。
func authMiddleware(javaClient authGateway) gin.HandlerFunc { func authMiddleware(javaClient authGateway) gin.HandlerFunc {
return func(c *gin.Context) { 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 校验内部回调密钥。 // internalSecretMiddleware 校验内部回调密钥。
func internalSecretMiddleware(secret string) gin.HandlerFunc { func internalSecretMiddleware(secret string) gin.HandlerFunc {
return func(c *gin.Context) { 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 上下文读取已经通过鉴权的用户。 // mustAuthUser 从 Gin 上下文读取已经通过鉴权的用户。
func mustAuthUser(c *gin.Context) common.AuthUser { func mustAuthUser(c *gin.Context) common.AuthUser {
value, exists := c.Get(authUserContextKey) value, exists := c.Get(authUserContextKey)
@ -196,9 +112,6 @@ func trimBearer(authorization string) string {
// resolveClientIP 解析请求来源 IP。 // resolveClientIP 解析请求来源 IP。
func resolveClientIP(c *gin.Context) string { 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 != "" { if forwarded := strings.TrimSpace(c.GetHeader("X-Forwarded-For")); forwarded != "" {
parts := strings.Split(forwarded, ",") parts := strings.Split(forwarded, ",")
if len(parts) > 0 { if len(parts) > 0 {

View File

@ -11,11 +11,9 @@ import (
"chatapp3-golang/internal/service/binancerecharge" "chatapp3-golang/internal/service/binancerecharge"
"chatapp3-golang/internal/service/errorlog" "chatapp3-golang/internal/service/errorlog"
"chatapp3-golang/internal/service/firstrechargereward" "chatapp3-golang/internal/service/firstrechargereward"
"chatapp3-golang/internal/service/gameking"
"chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameopen"
"chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/gameprovider"
"chatapp3-golang/internal/service/hostcenter" "chatapp3-golang/internal/service/hostcenter"
"chatapp3-golang/internal/service/hotgame"
"chatapp3-golang/internal/service/invite" "chatapp3-golang/internal/service/invite"
"chatapp3-golang/internal/service/lingxian" "chatapp3-golang/internal/service/lingxian"
"chatapp3-golang/internal/service/luckygift" "chatapp3-golang/internal/service/luckygift"
@ -35,7 +33,6 @@ import (
"chatapp3-golang/internal/service/voiceroomrocket" "chatapp3-golang/internal/service/voiceroomrocket"
"chatapp3-golang/internal/service/weekstar" "chatapp3-golang/internal/service/weekstar"
"chatapp3-golang/internal/service/wheel" "chatapp3-golang/internal/service/wheel"
"chatapp3-golang/internal/service/yumigiftchallenge"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -50,14 +47,11 @@ type Services struct {
FirstRechargeReward *firstrechargereward.Service FirstRechargeReward *firstrechargereward.Service
Lingxian *lingxian.Service Lingxian *lingxian.Service
GameOpen *gameopen.GameOpenService GameOpen *gameopen.GameOpenService
GameKing *gameking.Service
GameProviders *gameprovider.Registry GameProviders *gameprovider.Registry
GameVisibility *gameprovider.VisibilityPolicy
LuckyGift *luckygift.LuckyGiftService LuckyGift *luckygift.LuckyGiftService
ManagerCenter *managercenter.Service ManagerCenter *managercenter.Service
PropsStore *propsstore.Service PropsStore *propsstore.Service
HostCenter *hostcenter.Service HostCenter *hostcenter.Service
Hotgame *hotgame.Service
RechargeAgency *rechargeagency.Service RechargeAgency *rechargeagency.Service
RechargeReward *rechargereward.Service RechargeReward *rechargereward.Service
RegionIMGroup *regionimgroup.Service RegionIMGroup *regionimgroup.Service
@ -72,7 +66,6 @@ type Services struct {
VoiceRoomRedPacket *voiceroomredpacket.Service VoiceRoomRedPacket *voiceroomredpacket.Service
VoiceRoomRocket *voiceroomrocket.Service VoiceRoomRocket *voiceroomrocket.Service
WeekStar *weekstar.WeekStarService WeekStar *weekstar.WeekStarService
YumiGiftChallenge *yumigiftchallenge.Service
} }
// NewRouter 创建 Gin 路由,并按模块注册所有接口。 // NewRouter 创建 Gin 路由,并按模块注册所有接口。
@ -96,8 +89,6 @@ func NewRouter(
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward) registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
registerSignInRewardRoutes(engine, javaClient, services.SignInReward) registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter) registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter)
registerTaskCenterArchiveGCRoutes(engine, cfg, javaClient, services.TaskCenter)
registerGameKingRoutes(engine, cfg, javaClient, services.GameKing)
registerUserBadgeRoutes(engine, javaClient, services.UserBadge) registerUserBadgeRoutes(engine, javaClient, services.UserBadge)
registerVipRoutes(engine, javaClient, services.VIP) registerVipRoutes(engine, javaClient, services.VIP)
registerWheelRoutes(engine, cfg, javaClient, services.Wheel) registerWheelRoutes(engine, cfg, javaClient, services.Wheel)
@ -106,14 +97,12 @@ func NewRouter(
registerVoiceRoomRedPacketRoutes(engine, javaClient, services.VoiceRoomRedPacket) registerVoiceRoomRedPacketRoutes(engine, javaClient, services.VoiceRoomRedPacket)
registerVoiceRoomRocketRoutes(engine, cfg, javaClient, services.VoiceRoomRocket) registerVoiceRoomRocketRoutes(engine, cfg, javaClient, services.VoiceRoomRocket)
registerWeekStarRoutes(engine, javaClient, services.WeekStar) registerWeekStarRoutes(engine, javaClient, services.WeekStar)
registerYumiGiftChallengeRoutes(engine, cfg, javaClient, services.YumiGiftChallenge)
registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward) registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward)
registerHostCenterRoutes(engine, services.HostCenter) registerHostCenterRoutes(engine, services.HostCenter)
registerGameOpenRoutes(engine, cfg, services.GameOpen) registerGameOpenRoutes(engine, cfg, services.GameOpen)
registerGameProviderRoutes(engine, javaClient, services.GameProviders, services.GameVisibility) registerGameProviderRoutes(engine, javaClient, services.GameProviders)
registerBaishunRoutes(engine, cfg, javaClient, services.Baishun) registerBaishunRoutes(engine, cfg, javaClient, services.Baishun)
registerLingxianRoutes(engine, javaClient, services.Lingxian) registerLingxianRoutes(engine, javaClient, services.Lingxian)
registerHotgameRoutes(engine, javaClient, services.Hotgame)
registerLuckyGiftRoutes(engine, cfg, services.LuckyGift) registerLuckyGiftRoutes(engine, cfg, services.LuckyGift)
registerManagerCenterRoutes(engine, javaClient, services.ManagerCenter) registerManagerCenterRoutes(engine, javaClient, services.ManagerCenter)
registerPropsStoreRoutes(engine, javaClient, services.PropsStore) registerPropsStoreRoutes(engine, javaClient, services.PropsStore)

View File

@ -1,81 +0,0 @@
package router
import (
"net/http"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/service/taskcenter"
"github.com/gin-gonic/gin"
)
const taskCenterArchiveGCManagePermission = "resident-activity:task-center-archive-gc:manage"
const taskCenterArchiveGCMenuAlias = "ResidentTaskCenterArchiveGC"
// registerTaskCenterArchiveGCRoutes 只做管理命令/cron tick 的 HTTP 绑定;所有状态机、
// 租约、COS 校验和删除边界均由 taskcenter service 实现。
func registerTaskCenterArchiveGCRoutes(engine *gin.Engine, cfg config.Config, javaClient authGateway, service *taskcenter.Service) {
if service == nil {
return
}
admin := engine.Group("/resident-activity/task-center/archive-gc")
admin.Use(consoleAuthMiddleware(javaClient))
admin.GET("/status", consoleMenuMiddleware(javaClient, taskCenterArchiveGCMenuAlias), func(c *gin.Context) {
response, err := service.ArchiveGCStatus(c.Request.Context())
if err != nil {
writeError(c, err)
return
}
writeOK(c, response)
})
admin.POST("/dry-run", consolePermissionMiddleware(javaClient, taskCenterArchiveGCManagePermission), func(c *gin.Context) {
var request taskcenter.ArchiveGCDryRunRequest
if err := c.ShouldBindJSON(&request); err != nil {
writeError(c, taskcenter.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
response, err := service.ArchiveGCDryRun(c.Request.Context(), request)
if err != nil {
writeError(c, err)
return
}
writeOK(c, response)
})
admin.POST("/start", consolePermissionMiddleware(javaClient, taskCenterArchiveGCManagePermission), func(c *gin.Context) {
response, err := service.StartArchiveGC(c.Request.Context())
if err != nil {
writeError(c, err)
return
}
writeOK(c, response)
})
admin.POST("/pause", consolePermissionMiddleware(javaClient, taskCenterArchiveGCManagePermission), func(c *gin.Context) {
response, err := service.PauseArchiveGC(c.Request.Context())
if err != nil {
writeError(c, err)
return
}
writeOK(c, response)
})
admin.POST("/resume", consolePermissionMiddleware(javaClient, taskCenterArchiveGCManagePermission), func(c *gin.Context) {
response, err := service.ResumeArchiveGC(c.Request.Context())
if err != nil {
writeError(c, err)
return
}
writeOK(c, response)
})
internal := engine.Group("/internal/task-center/archive-gc")
// tick 会最终删除生产数据,密钥缺失也必须 fail-closed不能沿用历史空密钥兼容。
internal.Use(requiredInternalSecretMiddleware(cfg.HTTP.InternalCallbackSecret))
internal.POST("/tick", func(c *gin.Context) {
response, err := service.ArchiveGCTick(c.Request.Context())
if err != nil {
writeError(c, err)
return
}
writeOK(c, response)
})
}

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

@ -13,7 +13,7 @@ import (
// ListShortcutGames 返回房间里的快捷游戏列表,数量上限为 5 个。 // ListShortcutGames 返回房间里的快捷游戏列表,数量上限为 5 个。
func (s *BaishunService) ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@ -25,7 +25,7 @@ func (s *BaishunService) ListShortcutGames(ctx context.Context, user AuthUser, r
// ListRoomGames 返回房间可启动的完整游戏列表。 // ListRoomGames 返回房间可启动的完整游戏列表。
func (s *BaishunService) ListRoomGames(ctx context.Context, user AuthUser, roomID string, category string) (*RoomGameListResponse, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@ -59,7 +59,7 @@ func (s *BaishunService) GetRoomState(ctx context.Context, user AuthUser, roomID
} }
// listRoomGames 联表读取房间可启动游戏,并按展示规则排序。 // 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) sysOrigin = normalizeAdminSysOrigin(sysOrigin)
profile, err := s.activeProfile(ctx, sysOrigin) profile, err := s.activeProfile(ctx, sysOrigin)
if err != nil { 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("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"). 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) 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") { if strings.TrimSpace(category) != "" && !strings.EqualFold(strings.TrimSpace(category), "CHAT_ROOM") {
query = query.Where("cfg.category = ?", category) query = query.Where("cfg.category = ?", category)
} }

View File

@ -41,7 +41,6 @@ func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*ConfigRespo
ID: configRow.ID, ID: configRow.ID,
SysOrigin: configRow.SysOrigin, SysOrigin: configRow.SysOrigin,
Enabled: configRow.Enabled, Enabled: configRow.Enabled,
MinAppVersion: strings.TrimSpace(configRow.MinAppVersion),
UpdateTime: formatDateTime(configRow.UpdateTime), UpdateTime: formatDateTime(configRow.UpdateTime),
LevelConfigs: buildLevelPayloads(levels, rewardItems, 0, 0, true), LevelConfigs: buildLevelPayloads(levels, rewardItems, 0, 0, true),
}, nil }, nil
@ -57,10 +56,6 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*Confi
if req.Enabled && !hasEnabledLevel(levels) { if req.Enabled && !hasEnabledLevel(levels) {
return nil, NewAppError(http.StatusBadRequest, "invalid_level_configs", "enabled config must contain at least one enabled level") 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 var savedConfigID int64
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
@ -96,7 +91,6 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*Confi
configRow.SysOrigin = sysOrigin configRow.SysOrigin = sysOrigin
configRow.Enabled = req.Enabled configRow.Enabled = req.Enabled
configRow.MinAppVersion = minAppVersion
configRow.UpdateTime = now configRow.UpdateTime = now
if configRow.CreateTime.IsZero() { if configRow.CreateTime.IsZero() {
configRow.CreateTime = now configRow.CreateTime = now
@ -132,7 +126,6 @@ func (s *Service) normalizeLevelInputs(inputs []LevelInput) ([]model.FirstRechar
} }
seenLevels := make(map[int]struct{}, len(inputs)) seenLevels := make(map[int]struct{}, len(inputs))
seenAmounts := make(map[int64]struct{}, len(inputs)) seenAmounts := make(map[int64]struct{}, len(inputs))
seenGoogleProductIDs := make(map[string]struct{}, len(inputs))
rows := make([]model.FirstRechargeRewardLevel, 0, len(inputs)) rows := make([]model.FirstRechargeRewardLevel, 0, len(inputs))
for _, item := range inputs { for _, item := range inputs {
if item.Level <= 0 { if item.Level <= 0 {
@ -155,13 +148,6 @@ func (s *Service) normalizeLevelInputs(inputs []LevelInput) ([]model.FirstRechar
if _, exists := seenAmounts[amountCents]; exists { if _, exists := seenAmounts[amountCents]; exists {
return nil, NewAppError(http.StatusBadRequest, "duplicate_recharge_amount", "levelConfigs must not contain duplicate rechargeAmount") 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{}{} seenLevels[item.Level] = struct{}{}
seenAmounts[amountCents] = struct{}{} seenAmounts[amountCents] = struct{}{}
id, err := utils.NextID() id, err := utils.NextID()
@ -172,7 +158,6 @@ func (s *Service) normalizeLevelInputs(inputs []LevelInput) ([]model.FirstRechar
ID: id, ID: id,
Level: item.Level, Level: item.Level,
RechargeAmountCents: amountCents, RechargeAmountCents: amountCents,
GoogleProductID: googleProductID,
RewardGroupID: rewardGroupID, RewardGroupID: rewardGroupID,
RewardGroupName: strings.TrimSpace(item.RewardGroupName), RewardGroupName: strings.TrimSpace(item.RewardGroupName),
Enabled: item.Enabled, Enabled: item.Enabled,

View File

@ -41,6 +41,9 @@ func (s *Service) ProcessRechargeEvent(ctx context.Context, event RechargeEvent)
} }
return nil, err return nil, err
} }
if !isFirstRechargeRewardAppVersionSupported(normalized.AppVersion) {
return &ProcessRechargeResponse{Processed: false, Reason: "app_version_not_supported"}, nil
}
if !isAcceptedPaymentMethod(normalized.PaymentMethod, normalized.PayPlatform) { if !isAcceptedPaymentMethod(normalized.PaymentMethod, normalized.PayPlatform) {
return &ProcessRechargeResponse{Processed: false, Reason: "unsupported_payment_method"}, nil return &ProcessRechargeResponse{Processed: false, Reason: "unsupported_payment_method"}, nil
} }
@ -58,12 +61,9 @@ func (s *Service) ProcessRechargeEvent(ctx context.Context, event RechargeEvent)
if !bundle.Config.Enabled { if !bundle.Config.Enabled {
return &ProcessRechargeResponse{Processed: false, Reason: "config_disabled"}, nil return &ProcessRechargeResponse{Processed: false, Reason: "config_disabled"}, nil
} }
if !isFirstRechargeRewardAppVersionSupported(normalized.AppVersion, bundle.Config.MinAppVersion) { matched, ok := pickMatchedLevel(bundle.Levels, normalized.AmountCents)
return &ProcessRechargeResponse{Processed: false, Reason: "app_version_not_supported"}, nil
}
matched, ok := pickMatchedLevel(bundle.Levels, normalized.AmountCents, normalized.GoogleProductID)
if !ok { if !ok {
return &ProcessRechargeResponse{Processed: false, Reason: "level_not_matched"}, nil return &ProcessRechargeResponse{Processed: false, Reason: "amount_not_reached"}, nil
} }
record, terminal, err := s.getOrCreateGrantRecord(ctx, normalized, bundle.Config, matched) record, terminal, err := s.getOrCreateGrantRecord(ctx, normalized, bundle.Config, matched)
@ -112,14 +112,13 @@ func (s *Service) normalizeRechargeEvent(event RechargeEvent) (normalizedRecharg
if amountCents <= 0 { if amountCents <= 0 {
amountCents = firstPositiveAmountCents(event.Amount.String(), event.AmountUSD.String(), event.USDAmount.String()) amountCents = firstPositiveAmountCents(event.Amount.String(), event.AmountUSD.String(), event.USDAmount.String())
} }
if amountCents <= 0 {
return normalizedRechargeEvent{}, NewAppError(http.StatusBadRequest, "invalid_amount", "amountCents or amount is required")
}
payPlatform := strings.ToUpper(strings.TrimSpace(event.PayPlatform)) payPlatform := strings.ToUpper(strings.TrimSpace(event.PayPlatform))
paymentMethod := strings.ToUpper(strings.TrimSpace(event.PaymentMethod)) paymentMethod := strings.ToUpper(strings.TrimSpace(event.PaymentMethod))
appVersion := normalizeRechargeAppVersion(event) 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()) sourceOrderID := firstNonEmpty(event.SourceOrderID.String(), event.OrderID.String())
eventID := strings.TrimSpace(event.EventID) eventID := strings.TrimSpace(event.EventID)
if eventID == "" && sourceOrderID != "" { if eventID == "" && sourceOrderID != "" {
@ -141,7 +140,6 @@ func (s *Service) normalizeRechargeEvent(event RechargeEvent) (normalizedRecharg
PayPlatform: payPlatform, PayPlatform: payPlatform,
PaymentMethod: paymentMethod, PaymentMethod: paymentMethod,
AppVersion: appVersion, AppVersion: appVersion,
GoogleProductID: googleProductID,
SourceOrderID: sourceOrderID, SourceOrderID: sourceOrderID,
OccurredAt: occurredAt, OccurredAt: occurredAt,
}, nil }, nil
@ -189,7 +187,6 @@ func (s *Service) getOrCreateGrantRecord(
Level: level.Level, Level: level.Level,
PayPlatform: event.PayPlatform, PayPlatform: event.PayPlatform,
PaymentMethod: event.PaymentMethod, PaymentMethod: event.PaymentMethod,
GoogleProductID: event.GoogleProductID,
SourceOrderID: event.SourceOrderID, SourceOrderID: event.SourceOrderID,
RewardGroupID: level.RewardGroupID, RewardGroupID: level.RewardGroupID,
RewardGroupName: level.RewardGroupName, RewardGroupName: level.RewardGroupName,
@ -293,7 +290,6 @@ func (s *Service) pushRewardNotice(ctx context.Context, record *model.FirstRecha
"rewardGroupName": record.RewardGroupName, "rewardGroupName": record.RewardGroupName,
"payPlatform": record.PayPlatform, "payPlatform": record.PayPlatform,
"paymentMethod": record.PaymentMethod, "paymentMethod": record.PaymentMethod,
"googleProductId": record.GoogleProductID,
"noticeType": firstRechargeRewardNoticeType, "noticeType": firstRechargeRewardNoticeType,
} }
return s.java.SendOfficialNoticeCustomize(ctx, integration.OfficialNoticeCustomizeRequest{ return s.java.SendOfficialNoticeCustomize(ctx, integration.OfficialNoticeCustomizeRequest{
@ -376,18 +372,6 @@ func normalizeRechargeAppVersion(event RechargeEvent) string {
) )
} }
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 { func rechargePayloadString(payload json.RawMessage, keys ...string) string {
if len(payload) == 0 { if len(payload) == 0 {
return "" return ""

View File

@ -20,7 +20,7 @@ const (
) )
// GetHome returns app-facing first recharge reward config and the user's grant state. // 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) { func (s *Service) GetHome(ctx context.Context, user AuthUser) (*HomeResponse, error) {
sysOrigin, err := requireUser(user) sysOrigin, err := requireUser(user)
if err != nil { if err != nil {
return nil, err return nil, err
@ -52,17 +52,12 @@ func (s *Service) GetHome(ctx context.Context, user AuthUser, appVersion string)
ActivityStatus: activityStatusOngoing, ActivityStatus: activityStatusOngoing,
UserID: user.UserID, UserID: user.UserID,
SysOrigin: bundle.Config.SysOrigin, SysOrigin: bundle.Config.SysOrigin,
MinAppVersion: bundle.Config.MinAppVersion,
LevelConfigs: buildLevelPayloads(bundle.Levels, rewardItems, 0, 0, false), LevelConfigs: buildLevelPayloads(bundle.Levels, rewardItems, 0, 0, false),
HasRewardRecord: false, HasRewardRecord: false,
} }
if !bundle.Config.Enabled { if !bundle.Config.Enabled {
resp.ActivityStatus = activityStatusDisabled 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) record, ok, err := s.loadUserGrantRecord(ctx, bundle.Config.SysOrigin, user.UserID)
if err != nil { if err != nil {

View File

@ -15,7 +15,6 @@ func configSnapshotFromModel(row model.FirstRechargeRewardConfig) configSnapshot
ID: row.ID, ID: row.ID,
SysOrigin: strings.ToUpper(strings.TrimSpace(row.SysOrigin)), SysOrigin: strings.ToUpper(strings.TrimSpace(row.SysOrigin)),
Enabled: row.Enabled, Enabled: row.Enabled,
MinAppVersion: strings.TrimSpace(row.MinAppVersion),
UpdateTime: row.UpdateTime, UpdateTime: row.UpdateTime,
} }
} }
@ -25,7 +24,6 @@ func levelSnapshotFromModel(row model.FirstRechargeRewardLevel) levelSnapshot {
ID: row.ID, ID: row.ID,
Level: row.Level, Level: row.Level,
RechargeAmountCents: row.RechargeAmountCents, RechargeAmountCents: row.RechargeAmountCents,
GoogleProductID: normalizeGoogleProductID(row.GoogleProductID),
RewardGroupID: row.RewardGroupID, RewardGroupID: row.RewardGroupID,
RewardGroupName: strings.TrimSpace(row.RewardGroupName), RewardGroupName: strings.TrimSpace(row.RewardGroupName),
Enabled: row.Enabled, Enabled: row.Enabled,
@ -53,7 +51,6 @@ func buildLevelPayloads(
Level: level.Level, Level: level.Level,
RechargeAmount: formatAmountCents(level.RechargeAmountCents), RechargeAmount: formatAmountCents(level.RechargeAmountCents),
RechargeAmountCents: level.RechargeAmountCents, RechargeAmountCents: level.RechargeAmountCents,
GoogleProductID: level.GoogleProductID,
RewardGroupID: level.RewardGroupID, RewardGroupID: level.RewardGroupID,
RewardGroupName: level.RewardGroupName, RewardGroupName: level.RewardGroupName,
RewardItems: rewardItems[level.RewardGroupID], RewardItems: rewardItems[level.RewardGroupID],
@ -65,27 +62,19 @@ func buildLevelPayloads(
return payloads return payloads
} }
func pickMatchedLevel(levels []levelSnapshot, rechargeAmountCents int64, googleProductID string) (levelSnapshot, bool) { func pickMatchedLevel(levels []levelSnapshot, rechargeAmountCents int64) (levelSnapshot, bool) {
googleProductID = normalizeGoogleProductID(googleProductID) var matched levelSnapshot
if googleProductID != "" { ok := false
for _, level := range levels {
if !level.Enabled || level.RewardGroupID <= 0 || level.GoogleProductID == "" {
continue
}
if level.GoogleProductID == googleProductID {
return level, true
}
}
}
for _, level := range levels { for _, level := range levels {
if !level.Enabled || level.RechargeAmountCents <= 0 || level.RewardGroupID <= 0 { if !level.Enabled || level.RechargeAmountCents <= 0 || level.RewardGroupID <= 0 {
continue continue
} }
if rechargeAmountCents == level.RechargeAmountCents { if rechargeAmountCents >= level.RechargeAmountCents && (!ok || level.RechargeAmountCents > matched.RechargeAmountCents) {
return level, true matched = level
ok = true
} }
} }
return levelSnapshot{}, false return matched, ok
} }
func sortLevels(levels []levelSnapshot) { func sortLevels(levels []levelSnapshot) {

View File

@ -92,7 +92,6 @@ func grantRecordViewFromModel(row model.FirstRechargeRewardGrantRecord, rewardIt
Level: row.Level, Level: row.Level,
PayPlatform: row.PayPlatform, PayPlatform: row.PayPlatform,
PaymentMethod: row.PaymentMethod, PaymentMethod: row.PaymentMethod,
GoogleProductID: row.GoogleProductID,
SourceOrderID: row.SourceOrderID, SourceOrderID: row.SourceOrderID,
RewardGroupID: row.RewardGroupID, RewardGroupID: row.RewardGroupID,
RewardGroupName: row.RewardGroupName, RewardGroupName: row.RewardGroupName,

View File

@ -20,9 +20,8 @@ func TestSaveConfigReturnsRewardItems(t *testing.T) {
req := mustDecodeSaveConfigRequest(t, `{ req := mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI", "sysOrigin": "LIKEI",
"enabled": true, "enabled": true,
"minAppVersion": "1.5.0",
"levelConfigs": [ "levelConfigs": [
{"level": 1, "rechargeAmount": "9.99", "googleProductId": "gold_999", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true} {"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
] ]
}`) }`)
resp, err := service.SaveConfig(context.Background(), req) resp, err := service.SaveConfig(context.Background(), req)
@ -39,9 +38,6 @@ func TestSaveConfigReturnsRewardItems(t *testing.T) {
if level.RechargeAmountCents != 999 || level.RewardGroupID != 1001 { if level.RechargeAmountCents != 999 || level.RewardGroupID != 1001 {
t.Fatalf("level payload = %+v", level) 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 != "首冲礼包奖励" { if len(level.RewardItems) != 1 || level.RewardItems[0].Name != "首冲礼包奖励" {
t.Fatalf("reward items = %+v", level.RewardItems) t.Fatalf("reward items = %+v", level.RewardItems)
} }
@ -70,7 +66,6 @@ func TestSaveConfigReturnsVipCoverFromSourceURL(t *testing.T) {
resp, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{ resp, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI", "sysOrigin": "LIKEI",
"enabled": true, "enabled": true,
"minAppVersion": "1.5.0",
"levelConfigs": [ "levelConfigs": [
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "2001", "rewardGroupName": "VIP礼包", "enabled": true} {"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "2001", "rewardGroupName": "VIP礼包", "enabled": true}
] ]
@ -120,7 +115,6 @@ func TestRewardItemsReturnConcretePropsType(t *testing.T) {
resp, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{ resp, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI", "sysOrigin": "LIKEI",
"enabled": true, "enabled": true,
"minAppVersion": "1.5.0",
"levelConfigs": [ "levelConfigs": [
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "3001", "rewardGroupName": "道具礼包", "enabled": true} {"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "3001", "rewardGroupName": "道具礼包", "enabled": true}
] ]
@ -153,9 +147,8 @@ func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{ _, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI", "sysOrigin": "LIKEI",
"enabled": true, "enabled": true,
"minAppVersion": "1.5.0",
"levelConfigs": [ "levelConfigs": [
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true} {"level": 1, "rechargeAmount": "5.00", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
] ]
}`)) }`))
if err != nil { if err != nil {
@ -192,7 +185,7 @@ func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("low amount ProcessRechargeEvent() error = %v", err) t.Fatalf("low amount ProcessRechargeEvent() error = %v", err)
} }
if lowAmount.Processed || lowAmount.Reason != "level_not_matched" { if lowAmount.Processed || lowAmount.Reason != "amount_not_reached" {
t.Fatalf("low amount response = %+v", lowAmount) t.Fatalf("low amount response = %+v", lowAmount)
} }
@ -299,9 +292,8 @@ func TestProcessRechargeEventStartsCountingAtSupportedAppVersion(t *testing.T) {
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{ _, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI", "sysOrigin": "LIKEI",
"enabled": true, "enabled": true,
"minAppVersion": "1.5.0",
"levelConfigs": [ "levelConfigs": [
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true} {"level": 1, "rechargeAmount": "5.00", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
] ]
}`)) }`))
if err != nil { if err != nil {
@ -388,86 +380,13 @@ func TestFirstRechargeRewardAppVersionSupported(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if got := isFirstRechargeRewardAppVersionSupported(tt.appVersion, "1.5.0"); got != tt.want { if got := isFirstRechargeRewardAppVersionSupported(tt.appVersion); got != tt.want {
t.Fatalf("isFirstRechargeRewardAppVersionSupported(%q) = %v, want %v", tt.appVersion, 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) { func newTestService(t *testing.T) (*Service, *fakeGateway, *gorm.DB) {
t.Helper() t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})

View File

@ -65,7 +65,6 @@ type LevelPayload struct {
Level int `json:"level"` Level int `json:"level"`
RechargeAmount string `json:"rechargeAmount"` RechargeAmount string `json:"rechargeAmount"`
RechargeAmountCents int64 `json:"rechargeAmountCents"` RechargeAmountCents int64 `json:"rechargeAmountCents"`
GoogleProductID string `json:"googleProductId,omitempty"`
RewardGroupID int64 `json:"rewardGroupId,string"` RewardGroupID int64 `json:"rewardGroupId,string"`
RewardGroupName string `json:"rewardGroupName,omitempty"` RewardGroupName string `json:"rewardGroupName,omitempty"`
RewardItems []RewardItem `json:"rewardItems"` RewardItems []RewardItem `json:"rewardItems"`
@ -79,7 +78,6 @@ type LevelInput struct {
Level int `json:"level"` Level int `json:"level"`
RechargeAmount flexibleAmount `json:"rechargeAmount"` RechargeAmount flexibleAmount `json:"rechargeAmount"`
RechargeAmountCents int64 `json:"rechargeAmountCents"` RechargeAmountCents int64 `json:"rechargeAmountCents"`
GoogleProductID string `json:"googleProductId"`
RewardGroupID flexibleInt64 `json:"rewardGroupId"` RewardGroupID flexibleInt64 `json:"rewardGroupId"`
RewardGroupName string `json:"rewardGroupName"` RewardGroupName string `json:"rewardGroupName"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
@ -90,7 +88,6 @@ type ConfigResponse struct {
ID int64 `json:"id,string"` ID int64 `json:"id,string"`
SysOrigin string `json:"sysOrigin"` SysOrigin string `json:"sysOrigin"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
MinAppVersion string `json:"minAppVersion,omitempty"`
UpdateTime string `json:"updateTime,omitempty"` UpdateTime string `json:"updateTime,omitempty"`
LevelConfigs []LevelPayload `json:"levelConfigs"` LevelConfigs []LevelPayload `json:"levelConfigs"`
} }
@ -99,7 +96,6 @@ type SaveConfigRequest struct {
ID flexibleInt64 `json:"id"` ID flexibleInt64 `json:"id"`
SysOrigin string `json:"sysOrigin"` SysOrigin string `json:"sysOrigin"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
MinAppVersion string `json:"minAppVersion"`
LevelConfigs []LevelInput `json:"levelConfigs"` LevelConfigs []LevelInput `json:"levelConfigs"`
} }
@ -112,7 +108,6 @@ type HomeResponse struct {
HasRewardRecord bool `json:"hasRewardRecord"` HasRewardRecord bool `json:"hasRewardRecord"`
RewardStatus string `json:"rewardStatus,omitempty"` RewardStatus string `json:"rewardStatus,omitempty"`
Rewarded bool `json:"rewarded"` Rewarded bool `json:"rewarded"`
MinAppVersion string `json:"minAppVersion,omitempty"`
MatchedLevel int `json:"matchedLevel,omitempty"` MatchedLevel int `json:"matchedLevel,omitempty"`
RechargeAmount string `json:"rechargeAmount,omitempty"` RechargeAmount string `json:"rechargeAmount,omitempty"`
RechargeAmountCents int64 `json:"rechargeAmountCents,omitempty"` RechargeAmountCents int64 `json:"rechargeAmountCents,omitempty"`
@ -146,7 +141,6 @@ type GrantRecordView struct {
Level int `json:"level"` Level int `json:"level"`
PayPlatform string `json:"payPlatform"` PayPlatform string `json:"payPlatform"`
PaymentMethod string `json:"paymentMethod"` PaymentMethod string `json:"paymentMethod"`
GoogleProductID string `json:"googleProductId,omitempty"`
SourceOrderID string `json:"sourceOrderId,omitempty"` SourceOrderID string `json:"sourceOrderId,omitempty"`
RewardGroupID int64 `json:"rewardGroupId,string"` RewardGroupID int64 `json:"rewardGroupId,string"`
RewardGroupName string `json:"rewardGroupName,omitempty"` RewardGroupName string `json:"rewardGroupName,omitempty"`
@ -182,8 +176,6 @@ type RechargeEvent struct {
Currency string `json:"currency"` Currency string `json:"currency"`
PayPlatform string `json:"payPlatform"` PayPlatform string `json:"payPlatform"`
PaymentMethod string `json:"paymentMethod"` PaymentMethod string `json:"paymentMethod"`
GoogleProductID string `json:"googleProductId"`
ProductID flexibleString `json:"productId"`
AppVersion flexibleString `json:"appVersion"` AppVersion flexibleString `json:"appVersion"`
ClientVersion flexibleString `json:"clientVersion"` ClientVersion flexibleString `json:"clientVersion"`
Version flexibleString `json:"version"` Version flexibleString `json:"version"`
@ -211,7 +203,6 @@ type configSnapshot struct {
ID int64 ID int64
SysOrigin string SysOrigin string
Enabled bool Enabled bool
MinAppVersion string
UpdateTime time.Time UpdateTime time.Time
} }
@ -219,7 +210,6 @@ type levelSnapshot struct {
ID int64 ID int64
Level int Level int
RechargeAmountCents int64 RechargeAmountCents int64
GoogleProductID string
RewardGroupID int64 RewardGroupID int64
RewardGroupName string RewardGroupName string
Enabled bool Enabled bool
@ -234,7 +224,6 @@ type normalizedRechargeEvent struct {
PayPlatform string PayPlatform string
PaymentMethod string PaymentMethod string
AppVersion string AppVersion string
GoogleProductID string
SourceOrderID string SourceOrderID string
OccurredAt time.Time OccurredAt time.Time
} }

View File

@ -5,18 +5,14 @@ import (
"strings" "strings"
) )
const defaultFirstRechargeRewardMinAppVersion = "1.5.0" const firstRechargeRewardMinAppVersion = "1.5.0"
func isFirstRechargeRewardAppVersionSupported(appVersion string, minAppVersion string) bool { func isFirstRechargeRewardAppVersionSupported(appVersion string) bool {
appVersion = strings.TrimSpace(appVersion) appVersion = strings.TrimSpace(appVersion)
minAppVersion = strings.TrimSpace(minAppVersion)
if minAppVersion == "" {
minAppVersion = defaultFirstRechargeRewardMinAppVersion
}
if appVersion == "" { if appVersion == "" {
return false return false
} }
return compareAppVersion(appVersion, minAppVersion) >= 0 return compareAppVersion(appVersion, firstRechargeRewardMinAppVersion) >= 0
} }
func compareAppVersion(left, right string) int { func compareAppVersion(left, right string) int {

View File

@ -1,71 +0,0 @@
package gameking
import (
"errors"
"net/http"
"strings"
"chatapp3-golang/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ensureActivityEnableSafetyTx 把“索引已在线”和“GC 没在删除”纳入首次启用事务。
// 它与 GC 删除事务锁同一控制行,因此两个并发请求不会同时各自通过旧状态检查。
func ensureActivityEnableSafetyTx(tx *gorm.DB) error {
if err := requireBackfillIndexOnDB(tx); err != nil {
return err
}
var run model.TaskCenterArchiveGCRun
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", uint8(1)).First(&run).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return NewAppError(http.StatusServiceUnavailable, "archive_gc_migration_required", "migration 060 archive GC safety state is required before enabling game king")
}
return NewAppError(http.StatusServiceUnavailable, "archive_gc_state_check_failed", "cannot verify archive GC state before enabling game king")
}
if strings.EqualFold(run.Status, "RUNNING") {
return NewAppError(http.StatusConflict, "archive_gc_running", "pause task center archive GC before enabling game king")
}
return nil
}
func requireBackfillIndexOnDB(db *gorm.DB) error {
var columns []backfillIndexColumn
if err := db.Raw(`
-- 与任务中心 GC 门禁保持相同映射规则不加别名时 MySQL 的大写列标签不会填充
-- 显式小写 GORM tag导致真实存在的 Game King 索引也被误判为缺失
SELECT COLUMN_NAME AS column_name,
SEQ_IN_INDEX AS seq_in_index,
SUB_PART AS sub_part,
INDEX_TYPE AS index_type,
IS_VISIBLE AS 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 enabling or backfill",
)
}

View File

@ -1,82 +0,0 @@
package gameking
import (
"context"
"net/http"
"time"
"chatapp3-golang/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// loadOrCreateBackfillState 先建立活动级水位行,后续并发补数才能通过行锁检测游标竞争,
// 避免较慢请求把较快请求已经推进的复合游标覆盖回旧位置。
func (s *Service) loadOrCreateBackfillState(ctx context.Context, activity model.YumiGameKingActivity) (model.YumiGameKingBackfillState, error) {
now := time.Now()
start := activity.StartTime
initial := model.YumiGameKingBackfillState{
ActivityID: activity.ID, LastOccurredAt: &start, CreateTime: now, UpdateTime: now,
}
if err := s.db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&initial).Error; err != nil {
return initial, err
}
var row model.YumiGameKingBackfillState
if err := s.db.WithContext(ctx).Where("activity_id = ?", activity.ID).First(&row).Error; err != nil {
return row, err
}
return row, nil
}
// adoptBackfillCursor 只兼容 migration 060 上线前调用方已经保存的合法 lastId水位一旦
// 非零,调用方就不能跳转或回退游标。
func (s *Service) adoptBackfillCursor(ctx context.Context, activityID int64, cursor model.TaskCenterEventArchiveOutbox) (model.YumiGameKingBackfillState, error) {
var state model.YumiGameKingBackfillState
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("activity_id = ?", activityID).First(&state).Error; err != nil {
return err
}
if state.LastOutboxID != 0 {
return NewAppError(http.StatusConflict, "backfill_cursor_conflict", "persisted backfill cursor changed; reload and retry")
}
now := time.Now()
if err := tx.Model(&model.YumiGameKingBackfillState{}).Where("activity_id = ?", activityID).Updates(map[string]any{
"last_outbox_id": cursor.ID, "last_occurred_at": cursor.OccurredAt,
"completed": false, "update_time": now,
}).Error; err != nil {
return err
}
state.LastOutboxID = cursor.ID
state.LastOccurredAt = &cursor.OccurredAt
state.UpdateTime = now
return nil
})
return state, err
}
func (s *Service) persistBackfillState(
ctx context.Context,
activityID, expectedLastID int64,
nextLastID int64,
nextOccurredAt time.Time,
scanned, accepted int,
completed bool,
) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var locked model.YumiGameKingBackfillState
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("activity_id = ?", activityID).First(&locked).Error; err != nil {
return err
}
if locked.LastOutboxID != expectedLastID {
// 本页可能已经通过幂等 ledger 入账;不覆盖赢家水位,调用方重读后重放即可安全收敛。
return NewAppError(http.StatusConflict, "backfill_cursor_conflict", "another backfill request advanced the persisted cursor; reload and retry")
}
return tx.Model(&model.YumiGameKingBackfillState{}).Where("activity_id = ?", activityID).Updates(map[string]any{
"last_outbox_id": nextLastID, "last_occurred_at": nextOccurredAt,
"scanned_rows": gorm.Expr("scanned_rows + ?", scanned),
"accepted_rows": gorm.Expr("accepted_rows + ?", accepted),
"completed": completed, "update_time": time.Now(),
}).Error
})
}

View File

@ -1,544 +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")
}
// 已启用但尚未开始的活动仍可能修改时间窗;只要本次保存结果为 enabled
// 就与 GC 删除事务锁同一控制行,避免 GC 按旧窗口保护后活动扩展到已删除区间。
if normalized.Enabled {
if err := ensureActivityEnableSafetyTx(tx); err != nil {
return err
}
}
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 err := ensureActivityEnableSafetyTx(tx); err != nil {
return err
}
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,260 +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
}
state, err := s.loadOrCreateBackfillState(ctx, activity)
if err != nil {
return nil, err
}
cursorTime := activity.StartTime
if state.LastOccurredAt != nil {
cursorTime = *state.LastOccurredAt
}
if lastID > 0 && state.LastOutboxID == 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
}
state, err = s.adoptBackfillCursor(ctx, activity.ID, cursor)
if err != nil {
return nil, err
}
cursorTime = cursor.OccurredAt
} else if lastID > 0 && lastID != state.LastOutboxID {
return nil, NewAppError(http.StatusConflict, "backfill_cursor_conflict", "lastId does not match the persisted backfill cursor")
}
lastID = state.LastOutboxID
startingLastID := lastID
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
cursorTime = row.OccurredAt
}
if err := s.persistBackfillState(ctx, activity.ID, startingLastID, result.NextLastID, cursorTime, result.Scanned, result.Accepted, result.Finished); err != nil {
return nil, err
}
return result, nil
}
// requireBackfillIndex 只接受迁移 058 创建的完整 BTREE 列序VARCHAR 前缀索引也会拒绝,
// 因为它不能提供与完整等值前缀相同的执行计划保证。缺表、缺索引或列序漂移统一 fail-closed。
func (s *Service) requireBackfillIndex(ctx context.Context) error {
return requireBackfillIndexOnDB(s.db.WithContext(ctx))
}

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 { 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 { func (s *GameOpenService) resolveLingxianAppKeyByGame(ctx context.Context, gameID string) string {

View File

@ -5,7 +5,6 @@ import (
"chatapp3-golang/internal/integration" "chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model" "chatapp3-golang/internal/model"
"context" "context"
"errors"
"testing" "testing"
"time" "time"
@ -21,7 +20,6 @@ type stubGateway struct {
accountMap map[string]integration.UserProfile accountMap map[string]integration.UserProfile
balance int64 balance int64
exists bool exists bool
changeErr error
cmd integration.GoldReceiptCommand 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 { func (s *stubGateway) ChangeGoldBalance(_ context.Context, cmd integration.GoldReceiptCommand) error {
s.cmd = cmd s.cmd = cmd
return s.changeErr return nil
} }
func TestHandleQueryUser(t *testing.T) { 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 { func newGameOpenTestDB(t *testing.T) *gorm.DB {
t.Helper() t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) 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( if err := db.AutoMigrate(
&model.LingxianProviderConfig{}, &model.LingxianProviderConfig{},
&model.HotgameProviderConfig{},
&model.SysGameListVendorExt{}, &model.SysGameListVendorExt{},
&model.GameOpenCallbackLog{}, &model.GameOpenCallbackLog{},
); err != nil { ); err != nil {

View File

@ -79,7 +79,7 @@ func (r *Registry) List() ProviderListResponse {
// ListShortcutGames 返回聚合后的快捷游戏列表。 // ListShortcutGames 返回聚合后的快捷游戏列表。
func (r *Registry) ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) { func (r *Registry) ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) {
if IsEmptyGameListUser(user.UserID) { if isEmptyGameListUser(user.UserID) {
return []RoomGameListItem{}, nil return []RoomGameListItem{}, nil
} }
if r == nil || len(r.ordered) == 0 { if r == nil || len(r.ordered) == 0 {
@ -103,7 +103,7 @@ func (r *Registry) ListShortcutGames(ctx context.Context, user AuthUser, roomID
// ListRoomGames 返回聚合后的房间游戏列表。 // ListRoomGames 返回聚合后的房间游戏列表。
func (r *Registry) ListRoomGames(ctx context.Context, user AuthUser, roomID, category string) (*RoomGameListResponse, error) { func (r *Registry) ListRoomGames(ctx context.Context, user AuthUser, roomID, category string) (*RoomGameListResponse, error) {
if IsEmptyGameListUser(user.UserID) { if isEmptyGameListUser(user.UserID) {
return &RoomGameListResponse{Items: []RoomGameListItem{}}, nil return &RoomGameListResponse{Items: []RoomGameListItem{}}, nil
} }
if r == nil || len(r.ordered) == 0 { if r == nil || len(r.ordered) == 0 {
@ -124,7 +124,7 @@ func (r *Registry) ListRoomGames(ctx context.Context, user AuthUser, roomID, cat
return &RoomGameListResponse{Items: items}, nil return &RoomGameListResponse{Items: items}, nil
} }
func IsEmptyGameListUser(userID int64) bool { func isEmptyGameListUser(userID int64) bool {
_, ok := emptyGameListUserIDs[userID] _, ok := emptyGameListUserIDs[userID]
return ok return ok
} }
@ -251,8 +251,6 @@ func normalizeProviderAlias(key string) string {
switch normalizeKey(key) { switch normalizeKey(key) {
case "LEADER": case "LEADER":
return "LINGXIAN" return "LINGXIAN"
case "HOTGAME", "HOT_GAME", "REYOU", "LALU":
return "HOTGAME"
default: default:
return normalizeKey(key) 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) { func TestRegistryListKeepsRegistrationOrder(t *testing.T) {
registry := NewRegistry( registry := NewRegistry(
&stubProvider{key: "BAISHUN", name: "百顺"}, &stubProvider{key: "BAISHUN", name: "百顺"},

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,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) { 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 { if err != nil {
return nil, err 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 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) sysOrigin = normalizeSysOrigin(sysOrigin)
profile, err := s.activeProfile(ctx, sysOrigin) profile, err := s.activeProfile(ctx, sysOrigin)
if err != nil { if err != nil {
@ -125,10 +125,6 @@ func (s *Service) listRoomGames(ctx context.Context, sysOrigin, regionID, catego
var rows []gameRow var rows []gameRow
query := s.baseGameQuery(ctx, sysOrigin, profile). query := s.baseGameQuery(ctx, sysOrigin, profile).
Where("cfg.sys_origin = ? AND cfg.is_showcase = 1 AND ext.vendor_type = ?", sysOrigin, vendorType) 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) { if strings.TrimSpace(category) != "" && !strings.EqualFold(strings.TrimSpace(category), roomCategory) {
query = query.Where("cfg.category = ?", category) query = query.Where("cfg.category = ?", category)
} }

View File

@ -124,7 +124,7 @@ func (s *Service) ListBDLeaders(ctx context.Context, user AuthUser) (*BDLeaderLi
} }
func (s *Service) AddBDLeader(ctx context.Context, user AuthUser, req AddBDLeaderRequest) (*AddBDLeaderResponse, error) { func (s *Service) AddBDLeader(ctx context.Context, user AuthUser, req AddBDLeaderRequest) (*AddBDLeaderResponse, error) {
manager, err := s.requireManagerFeature(ctx, user, managerFeatureAddBDLeader) manager, err := s.requireManagerInfo(ctx, user)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"time"
"chatapp3-golang/internal/integration" "chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model" "chatapp3-golang/internal/model"
@ -20,11 +19,6 @@ type teamManagerInfoRow struct {
Origin string `gorm:"column:sys_origin"` Origin string `gorm:"column:sys_origin"`
Contact string `gorm:"column:contact"` Contact string `gorm:"column:contact"`
RegionID string `gorm:"column:region_id"` 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"` CreateUser int64 `gorm:"column:create_user"`
UpdateUser int64 `gorm:"column:update_user"` UpdateUser int64 `gorm:"column:update_user"`
} }
@ -38,34 +32,17 @@ type userBaseInfoRow struct {
Account string `gorm:"column:account"` Account string `gorm:"column:account"`
UserAvatar string `gorm:"column:user_avatar"` UserAvatar string `gorm:"column:user_avatar"`
UserNickname string `gorm:"column:user_nickname"` UserNickname string `gorm:"column:user_nickname"`
CountryID int64 `gorm:"column:country_id"`
CountryCode string `gorm:"column:country_code"` CountryCode string `gorm:"column:country_code"`
CountryName string `gorm:"column:country_name"` CountryName string `gorm:"column:country_name"`
OriginSys string `gorm:"column:origin_sys"` OriginSys string `gorm:"column:origin_sys"`
AccountStatus string `gorm:"column:account_status"` AccountStatus string `gorm:"column:account_status"`
IsDel bool `gorm:"column:is_del"` IsDel bool `gorm:"column:is_del"`
UpdateTime time.Time `gorm:"column:update_time"`
UpdateUser int64 `gorm:"column:update_user"`
} }
func (userBaseInfoRow) TableName() string { func (userBaseInfoRow) TableName() string {
return "user_base_info" 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 { type propsSourceRecordRow struct {
ID int64 `gorm:"column:id;primaryKey"` ID int64 `gorm:"column:id;primaryKey"`
Type string `gorm:"column:type"` Type string `gorm:"column:type"`
@ -96,8 +73,7 @@ func (propsNobleVIPAbilityRow) TableName() string {
} }
func (s *Service) GetProfile(ctx context.Context, user AuthUser) (*ProfileResponse, error) { func (s *Service) GetProfile(ctx context.Context, user AuthUser) (*ProfileResponse, error) {
manager, err := s.requireManagerInfo(ctx, user) if err := s.ensureManager(ctx, user); err != nil {
if err != nil {
return nil, err return nil, err
} }
@ -105,7 +81,7 @@ func (s *Service) GetProfile(ctx context.Context, user AuthUser) (*ProfileRespon
if err != nil { if err != nil {
return nil, err 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) { func (s *Service) SearchUser(ctx context.Context, user AuthUser, account string) (*UserSearchResponse, error) {
@ -125,34 +101,6 @@ func (s *Service) SearchUser(ctx context.Context, user AuthUser, account string)
return &UserSearchResponse{User: view}, nil 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) { func (s *Service) ListProps(ctx context.Context, user AuthUser, propsType string) (*PropsListResponse, error) {
if err := s.ensureManager(ctx, user); err != nil { if err := s.ensureManager(ctx, user); err != nil {
return nil, err return nil, err
@ -200,7 +148,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) { 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 return nil, err
} }
if req.AcceptUserID.Int64() <= 0 { if req.AcceptUserID.Int64() <= 0 {
@ -209,10 +157,6 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
if req.PropsID.Int64() <= 0 { if req.PropsID.Int64() <= 0 {
return nil, badRequest("props_required", "propsId is required") return nil, badRequest("props_required", "propsId is required")
} }
days, err := normalizeGiftDays(req.Days)
if err != nil {
return nil, err
}
if s.java == nil { if s.java == nil {
return nil, serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable") return nil, serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable")
} }
@ -228,7 +172,7 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
prop, err := s.loadGiftableProps(ctx, req.PropsID.Int64()) prop, err := s.loadGiftableProps(ctx, req.PropsID.Int64())
if err != nil { if err != nil {
if isAppErrorCode(err, "prop_not_giftable") { 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 return nil, vipErr
} else if resp != nil { } else if resp != nil {
return resp, nil return resp, nil
@ -237,6 +181,7 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
return nil, err return nil, err
} }
days := propsGiftDays(prop.Type)
if prop.Type == propsTypeBadge { if prop.Type == propsTypeBadge {
if err := s.java.ActivateTemporaryBadge(ctx, req.AcceptUserID.Int64(), prop.ID, days); err != nil { if err := s.java.ActivateTemporaryBadge(ctx, req.AcceptUserID.Int64(), prop.ID, days); err != nil {
return nil, serverError("send_badge_failed", err.Error()) return nil, serverError("send_badge_failed", err.Error())
@ -263,7 +208,7 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
if err != nil { if err != nil {
return nil, serverError("vip_ability_query_failed", err.Error()) return nil, serverError("vip_ability_query_failed", err.Error())
} }
grants = append(grants, nobleVIPAccessoryGrants(ability, days)...) grants = append(grants, nobleVIPAccessoryGrants(ability)...)
} }
for _, grant := range grants { for _, grant := range grants {
@ -298,7 +243,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) { 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 return nil, err
} }
if req.UserID.Int64() <= 0 { if req.UserID.Int64() <= 0 {
@ -345,7 +290,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) { 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 return nil, err
} }
if req.UserID.Int64() <= 0 { if req.UserID.Int64() <= 0 {
@ -382,107 +327,11 @@ func (s *Service) UnbanUser(ctx context.Context, user AuthUser, req UnbanUserReq
}, nil }, 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")
}
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")
}
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())
}
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 { func (s *Service) ensureManager(ctx context.Context, user AuthUser) error {
_, err := s.requireManagerInfo(ctx, user) _, err := s.requireManagerInfo(ctx, user)
return err 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
}
}
func (s *Service) findUserView(ctx context.Context, account string) (UserView, error) { func (s *Service) findUserView(ctx context.Context, account string) (UserView, error) {
if userID, err := strconv.ParseInt(account, 10, 64); err == nil && userID > 0 { if userID, err := strconv.ParseInt(account, 10, 64); err == nil && userID > 0 {
if row, err := s.loadUserRowByID(ctx, userID); err == nil { if row, err := s.loadUserRowByID(ctx, userID); err == nil {
@ -548,65 +397,6 @@ func (s *Service) loadUserRowByAccount(ctx context.Context, account string) (use
return row, err 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) { func (s *Service) userRowToView(ctx context.Context, row userBaseInfoRow) (UserView, error) {
hasRecharge, canBan := s.userRechargeEligibility(ctx, row.ID) hasRecharge, canBan := s.userRechargeEligibility(ctx, row.ID)
canBan = canBan && !isUnbannableAccountStatus(row.AccountStatus) canBan = canBan && !isUnbannableAccountStatus(row.AccountStatus)
@ -615,7 +405,6 @@ func (s *Service) userRowToView(ctx context.Context, row userBaseInfoRow) (UserV
Account: row.Account, Account: row.Account,
UserAvatar: row.UserAvatar, UserAvatar: row.UserAvatar,
UserNickname: row.UserNickname, UserNickname: row.UserNickname,
CountryID: ID(row.CountryID),
CountryCode: row.CountryCode, CountryCode: row.CountryCode,
CountryName: row.CountryName, CountryName: row.CountryName,
OriginSys: row.OriginSys, OriginSys: row.OriginSys,
@ -633,7 +422,6 @@ func (s *Service) javaProfileToView(ctx context.Context, profile integration.Use
Account: profile.Account, Account: profile.Account,
UserAvatar: profile.UserAvatar, UserAvatar: profile.UserAvatar,
UserNickname: profile.UserNickname, UserNickname: profile.UserNickname,
CountryID: ID(profile.CountryID),
CountryCode: profile.CountryCode, CountryCode: profile.CountryCode,
CountryName: profile.CountryName, CountryName: profile.CountryName,
OriginSys: profile.OriginSys, OriginSys: profile.OriginSys,
@ -659,9 +447,6 @@ func overlayJavaProfile(view *UserView, profile integration.UserProfile) {
if profile.CountryName != "" { if profile.CountryName != "" {
view.CountryName = profile.CountryName view.CountryName = profile.CountryName
} }
if int64(profile.CountryID) > 0 {
view.CountryID = ID(profile.CountryID)
}
if profile.OriginSys != "" { if profile.OriginSys != "" {
view.OriginSys = profile.OriginSys view.OriginSys = profile.OriginSys
} }
@ -673,21 +458,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) { func (s *Service) userHasRecharge(ctx context.Context, userID int64) (bool, error) {
if userID <= 0 { if userID <= 0 {
return false, nil return false, nil
@ -753,7 +523,7 @@ func (s *Service) loadGiftableNobleVIPAbility(ctx context.Context, propsID int64
return propsNobleVIPAbilityRow{}, serverError("vip_ability_query_failed", err.Error()) return propsNobleVIPAbilityRow{}, serverError("vip_ability_query_failed", err.Error())
} }
if row.VIPLevel > maxGiftableVIPLevel { 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 return row, nil
} }
@ -791,13 +561,13 @@ type propsGrant struct {
days int days int
} }
func nobleVIPAccessoryGrants(ability integration.PropsNobleVIPAbility, days int) []propsGrant { func nobleVIPAccessoryGrants(ability integration.PropsNobleVIPAbility) []propsGrant {
grants := make([]propsGrant, 0, 4) grants := make([]propsGrant, 0, 4)
appendIfPositive := func(propsID int64, typ string) { appendIfPositive := func(propsID int64, typ string) {
if propsID <= 0 { if propsID <= 0 {
return 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.CarID), propsTypeRide)
appendIfPositive(int64(ability.AvatarFrameID), propsTypeAvatarFrame) appendIfPositive(int64(ability.AvatarFrameID), propsTypeAvatarFrame)
@ -851,7 +621,7 @@ func propsRowToView(row propsSourceRecordRow, vipLevels map[int64]int) (PropsVie
if row.Type == propsTypeNobleVIP { if row.Type == propsTypeNobleVIP {
var exists bool var exists bool
level, exists = vipLevels[row.ID] level, exists = vipLevels[row.ID]
if !exists || level > maxGiftableVIPLevel { if !exists || level > 3 {
return PropsView{}, false return PropsView{}, false
} }
} }
@ -875,19 +645,6 @@ func propsGiftDays(propsType string) int {
return defaultGiftDays 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 { func normalizePropsType(propsType string) string {
return strings.ToUpper(strings.TrimSpace(propsType)) return strings.ToUpper(strings.TrimSpace(propsType))
} }

View File

@ -17,8 +17,6 @@ import (
type fakeManagerGateway struct { type fakeManagerGateway struct {
recharges map[int64][]integration.UserTotalRecharge recharges map[int64][]integration.UserTotalRecharge
rechargeErr error rechargeErr error
freightSellers map[int64]bool
freightSellerErr error
abilities map[int64]integration.PropsNobleVIPAbility abilities map[int64]integration.PropsNobleVIPAbility
maxAbility integration.PropsNobleVIPAbility maxAbility integration.PropsNobleVIPAbility
giveRequests []integration.GivePropsBackpackRequest giveRequests []integration.GivePropsBackpackRequest
@ -26,16 +24,9 @@ type fakeManagerGateway struct {
unblockRequests []integration.UnblockAccountRequest unblockRequests []integration.UnblockAccountRequest
switchIDs []int64 switchIDs []int64
cacheCleared []int64 cacheCleared []int64
countryUpdates []fakeCountryUpdate
countryUpdateErr error
badgeActivations []fakeBadgeActivation badgeActivations []fakeBadgeActivation
} }
type fakeCountryUpdate struct {
userID int64
countryID int64
}
type fakeBadgeActivation struct { type fakeBadgeActivation struct {
userID int64 userID int64
badgeID int64 badgeID int64
@ -46,14 +37,6 @@ func (f *fakeManagerGateway) GetUserProfile(context.Context, int64) (integration
return integration.UserProfile{}, nil 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) { func (f *fakeManagerGateway) GetUserProfileByAccountOne(context.Context, string) (integration.UserProfile, error) {
return integration.UserProfile{}, nil return integration.UserProfile{}, nil
} }
@ -69,13 +52,6 @@ func (f *fakeManagerGateway) MapUserTotalRecharge(_ context.Context, userIDs []i
return result, nil 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 { func (f *fakeManagerGateway) GivePropsBackpack(_ context.Context, req integration.GivePropsBackpackRequest) error {
f.giveRequests = append(f.giveRequests, req) f.giveRequests = append(f.giveRequests, req)
return nil return nil
@ -122,14 +98,10 @@ func TestListPropsRequiresAdminFreeAndFiltersVIPLevels(t *testing.T) {
propsSourceRecordRow{ID: 102, Type: propsTypeRide, Name: "Paid ride", AdminFree: false}, propsSourceRecordRow{ID: 102, Type: propsTypeRide, Name: "Paid ride", AdminFree: false},
propsSourceRecordRow{ID: 201, Type: propsTypeNobleVIP, Name: "VIP 3", AdminFree: true}, propsSourceRecordRow{ID: 201, Type: propsTypeNobleVIP, Name: "VIP 3", AdminFree: true},
propsSourceRecordRow{ID: 204, Type: propsTypeNobleVIP, Name: "VIP 4", 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), seedAbilities(t, service.db.(*gorm.DB),
propsNobleVIPAbilityRow{ID: 201, VIPLevel: 3}, propsNobleVIPAbilityRow{ID: 201, VIPLevel: 3},
propsNobleVIPAbilityRow{ID: 204, VIPLevel: 4}, propsNobleVIPAbilityRow{ID: 204, VIPLevel: 4},
propsNobleVIPAbilityRow{ID: 205, VIPLevel: 5},
propsNobleVIPAbilityRow{ID: 206, VIPLevel: 6},
) )
resp, err := service.ListProps(context.Background(), AuthUser{UserID: 1}, "") 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) 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 { 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 { if _, ok := got[204]; ok {
t.Fatalf("vip 204 = %+v, want level 4 visible", item) t.Fatalf("records = %+v, VIP level 4 must be hidden", resp.Records)
}
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)
} }
} }
@ -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: 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: 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: 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) resp, err := service.ListProps(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, propsTypeNobleVIP)
if err != nil { if err != nil {
t.Fatalf("ListProps() error = %v", err) t.Fatalf("ListProps() error = %v", err)
} }
if len(resp.Records) != 3 { if len(resp.Records) != 1 {
t.Fatalf("records = %+v, want enabled LIKEI vip configs up to level 5", resp.Records) t.Fatalf("records = %+v, want only one enabled LIKEI vip config", resp.Records)
} }
got := map[int64]PropsView{} got := resp.Records[0]
for _, item := range resp.Records { if got.ID.Int64() != 301 || got.Type != propsTypeNobleVIP || got.VIPLevel != 1 || got.Days != 30 {
got[item.ID.Int64()] = item t.Fatalf("vip props = %+v, want config 301 level 1", got)
}
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)
} }
} }
@ -215,27 +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) { func TestAddBDLeaderCreatesLeaderAndListEntry(t *testing.T) {
service, _ := newManagerCenterTestService(t) service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB) db := service.db.(*gorm.DB)
@ -303,24 +234,6 @@ func TestAddBDLeaderRejectsTargetFromOtherOrigin(t *testing.T) {
assertAppErrorCode(t, err, "bd_leader_origin_mismatch") 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) { func TestSendPropsRejectsNonAdminFree(t *testing.T) {
service, fake := newManagerCenterTestService(t) service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB) db := service.db.(*gorm.DB)
@ -381,23 +294,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) { func TestSendBadgeActivatesBadgeBackpack(t *testing.T) {
service, fake := newManagerCenterTestService(t) service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB) db := service.db.(*gorm.DB)
@ -437,12 +333,12 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
seedVIPConfigs(t, db, model.VipLevelConfig{ seedVIPConfigs(t, db, model.VipLevelConfig{
ID: 301, ID: 301,
SysOrigin: "LIKEI", SysOrigin: "LIKEI",
Level: 5, Level: 3,
LevelCode: "VIP5", LevelCode: "VIP3",
DisplayName: "VIP 5", DisplayName: "VIP 3",
Enabled: true, Enabled: true,
DurationDays: 30, DurationDays: 30,
PriceGold: 5000, PriceGold: 3000,
}) })
resp, err := service.SendProps(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, SendPropsRequest{ resp, err := service.SendProps(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, SendPropsRequest{
@ -452,7 +348,7 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("SendProps() error = %v", err) 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) t.Fatalf("resp = %+v, want vip gift success", resp)
} }
@ -460,8 +356,8 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
if err := db.Where("sys_origin = ? AND user_id = ?", "LIKEI", int64(2)).First(&state).Error; err != nil { if err := db.Where("sys_origin = ? AND user_id = ?", "LIKEI", int64(2)).First(&state).Error; err != nil {
t.Fatalf("load state: %v", err) t.Fatalf("load state: %v", err)
} }
if state.Level != 5 || state.Status != vipStatusActive { if state.Level != 3 || state.Status != vipStatusActive {
t.Fatalf("state = %+v, want vip5 active", state) t.Fatalf("state = %+v, want vip3 active", state)
} }
var orders int64 var orders int64
if err := db.Model(&model.VipOrderRecord{}).Where("user_id = ? AND order_type = ?", int64(2), vipOrderTypeGMGift).Count(&orders).Error; err != nil { if err := db.Model(&model.VipOrderRecord{}).Where("user_id = ? AND order_type = ?", int64(2), vipOrderTypeGMGift).Count(&orders).Error; err != nil {
@ -471,7 +367,7 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
t.Fatalf("orders = %d, want 1", orders) t.Fatalf("orders = %d, want 1", orders)
} }
var records int64 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) t.Fatalf("count gift records: %v", err)
} }
if records != 1 { if records != 1 {
@ -532,9 +428,9 @@ func TestSendVIPRejectsLevelAboveGiftLimit(t *testing.T) {
seedVIPConfigs(t, db, model.VipLevelConfig{ seedVIPConfigs(t, db, model.VipLevelConfig{
ID: 304, ID: 304,
SysOrigin: "LIKEI", SysOrigin: "LIKEI",
Level: 6, Level: 4,
LevelCode: "VIP6", LevelCode: "VIP4",
DisplayName: "VIP 6", DisplayName: "VIP 4",
Enabled: true, Enabled: true,
DurationDays: 30, DurationDays: 30,
}) })
@ -617,19 +513,6 @@ func assertManagerVIPCleanupBadge(t *testing.T, db *gorm.DB, badgeID int64, expi
} }
} }
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) { func TestBanUserRejectsRechargedUser(t *testing.T) {
service, fake := newManagerCenterTestService(t) service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB) db := service.db.(*gorm.DB)
@ -709,19 +592,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) { func TestUnbanUserRejectsNormalUser(t *testing.T) {
service, fake := newManagerCenterTestService(t) service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB) db := service.db.(*gorm.DB)
@ -781,120 +651,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) { func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
t.Helper() t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
@ -904,7 +660,6 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
if err := db.AutoMigrate( if err := db.AutoMigrate(
&teamManagerInfoRow{}, &teamManagerInfoRow{},
&userBaseInfoRow{}, &userBaseInfoRow{},
&sysCountryCodeRow{},
&bdLeaderRow{}, &bdLeaderRow{},
&businessDevelopmentBaseInfoRow{}, &businessDevelopmentBaseInfoRow{},
&userHistoryIdentityRow{}, &userHistoryIdentityRow{},
@ -920,7 +675,6 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
} }
fake := &fakeManagerGateway{ fake := &fakeManagerGateway{
recharges: map[int64][]integration.UserTotalRecharge{}, recharges: map[int64][]integration.UserTotalRecharge{},
freightSellers: map[int64]bool{},
abilities: map[int64]integration.PropsNobleVIPAbility{}, abilities: map[int64]integration.PropsNobleVIPAbility{},
} }
return NewService(db, fake), fake return NewService(db, fake), fake
@ -928,36 +682,11 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
func seedManager(t *testing.T, db *gorm.DB, userID int64) { func seedManager(t *testing.T, db *gorm.DB, userID int64) {
t.Helper() t.Helper()
seedManagerWithFeatures(t, db, userID, ManagerFeatures{ if err := db.Create(&teamManagerInfoRow{ID: userID, UserID: userID, Origin: "LIKEI", RegionID: "SA"}).Error; err != nil {
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 {
t.Fatalf("seed manager: %v", err) t.Fatalf("seed manager: %v", err)
} }
} }
func boolPtr(value bool) *bool {
return &value
}
func seedUsers(t *testing.T, db *gorm.DB, rows ...userBaseInfoRow) { func seedUsers(t *testing.T, db *gorm.DB, rows ...userBaseInfoRow) {
t.Helper() t.Helper()
for _, row := range rows { for _, row := range rows {
@ -970,15 +699,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) { func seedProps(t *testing.T, db *gorm.DB, rows ...propsSourceRecordRow) {
t.Helper() t.Helper()
for _, row := range rows { for _, row := range rows {

View File

@ -28,14 +28,9 @@ const (
vipGiftOrigin = "VIP_PURCHASE" vipGiftOrigin = "VIP_PURCHASE"
vipGiftOriginDesc = "VIP purchase" vipGiftOriginDesc = "VIP purchase"
defaultGiftDays = 30 defaultGiftDays = 7
defaultVIPGiftDays = 30 defaultVIPGiftDays = 30
// 经理赠送统一限制为运营确认的三个有效期,避免客户端提交任意天数。 maxGiftableVIPLevel = 3
giftDays30 = 30
giftDays60 = 60
giftDays90 = 90
// 经理中心 VIP 赠送按当前运营范围放开 VIP1-VIP5列表和发放校验共用该上限避免展示可选但提交失败。
maxGiftableVIPLevel = 5
accountStatusNormal = "NORMAL" accountStatusNormal = "NORMAL"
accountStatusFreeze = "FREEZE" accountStatusFreeze = "FREEZE"
@ -43,16 +38,6 @@ const (
accountStatusArchiveDevice = "ARCHIVE_DEVICE" 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 AppError = common.AppError
type AuthUser = common.AuthUser type AuthUser = common.AuthUser
@ -64,10 +49,8 @@ type managerDB interface {
type managerGateway interface { type managerGateway interface {
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error) 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) GetUserProfileByAccountOne(ctx context.Context, account string) (integration.UserProfile, error)
MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, 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 GivePropsBackpack(ctx context.Context, req integration.GivePropsBackpackRequest) error
GetNobleVIPAbility(ctx context.Context, sourceID int64) (integration.PropsNobleVIPAbility, error) GetNobleVIPAbility(ctx context.Context, sourceID int64) (integration.PropsNobleVIPAbility, error)
GetUserMaxNobleVIPAbility(ctx context.Context, userID int64) (integration.PropsNobleVIPAbility, error) GetUserMaxNobleVIPAbility(ctx context.Context, userID int64) (integration.PropsNobleVIPAbility, error)
@ -124,39 +107,17 @@ func (id *ID) UnmarshalJSON(data []byte) error {
type ProfileResponse struct { type ProfileResponse struct {
Manager UserView `json:"manager"` 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 { type UserSearchResponse struct {
User UserView `json:"user"` 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 { type UserView struct {
UserID ID `json:"userId"` UserID ID `json:"userId"`
Account string `json:"account"` Account string `json:"account"`
UserAvatar string `json:"userAvatar"` UserAvatar string `json:"userAvatar"`
UserNickname string `json:"userNickname"` UserNickname string `json:"userNickname"`
CountryID ID `json:"countryId,omitempty"`
CountryCode string `json:"countryCode,omitempty"` CountryCode string `json:"countryCode,omitempty"`
CountryName string `json:"countryName,omitempty"` CountryName string `json:"countryName,omitempty"`
OriginSys string `json:"originSys,omitempty"` OriginSys string `json:"originSys,omitempty"`
@ -185,7 +146,6 @@ type PropsView struct {
type SendPropsRequest struct { type SendPropsRequest struct {
AcceptUserID ID `json:"acceptUserId"` AcceptUserID ID `json:"acceptUserId"`
PropsID ID `json:"propsId"` PropsID ID `json:"propsId"`
Days int `json:"days"`
} }
type SendPropsResponse struct { type SendPropsResponse struct {
@ -219,16 +179,6 @@ type UnbanUserResponse struct {
AccountStatus string `json:"accountStatus"` 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 { type BDLeaderListResponse struct {
Count int `json:"count"` Count int `json:"count"`
Records []BDLeaderView `json:"records"` Records []BDLeaderView `json:"records"`

View File

@ -79,7 +79,7 @@ func (s *Service) listVIPProps(ctx context.Context, user AuthUser) (*PropsListRe
}, nil }, 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) sysOrigin := normalizeSysOrigin(target.OriginSys)
config, found, err := s.loadVIPConfigByID(ctx, sysOrigin, configID) config, found, err := s.loadVIPConfigByID(ctx, sysOrigin, configID)
if err != nil || !found { 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 { if config.Level < 1 || config.Level > maxGiftableVIPLevel {
return nil, badRequest("vip_not_giftable", "vip level is invalid") return nil, badRequest("vip_not_giftable", "vip level is invalid")
} }
// 经理选择的赠送天数覆盖 VIP 售卖配置时长,订单、状态和附属权益保持同一到期口径。 if config.DurationDays <= 0 {
config.DurationDays = days config.DurationDays = defaultVIPGiftDays
}
now := time.Now() now := time.Now()
orderID, err := utils.NextID() orderID, err := utils.NextID()

View File

@ -117,7 +117,7 @@ ORDER BY claims.last_claim_time DESC, claims.user_id ASC`
if err != nil { if err != nil {
return nil, err return nil, err
} }
rewardItems := s.loadRewardItemsMap(ctx, sysOrigin, claimDetailLevels(details)) rewardItems := s.loadRewardItemsMap(ctx, claimDetailLevels(details))
records = make([]RechargeRewardClaimRecordView, 0, len(rows)) records = make([]RechargeRewardClaimRecordView, 0, len(rows))
for _, row := range rows { for _, row := range rows {
records = append(records, rechargeRewardClaimRecordView(row, displayMonth, rechargeDate, details[row.UserID], rewardItems)) records = append(records, rechargeRewardClaimRecordView(row, displayMonth, rechargeDate, details[row.UserID], rewardItems))

View File

@ -39,7 +39,7 @@ func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*RechargeRew
if err != nil { if err != nil {
return nil, err return nil, err
} }
rewardItems := s.loadRewardItemsMap(ctx, configRow.SysOrigin, levels) rewardItems := s.loadRewardItemsMap(ctx, levels)
return &RechargeRewardConfigResponse{ return &RechargeRewardConfigResponse{
Configured: true, Configured: true,
ID: configRow.ID, 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()) 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 { if err != nil {
return nil, err 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)) 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 { if len(inputs) == 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_level_configs", "levelConfigs is required") 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 { if amountCents <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_recharge_amount", "rechargeAmount must be greater than 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) { if item.RewardGold <= 0 && (rewardGroupID == nil || *rewardGroupID <= 0) {
return nil, NewAppError(http.StatusBadRequest, "invalid_reward", "rewardGold or rewardGroupId is required") 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 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) { func (s *Service) loadConfigBundle(ctx context.Context, sysOrigin string) (*rechargeRewardConfigBundle, error) {
sysOrigin = s.normalizeSysOrigin(sysOrigin) sysOrigin = s.normalizeSysOrigin(sysOrigin)

View File

@ -224,46 +224,6 @@ INSERT INTO likei_wallet.user_freight_balance_running_water (accept_user_id, sys
} }
} }
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) { func TestPageClaimRecordsUsesActualReceiveRecords(t *testing.T) {
groupID := int64(9001) groupID := int64(9001)
service, db := newTestService(t, rechargeRewardTestGateway{ service, db := newTestService(t, rechargeRewardTestGateway{
@ -448,15 +408,6 @@ func (g rechargeRewardTestGateway) GetRewardGroupDetail(_ context.Context, group
return integration.RewardGroupDetail{}, fmt.Errorf("group not found") 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) { func (g rechargeRewardTestGateway) MapUserTotalRecharge(context.Context, []int64) (map[int64][]integration.UserTotalRecharge, error) {
return g.total, nil 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.NextRechargeAmount = formatAmountCents(next.RechargeAmountCents)
resp.NextRechargeAmountCents = next.RechargeAmountCents resp.NextRechargeAmountCents = next.RechargeAmountCents
} }
rewardItems := s.loadRewardItemsMap(ctx, resp.SysOrigin, bundle.Levels) rewardItems := s.loadRewardItemsMap(ctx, bundle.Levels)
matchedLevel := 0 matchedLevel := 0
if matchedOK { if matchedOK {
matchedLevel = matched.Level matchedLevel = matched.Level

View File

@ -131,7 +131,6 @@ func sortLevels(levels []rechargeRewardLevelSnapshot) {
func (s *Service) loadRewardItemsMap( func (s *Service) loadRewardItemsMap(
ctx context.Context, ctx context.Context,
sysOrigin string,
levels []rechargeRewardLevelSnapshot, levels []rechargeRewardLevelSnapshot,
) map[int64][]RechargeRewardRewardItem { ) map[int64][]RechargeRewardRewardItem {
if s.java == nil { if s.java == nil {
@ -139,14 +138,10 @@ func (s *Service) loadRewardItemsMap(
} }
groupIDs := make([]int64, 0) groupIDs := make([]int64, 0)
seen := make(map[int64]struct{}) seen := make(map[int64]struct{})
groupNames := make(map[int64]string)
for _, level := range levels { for _, level := range levels {
if level.RewardGroupID == nil || *level.RewardGroupID <= 0 { if level.RewardGroupID == nil || *level.RewardGroupID <= 0 {
continue continue
} }
if strings.TrimSpace(level.RewardGroupName) != "" {
groupNames[*level.RewardGroupID] = level.RewardGroupName
}
if _, exists := seen[*level.RewardGroupID]; exists { if _, exists := seen[*level.RewardGroupID]; exists {
continue continue
} }
@ -158,23 +153,15 @@ func (s *Service) loadRewardItemsMap(
result := make(map[int64][]RechargeRewardRewardItem, len(groupIDs)) result := make(map[int64][]RechargeRewardRewardItem, len(groupIDs))
for _, groupID := range groupIDs { for _, groupID := range groupIDs {
detail, err := s.java.GetRewardGroupDetail(ctx, groupID) detail, err := s.java.GetRewardGroupDetail(ctx, groupID)
if err != nil || !rewardGroupDetailHasItems(detail) { if err != nil {
if fallback, fallbackErr := s.java.FindRewardGroupDetail(ctx, sysOrigin, groupNames[groupID]); fallbackErr == nil && rewardGroupDetailHasItems(fallback) {
detail = fallback
} else {
result[groupID] = []RechargeRewardRewardItem{} result[groupID] = []RechargeRewardRewardItem{}
continue continue
} }
}
result[groupID] = rewardItemsFromGroup(detail) result[groupID] = rewardItemsFromGroup(detail)
} }
return result return result
} }
func rewardGroupDetailHasItems(detail integration.RewardGroupDetail) bool {
return len(detail.RewardConfigList) > 0
}
func rewardItemsFromGroup(detail integration.RewardGroupDetail) []RechargeRewardRewardItem { func rewardItemsFromGroup(detail integration.RewardGroupDetail) []RechargeRewardRewardItem {
items := make([]RechargeRewardRewardItem, 0, len(detail.RewardConfigList)) items := make([]RechargeRewardRewardItem, 0, len(detail.RewardConfigList))
for _, item := range detail.RewardConfigList { for _, item := range detail.RewardConfigList {

View File

@ -35,7 +35,6 @@ type rechargeRewardDB interface {
type rechargeRewardJavaGateway interface { type rechargeRewardJavaGateway interface {
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error) GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error)
GetRewardGroupDetail(ctx context.Context, groupID int64) (integration.RewardGroupDetail, 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) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error)
GetThisMonthTotalPersonalRecharge(ctx context.Context, userID int64) (integration.DecimalString, error) GetThisMonthTotalPersonalRecharge(ctx context.Context, userID int64) (integration.DecimalString, error)
} }

View File

@ -20,11 +20,6 @@ import (
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
const (
defaultTaskCenterArchiveBatchSize = 500
maxTaskCenterArchiveBatchSize = 1000
)
// StartArchiveWorker 启动任务中心事件 COS 归档 worker。 // StartArchiveWorker 启动任务中心事件 COS 归档 worker。
func (s *Service) StartArchiveWorker(ctx context.Context) error { func (s *Service) StartArchiveWorker(ctx context.Context) error {
if !s.cfg.TaskCenter.Archive.Enabled { 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 { 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) rows, err := s.claimArchiveRows(ctx, batchSize)
if err != nil { if err != nil {
return err return err
@ -99,47 +97,17 @@ func (s *Service) claimArchiveRows(ctx context.Context, batchSize int) ([]model.
var rows []model.TaskCenterEventArchiveOutbox var rows []model.TaskCenterEventArchiveOutbox
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
staleProcessingBefore := time.Now().Add(-10 * time.Minute) 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"}). if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
Where("status = ? AND update_time < ?", ArchiveStatusProcessing, staleProcessingBefore). Where("status IN ? OR (status = ? AND update_time < ?)",
Order("update_time asc, id asc"). []string{ArchiveStatusPending, ArchiveStatusFailed},
Limit(staleLimit). ArchiveStatusProcessing,
Find(&staleRows).Error; err != nil { staleProcessingBefore,
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).
Order("create_time asc, id asc"). Order("create_time asc, id asc").
Limit(retryable.limit). Limit(batchSize).
Find(&retryableRows).Error; err != nil { Find(&rows).Error; err != nil {
return err return err
} }
rows = append(rows, retryableRows...)
remaining -= len(retryableRows)
}
}
if len(rows) == 0 { if len(rows) == 0 {
return nil return nil
} }
@ -156,44 +124,6 @@ func (s *Service) claimArchiveRows(ctx context.Context, batchSize int) ([]model.
return rows, nil 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) { func (s *Service) newCOSClient() (*cos.Client, error) {
bucketURL, err := url.Parse(fmt.Sprintf("https://%s.cos.%s.myqcloud.com", bucketURL, err := url.Parse(fmt.Sprintf("https://%s.cos.%s.myqcloud.com",
strings.TrimSpace(s.cfg.TaskCenter.Archive.Bucket), strings.TrimSpace(s.cfg.TaskCenter.Archive.Bucket),
@ -215,13 +145,7 @@ func groupArchiveRows(rows []model.TaskCenterEventArchiveOutbox) [][]model.TaskC
groupsByKey := make(map[string][]model.TaskCenterEventArchiveOutbox) groupsByKey := make(map[string][]model.TaskCenterEventArchiveOutbox)
keys := make([]string, 0) keys := make([]string, 0)
for _, row := range rows { for _, row := range rows {
partitionHour := "UNKNOWN" groupKey := row.SysOrigin + "\x00" + row.EventType
if !row.OccurredAt.IsZero() {
partitionHour = row.OccurredAt.UTC().Format("2006-01-02T15")
}
// COS key 带 UTC dt/hour 分区,所以同一抢占批次也必须先按小时拆组;否则跨小时
// 事件会被放进第一行的错误目录,后续按分区恢复会漏数。
groupKey := partitionHour + "\x00" + row.SysOrigin + "\x00" + row.EventType
if _, exists := groupsByKey[groupKey]; !exists { if _, exists := groupsByKey[groupKey]; !exists {
keys = append(keys, groupKey) keys = append(keys, groupKey)
} }
@ -244,31 +168,15 @@ func (s *Service) uploadArchiveGroup(ctx context.Context, client *cos.Client, ro
if err != nil { if err != nil {
return err return err
} }
verified, err := putAndHeadArchiveObject(ctx, client, cosKey, body, map[string]string{ _, err = client.Object.Put(ctx, cosKey, bytes.NewReader(body), &cos.ObjectPutOptions{
"object-type": "archive", ObjectPutHeaderOptions: &cos.ObjectPutHeaderOptions{
"row-count": fmt.Sprintf("%d", len(rows)), ContentType: "application/gzip",
ContentEncoding: "gzip",
},
}) })
if err != nil { if err != nil {
return err return err
} }
verifiedAt := time.Now()
minID, maxID, minOccurredAt, maxOccurredAt := archiveGroupBounds(rows)
if err := s.upsertArchiveManifest(ctx, model.TaskCenterArchiveManifest{
ObjectType: archiveManifestObjectArchive,
COSKey: cosKey,
SHA256: verified.SHA256,
ETag: verified.ETag,
CompressedSize: verified.CompressedSize,
RowCount: len(rows),
MinOutboxID: minID,
MaxOutboxID: maxID,
MinOccurredAt: minOccurredAt,
MaxOccurredAt: maxOccurredAt,
VerificationStatus: archiveManifestVerified,
VerifiedAt: &verifiedAt,
}); err != nil {
return err
}
return s.markArchiveGroupUploaded(ctx, rows, cosKey) return s.markArchiveGroupUploaded(ctx, rows, cosKey)
} }

View File

@ -1,264 +0,0 @@
package taskcenter
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
archiveGCControlID = uint8(1)
defaultArchiveGCBatch = 200
minArchiveGCBatch = 200
maxArchiveGCBatch = 500
minimumArchiveGCAge = 30 * 24 * time.Hour
archiveGCClaimIndex = "idx_task_center_archive_claim_create"
)
// ArchiveGCStatus 返回唯一控制行;总开关关闭时不要求迁移已经执行,便于新版本先安全上线。
func (s *Service) ArchiveGCStatus(ctx context.Context) (*ArchiveGCStatusView, error) {
row, err := s.loadArchiveGCRun(ctx)
if err != nil {
// 总开关关闭的新环境可能尚未执行 060此时返回安全的 IDLE。若控制行已经存在
// 即使关闭开关也展示真实 RUNNING/PAUSED 状态,避免运维误以为历史 run 不存在。
if !s.cfg.TaskCenter.Archive.GC.Enabled {
return &ArchiveGCStatusView{Enabled: false, Status: ArchiveGCStatusIdle, BatchSize: defaultArchiveGCBatch}, nil
}
return nil, err
}
view := s.archiveGCStatusView(row)
return &view, nil
}
// ArchiveGCDryRun 对索引支持的候选范围做有界预检,并把 cutoff 固定进控制行。
// 这里不做 COUNT(*),因为在大表上为了展示总数触发长扫描本身就违背清理降载目标。
func (s *Service) ArchiveGCDryRun(ctx context.Context, request ArchiveGCDryRunRequest) (*ArchiveGCDryRunResponse, error) {
if err := s.requireArchiveGCReady(ctx); err != nil {
return nil, err
}
current, err := s.loadArchiveGCRun(ctx)
if err != nil {
return nil, err
}
if current.Status == ArchiveGCStatusRunning {
return nil, NewAppError(http.StatusConflict, "archive_gc_running", "pause the running archive GC before preparing another cutoff")
}
cutoff := time.UnixMilli(request.CutoffTime)
if request.CutoffTime <= 0 || cutoff.After(time.Now().Add(-minimumArchiveGCAge)) {
return nil, NewAppError(http.StatusBadRequest, "archive_gc_cutoff_too_recent", "cutoffTime must retain at least 30 days of hot data")
}
batchSize, err := normalizeArchiveGCBatch(request.BatchSize)
if err != nil {
return nil, err
}
preview, err := s.findArchiveGCCandidates(ctx, cutoff, batchSize+1)
if err != nil {
return nil, err
}
hasMore := len(preview) > batchSize
if hasMore {
preview = preview[:batchSize]
}
runSnowflake, err := utils.NextID()
if err != nil {
return nil, err
}
runID := strconv.FormatInt(runSnowflake, 10)
if runID == "" {
return nil, NewAppError(http.StatusInternalServerError, "archive_gc_run_id_failed", "cannot create archive GC run id")
}
now := time.Now()
var updated model.TaskCenterArchiveGCRun
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var locked model.TaskCenterArchiveGCRun
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", archiveGCControlID).First(&locked).Error; err != nil {
return archiveGCMigrationError(err)
}
if locked.Status == ArchiveGCStatusRunning {
return NewAppError(http.StatusConflict, "archive_gc_running", "pause the running archive GC before preparing another cutoff")
}
updates := map[string]any{
"run_id": runID, "status": ArchiveGCStatusDryRun, "cutoff_time": cutoff, "batch_size": batchSize,
"scanned_rows": 0, "eligible_rows": 0, "protected_rows": 0, "deleted_rows": 0,
"recovery_object_count": 0, "cursor_create_time": nil, "cursor_id": 0,
"lease_owner": "", "lease_until": nil, "last_error": "", "update_time": now,
}
if err := tx.Model(&model.TaskCenterArchiveGCRun{}).Where("id = ?", archiveGCControlID).Updates(updates).Error; err != nil {
return err
}
return tx.Where("id = ?", archiveGCControlID).First(&updated).Error
})
if err != nil {
return nil, err
}
view := s.archiveGCStatusView(updated)
return &ArchiveGCDryRunResponse{ArchiveGCStatusView: view, PreviewRows: len(preview), HasMore: hasMore}, nil
}
// StartArchiveGC 只允许启动刚刚预检过的控制行,确保真实删除沿用完全相同的固定 cutoff。
func (s *Service) StartArchiveGC(ctx context.Context) (*ArchiveGCStatusView, error) {
if err := s.requireArchiveGCReady(ctx); err != nil {
return nil, err
}
return s.transitionArchiveGC(ctx, []string{ArchiveGCStatusDryRun}, ArchiveGCStatusRunning)
}
func (s *Service) PauseArchiveGC(ctx context.Context) (*ArchiveGCStatusView, error) {
if err := s.requireArchiveGCEnabled(); err != nil {
return nil, err
}
return s.transitionArchiveGC(ctx, []string{ArchiveGCStatusRunning}, ArchiveGCStatusPaused)
}
func (s *Service) ResumeArchiveGC(ctx context.Context) (*ArchiveGCStatusView, error) {
if err := s.requireArchiveGCReady(ctx); err != nil {
return nil, err
}
return s.transitionArchiveGC(ctx, []string{ArchiveGCStatusPaused, ArchiveGCStatusFailed}, ArchiveGCStatusRunning)
}
func (s *Service) transitionArchiveGC(ctx context.Context, from []string, to string) (*ArchiveGCStatusView, error) {
var updated model.TaskCenterArchiveGCRun
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var locked model.TaskCenterArchiveGCRun
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", archiveGCControlID).First(&locked).Error; err != nil {
return archiveGCMigrationError(err)
}
allowed := false
for _, status := range from {
if locked.Status == status {
allowed = true
break
}
}
if !allowed {
return NewAppError(http.StatusConflict, "archive_gc_state_conflict", "archive GC state does not allow this operation")
}
if locked.CutoffTime == nil {
return NewAppError(http.StatusConflict, "archive_gc_cutoff_missing", "run dry-run before starting archive GC")
}
now := time.Now()
if err := tx.Model(&model.TaskCenterArchiveGCRun{}).Where("id = ?", archiveGCControlID).Updates(map[string]any{
"status": to, "lease_owner": "", "lease_until": nil, "last_error": "", "update_time": now,
}).Error; err != nil {
return err
}
return tx.Where("id = ?", archiveGCControlID).First(&updated).Error
})
if err != nil {
return nil, err
}
view := s.archiveGCStatusView(updated)
return &view, nil
}
func (s *Service) loadArchiveGCRun(ctx context.Context) (model.TaskCenterArchiveGCRun, error) {
var row model.TaskCenterArchiveGCRun
if err := s.db.WithContext(ctx).Where("id = ?", archiveGCControlID).First(&row).Error; err != nil {
return row, archiveGCMigrationError(err)
}
return row, nil
}
func (s *Service) requireArchiveGCReady(ctx context.Context) error {
if err := s.requireArchiveGCEnabled(); err != nil {
return err
}
archive := s.cfg.TaskCenter.Archive
if strings.TrimSpace(archive.Bucket) == "" || strings.TrimSpace(archive.Region) == "" ||
strings.TrimSpace(archive.SecretID) == "" || strings.TrimSpace(archive.SecretKey) == "" {
return NewAppError(http.StatusServiceUnavailable, "archive_gc_cos_not_configured", "task center archive COS is not configured")
}
return s.requireArchiveGCClaimIndex(ctx)
}
func (s *Service) requireArchiveGCEnabled() error {
if !s.cfg.TaskCenter.Archive.GC.Enabled {
return NewAppError(http.StatusServiceUnavailable, "archive_gc_disabled", "task center archive GC is disabled")
}
return nil
}
func (s *Service) requireArchiveGCClaimIndex(ctx context.Context) error {
type indexColumn struct {
ColumnName string `gorm:"column:column_name"`
Seq int `gorm:"column:seq_in_index"`
SubPart *int64 `gorm:"column:sub_part"`
IndexType string `gorm:"column:index_type"`
IsVisible string `gorm:"column:is_visible"`
}
var columns []indexColumn
if err := s.db.WithContext(ctx).Raw(`
-- MySQL 会把 information_schema 列标签原样返回为大写显式使用与 GORM tag
-- 一致的小写别名避免查询有三行但结构体字段全为零值而误判索引缺失
SELECT COLUMN_NAME AS column_name,
SEQ_IN_INDEX AS seq_in_index,
SUB_PART AS sub_part,
INDEX_TYPE AS index_type,
IS_VISIBLE AS 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`, archiveGCClaimIndex).Scan(&columns).Error; err != nil {
return NewAppError(http.StatusServiceUnavailable, "archive_gc_index_check_failed", "cannot verify the archive GC claim index")
}
required := [...]string{"status", "create_time", "id"}
if len(columns) != len(required) {
return NewAppError(http.StatusServiceUnavailable, "archive_gc_index_required", "migration 056 claim index must be online before archive GC")
}
for index, expected := range required {
column := columns[index]
if column.Seq != index+1 || !strings.EqualFold(column.ColumnName, expected) || column.SubPart != nil ||
!strings.EqualFold(column.IndexType, "BTREE") || !strings.EqualFold(column.IsVisible, "YES") {
return NewAppError(http.StatusServiceUnavailable, "archive_gc_index_required", "migration 056 claim index must be online before archive GC")
}
}
return nil
}
func normalizeArchiveGCBatch(value int) (int, error) {
if value == 0 {
return defaultArchiveGCBatch, nil
}
if value < minArchiveGCBatch || value > maxArchiveGCBatch {
return 0, NewAppError(http.StatusBadRequest, "archive_gc_batch_invalid", "batchSize must be between 200 and 500")
}
return value, nil
}
func archiveGCMigrationError(err error) error {
if errors.Is(err, gorm.ErrRecordNotFound) {
return NewAppError(http.StatusServiceUnavailable, "archive_gc_migration_required", "migration 060 archive GC control row is required")
}
return err
}
func (s *Service) archiveGCStatusView(row model.TaskCenterArchiveGCRun) ArchiveGCStatusView {
view := ArchiveGCStatusView{
Enabled: s.cfg.TaskCenter.Archive.GC.Enabled, RunID: row.RunID, Status: row.Status,
BatchSize: row.BatchSize, ScannedRows: row.ScannedRows, EligibleRows: row.EligibleRows,
ProtectedRows: row.ProtectedRows, DeletedRows: row.DeletedRows,
RecoveryObjectCount: row.RecoveryObjectCount, CursorID: row.CursorID,
LastError: row.LastError, CreateTime: row.CreateTime.UnixMilli(), UpdateTime: row.UpdateTime.UnixMilli(),
}
if row.CutoffTime != nil {
view.CutoffTime = row.CutoffTime.UnixMilli()
}
if row.CursorCreateTime != nil {
view.CursorCreateTime = row.CursorCreateTime.UnixMilli()
}
if row.LeaseUntil != nil {
view.LeaseUntil = row.LeaseUntil.UnixMilli()
}
return view
}

View File

@ -1,53 +0,0 @@
package taskcenter
const (
ArchiveGCStatusIdle = "IDLE"
ArchiveGCStatusDryRun = "DRY_RUN"
ArchiveGCStatusRunning = "RUNNING"
ArchiveGCStatusPaused = "PAUSED"
ArchiveGCStatusCompleted = "COMPLETED"
ArchiveGCStatusFailed = "FAILED"
)
// ArchiveGCDryRunRequest 冻结一次清理的截止时间和批量start 不再接收这些字段,避免
// 预检与真实执行使用不同范围。
type ArchiveGCDryRunRequest struct {
CutoffTime int64 `json:"cutoffTime"`
BatchSize int `json:"batchSize"`
}
type ArchiveGCStatusView struct {
Enabled bool `json:"enabled"`
RunID string `json:"runId"`
Status string `json:"status"`
CutoffTime int64 `json:"cutoffTime,omitempty"`
BatchSize int `json:"batchSize"`
ScannedRows int64 `json:"scannedRows"`
EligibleRows int64 `json:"eligibleRows"`
ProtectedRows int64 `json:"protectedRows"`
DeletedRows int64 `json:"deletedRows"`
RecoveryObjectCount int64 `json:"recoveryObjectCount"`
CursorCreateTime int64 `json:"cursorCreateTime,omitempty"`
CursorID int64 `json:"cursorId,string,omitempty"`
LeaseUntil int64 `json:"leaseUntil,omitempty"`
LastError string `json:"lastError,omitempty"`
CreateTime int64 `json:"createTime,omitempty"`
UpdateTime int64 `json:"updateTime,omitempty"`
}
type ArchiveGCDryRunResponse struct {
ArchiveGCStatusView
PreviewRows int `json:"previewRows"`
HasMore bool `json:"hasMore"`
}
type ArchiveGCTickResponse struct {
RunID string `json:"runId"`
Status string `json:"status"`
ProcessedRows int `json:"processedRows"`
DeletedRows int `json:"deletedRows"`
ProtectedRows int `json:"protectedRows"`
RecoveryCOSKey string `json:"recoveryCosKey,omitempty"`
Finished bool `json:"finished"`
SkippedReason string `json:"skippedReason,omitempty"`
}

View File

@ -1,533 +0,0 @@
package taskcenter
import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path"
"sort"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/model"
"github.com/tencentyun/cos-go-sdk-v5"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
archiveGCLeaseDuration = 5 * time.Minute
maxArchiveGCSourceObjectsPerRun = 10
)
var archiveGCWorkerIdentity = buildArchiveGCWorkerIdentity()
type archiveGCRecoveryRow struct {
ID int64 `json:"id,string"`
SysOrigin string `json:"sysOrigin"`
EventID string `json:"eventId"`
EventType string `json:"eventType"`
UserID int64 `json:"userId,string"`
OccurredAt time.Time `json:"occurredAt"`
RawJSON string `json:"rawJson"`
Status string `json:"status"`
RetryCount int `json:"retryCount"`
COSKey string `json:"cosKey"`
FailureReason string `json:"failureReason"`
CreateTime time.Time `json:"createTime"`
UpdateTime time.Time `json:"updateTime"`
ArchivedAt *time.Time `json:"archivedAt"`
}
// ArchiveGCTick 由 chatapp-cron 显式触发且每次只处理一个小批次。COS 回读和恢复包上传都
// 位于数据库删除之前;任一步失败都会暂停本次 run绝不继续“尽力删除”。
func (s *Service) ArchiveGCTick(ctx context.Context) (*ArchiveGCTickResponse, error) {
if err := s.requireArchiveGCReady(ctx); err != nil {
return nil, err
}
leaseToken := fmt.Sprintf("%s:%d", archiveGCWorkerIdentity, time.Now().UnixNano())
run, acquired, reason, err := s.acquireArchiveGCLease(ctx, leaseToken)
if err != nil {
return nil, err
}
if !acquired {
return &ArchiveGCTickResponse{RunID: run.RunID, Status: run.Status, SkippedReason: reason, Finished: run.Status == ArchiveGCStatusCompleted}, nil
}
if run.CutoffTime == nil {
err := NewAppError(http.StatusConflict, "archive_gc_cutoff_missing", "archive GC cutoff is missing")
s.pauseArchiveGCTick(run.RunID, leaseToken, err)
return nil, err
}
rows, err := s.findArchiveGCCandidates(ctx, *run.CutoffTime, run.BatchSize)
if err != nil {
s.pauseArchiveGCTick(run.RunID, leaseToken, err)
return nil, err
}
if len(rows) == 0 {
if err := s.completeArchiveGCTick(ctx, run.RunID, leaseToken); err != nil {
return nil, err
}
return &ArchiveGCTickResponse{RunID: run.RunID, Status: ArchiveGCStatusCompleted, Finished: true}, nil
}
rows = limitArchiveGCSourceObjects(rows, maxArchiveGCSourceObjectsPerRun)
client, err := s.newCOSClient()
if err != nil {
s.pauseArchiveGCTick(run.RunID, leaseToken, err)
return nil, err
}
if err := s.verifyArchiveGCSources(ctx, client, rows); err != nil {
s.pauseArchiveGCTick(run.RunID, leaseToken, err)
return nil, err
}
recoveryKey, recoverySHA, err := s.createAndVerifyArchiveGCRecovery(ctx, client, run.RunID, rows)
if err != nil {
s.pauseArchiveGCTick(run.RunID, leaseToken, err)
return nil, err
}
deleted, protected, err := s.deleteVerifiedArchiveGCRows(ctx, run, leaseToken, recoveryKey, recoverySHA, rows)
if err != nil {
s.pauseArchiveGCTick(run.RunID, leaseToken, err)
return nil, err
}
return &ArchiveGCTickResponse{
RunID: run.RunID, Status: ArchiveGCStatusRunning, ProcessedRows: len(rows), DeletedRows: deleted,
ProtectedRows: protected, RecoveryCOSKey: recoveryKey,
}, nil
}
func (s *Service) findArchiveGCCandidates(ctx context.Context, cutoff time.Time, limit int) ([]model.TaskCenterEventArchiveOutbox, error) {
if limit <= 0 || limit > maxArchiveGCBatch+1 {
limit = defaultArchiveGCBatch
}
var rows []model.TaskCenterEventArchiveOutbox
// FORCE INDEX 将扫描固定在 (status,create_time,id)NOT EXISTS 只针对命中的小范围行
// 查询活动小表。MAX_EXECUTION_TIME 防止长期活动保护了大量最旧行时,为凑满一批而
// 在生产大表持续扫描;超时会让 tick 暂停且不产生 DELETE。
err := s.db.WithContext(ctx).Raw(`
SELECT /*+ MAX_EXECUTION_TIME(5000) */ archive_row.*
FROM task_center_event_archive_outbox AS archive_row
FORCE INDEX (idx_task_center_archive_claim_create)
WHERE archive_row.status = ?
AND archive_row.create_time < ?
AND archive_row.archived_at IS NOT NULL
AND archive_row.archived_at < ?
AND archive_row.occurred_at IS NOT NULL
AND archive_row.occurred_at < ?
AND archive_row.cos_key <> ''
AND (
archive_row.event_type <> ?
OR NOT EXISTS (
SELECT 1
FROM yumi_game_king_activity AS activity
WHERE activity.enabled = 1
AND activity.settlement_status <> 'COMPLETED'
AND activity.sys_origin = archive_row.sys_origin
AND archive_row.occurred_at >= activity.start_time
AND archive_row.occurred_at < activity.end_time
)
)
ORDER BY archive_row.create_time ASC, archive_row.id ASC
LIMIT ?`, ArchiveStatusUploaded, cutoff, cutoff, cutoff, EventTypeGameConsumeGold, limit).Scan(&rows).Error
return rows, err
}
func (s *Service) acquireArchiveGCLease(ctx context.Context, leaseToken string) (model.TaskCenterArchiveGCRun, bool, string, error) {
var run model.TaskCenterArchiveGCRun
claimed := false
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", archiveGCControlID).First(&run).Error; err != nil {
return archiveGCMigrationError(err)
}
if run.Status != ArchiveGCStatusRunning {
return nil
}
var databaseClock struct {
Now time.Time `gorm:"column:now_time"`
}
if err := tx.Raw("SELECT NOW(3) AS now_time").Scan(&databaseClock).Error; err != nil {
return err
}
now := databaseClock.Now
if now.IsZero() {
return fmt.Errorf("database clock returned zero time")
}
if run.LeaseUntil != nil && run.LeaseUntil.After(now) {
return nil
}
leaseUntil := now.Add(archiveGCLeaseDuration)
if err := tx.Model(&model.TaskCenterArchiveGCRun{}).Where("id = ?", archiveGCControlID).Updates(map[string]any{
"lease_owner": leaseToken, "lease_until": leaseUntil, "update_time": now,
}).Error; err != nil {
return err
}
run.LeaseOwner = leaseToken
run.LeaseUntil = &leaseUntil
claimed = true
return nil
})
if err != nil {
return run, false, "", err
}
if run.Status != ArchiveGCStatusRunning {
return run, false, "not_running", nil
}
if !claimed {
return run, false, "lease_busy", nil
}
return run, true, "", nil
}
func (s *Service) verifyArchiveGCSources(ctx context.Context, client *cos.Client, rows []model.TaskCenterEventArchiveOutbox) error {
groups := make(map[string][]model.TaskCenterEventArchiveOutbox)
for _, row := range rows {
groups[row.COSKey] = append(groups[row.COSKey], row)
}
keys := make([]string, 0, len(groups))
for key := range groups {
keys = append(keys, key)
}
sort.Strings(keys)
for _, cosKey := range keys {
// HEAD 确认对象当前版本GET+gunzip 再确认候选 raw_json 确实包含在对象内;
// 不能因为数据库已有 cos_key 就假设历史 PUT 完整成功。
head, err := headAndVerifyArchiveObject(ctx, client, cosKey, "", -1, false)
if err != nil {
return fmt.Errorf("verify source archive %s HEAD: %w", cosKey, err)
}
if head.CompressedSize > maxArchiveCOSObjectBytes {
return fmt.Errorf("source archive %s exceeds %d bytes", cosKey, maxArchiveCOSObjectBytes)
}
body, _, err := downloadArchiveObject(ctx, client, cosKey)
if err != nil {
return fmt.Errorf("verify source archive %s GET: %w", cosKey, err)
}
if int64(len(body)) != head.CompressedSize {
return fmt.Errorf("source archive %s changed between HEAD and GET", cosKey)
}
sha := archiveSHA256(body)
verifiedHead, err := headAndVerifyArchiveObject(ctx, client, cosKey, sha, int64(len(body)), false)
if err != nil {
return fmt.Errorf("verify source archive %s metadata: %w", cosKey, err)
}
lines, err := archiveObjectLines(body)
if err != nil {
return fmt.Errorf("verify source archive %s gzip: %w", cosKey, err)
}
available := make(map[string]int, len(lines))
for _, line := range lines {
available[archiveLineSHA256(line)]++
}
for _, row := range groups[cosKey] {
hash := archiveLineSHA256(row.RawJSON)
if available[hash] <= 0 {
return fmt.Errorf("source archive %s does not contain outbox row %d", cosKey, row.ID)
}
available[hash]--
}
var existing model.TaskCenterArchiveManifest
existingErr := s.db.WithContext(ctx).Where("cos_key = ?", cosKey).First(&existing).Error
if existingErr == nil && existing.SHA256 != "" && !strings.EqualFold(existing.SHA256, sha) {
return fmt.Errorf("source archive %s no longer matches its manifest", cosKey)
}
if existingErr != nil && !errors.Is(existingErr, gorm.ErrRecordNotFound) {
return existingErr
}
verifiedAt := time.Now()
if err := s.upsertArchiveManifest(ctx, model.TaskCenterArchiveManifest{
ObjectType: archiveManifestObjectArchive, COSKey: cosKey, SHA256: sha, ETag: verifiedHead.ETag,
CompressedSize: int64(len(body)), RowCount: len(lines), MinOutboxID: existing.MinOutboxID,
MaxOutboxID: existing.MaxOutboxID, MinOccurredAt: existing.MinOccurredAt,
MaxOccurredAt: existing.MaxOccurredAt, VerificationStatus: archiveManifestVerified,
VerifiedAt: &verifiedAt,
}); err != nil {
return err
}
}
return nil
}
func (s *Service) createAndVerifyArchiveGCRecovery(
ctx context.Context,
client *cos.Client,
runID string,
rows []model.TaskCenterEventArchiveOutbox,
) (string, string, error) {
body, err := gzipArchiveGCRecoveryRows(rows)
if err != nil {
return "", "", err
}
cosKey := s.buildArchiveGCRecoveryKey(runID, rows)
verified, err := putAndHeadArchiveObject(ctx, client, cosKey, body, map[string]string{
"object-type": "gc-recovery", "run-id": runID, "row-count": strconv.Itoa(len(rows)),
})
if err != nil {
return "", "", err
}
// 恢复包本身也必须 GET+gunzip 并逐行比对 ID/raw_jsonHEAD 成功不等于内容可恢复。
downloaded, _, err := downloadArchiveObject(ctx, client, cosKey)
if err != nil {
return "", "", err
}
if archiveSHA256(downloaded) != verified.SHA256 {
return "", "", fmt.Errorf("recovery object %s sha256 mismatch after upload", cosKey)
}
lines, err := archiveObjectLines(downloaded)
if err != nil {
return "", "", err
}
if len(lines) != len(rows) {
return "", "", fmt.Errorf("recovery object %s row count %d does not match %d", cosKey, len(lines), len(rows))
}
expected := make(map[int64]string, len(rows))
for _, row := range rows {
expected[row.ID] = archiveLineSHA256(row.RawJSON)
}
for _, line := range lines {
var recovered archiveGCRecoveryRow
if err := json.Unmarshal([]byte(line), &recovered); err != nil {
return "", "", fmt.Errorf("decode recovery object %s: %w", cosKey, err)
}
expectedHash, ok := expected[recovered.ID]
if !ok || expectedHash != archiveLineSHA256(recovered.RawJSON) {
return "", "", fmt.Errorf("recovery object %s has unexpected row %d", cosKey, recovered.ID)
}
delete(expected, recovered.ID)
}
if len(expected) != 0 {
return "", "", fmt.Errorf("recovery object %s is missing rows", cosKey)
}
verifiedAt := time.Now()
minID, maxID, minOccurredAt, maxOccurredAt := archiveGroupBounds(rows)
parentCOSKey := ""
if rowsShareArchiveCOSKey(rows) {
parentCOSKey = rows[0].COSKey
}
if err := s.upsertArchiveManifest(ctx, model.TaskCenterArchiveManifest{
ObjectType: archiveManifestObjectRecovery, RunID: runID, COSKey: cosKey, ParentCOSKey: parentCOSKey,
SHA256: verified.SHA256, ETag: verified.ETag, CompressedSize: verified.CompressedSize,
RowCount: len(rows), MinOutboxID: minID, MaxOutboxID: maxID, MinOccurredAt: minOccurredAt,
MaxOccurredAt: maxOccurredAt, VerificationStatus: archiveManifestVerified, VerifiedAt: &verifiedAt,
}); err != nil {
return "", "", err
}
return cosKey, verified.SHA256, nil
}
func (s *Service) deleteVerifiedArchiveGCRows(
ctx context.Context,
run model.TaskCenterArchiveGCRun,
leaseToken, recoveryKey, recoverySHA string,
candidates []model.TaskCenterEventArchiveOutbox,
) (int, int, error) {
if run.CutoffTime == nil {
return 0, 0, NewAppError(http.StatusConflict, "archive_gc_cutoff_missing", "archive GC cutoff is missing")
}
candidateByID := make(map[int64]model.TaskCenterEventArchiveOutbox, len(candidates))
ids := make([]int64, 0, len(candidates))
for _, row := range candidates {
candidateByID[row.ID] = row
ids = append(ids, row.ID)
}
deleted, protected := 0, 0
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var lockedRun model.TaskCenterArchiveGCRun
// 与 Game King 启用门禁锁同一行。GC 在这把锁下复核活动窗口并删行,启用请求
// 要么先完成并被本事务看到,要么等待删除提交后发现 GC 仍为 RUNNING 而失败。
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", archiveGCControlID).First(&lockedRun).Error; err != nil {
return archiveGCMigrationError(err)
}
if lockedRun.RunID != run.RunID || lockedRun.Status != ArchiveGCStatusRunning || lockedRun.LeaseOwner != leaseToken {
return NewAppError(http.StatusConflict, "archive_gc_lease_lost", "archive GC lease or state changed before delete")
}
var recoveryManifest model.TaskCenterArchiveManifest
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where(
"cos_key = ? AND run_id = ? AND object_type = ? AND verification_status = ? AND sha256 = ?",
recoveryKey, run.RunID, archiveManifestObjectRecovery, archiveManifestVerified, recoverySHA,
).First(&recoveryManifest).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return NewAppError(http.StatusConflict, "archive_gc_recovery_manifest_missing", "verified recovery manifest changed or is missing before delete")
}
return err
}
if recoveryManifest.RowCount != len(candidates) || recoveryManifest.VerifiedAt == nil {
return NewAppError(http.StatusConflict, "archive_gc_recovery_manifest_invalid", "recovery manifest row count or verification time is invalid")
}
var lockedRows []model.TaskCenterEventArchiveOutbox
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ?", ids).Order("create_time ASC, id ASC").Find(&lockedRows).Error; err != nil {
return err
}
var protectedActivities []model.YumiGameKingActivity
if err := tx.Where("enabled = ? AND settlement_status <> ?", true, "COMPLETED").Find(&protectedActivities).Error; err != nil {
return err
}
deleteRows := make([]model.TaskCenterEventArchiveOutbox, 0, len(lockedRows))
for _, row := range lockedRows {
original, exists := candidateByID[row.ID]
if !exists || row.Status != ArchiveStatusUploaded || row.COSKey == "" || row.COSKey != original.COSKey ||
archiveLineSHA256(row.RawJSON) != archiveLineSHA256(original.RawJSON) || row.ArchivedAt == nil ||
!row.CreateTime.Before(*run.CutoffTime) || !row.ArchivedAt.Before(*run.CutoffTime) ||
row.OccurredAt.IsZero() || !row.OccurredAt.Before(*run.CutoffTime) || archiveGCActivityProtects(row, protectedActivities) {
protected++
continue
}
deleteRows = append(deleteRows, row)
}
if missing := len(candidates) - len(lockedRows); missing > 0 {
// 另一个写路径删除/改动候选属于异常状态;恢复包仍完整,但本次只统计为保护跳过。
protected += missing
}
deleteIDs := archiveRowIDs(deleteRows)
if len(deleteIDs) > 0 {
result := tx.Where("id IN ? AND status = ?", deleteIDs, ArchiveStatusUploaded).Delete(&model.TaskCenterEventArchiveOutbox{})
if result.Error != nil {
return result.Error
}
deleted = int(result.RowsAffected)
if deleted != len(deleteIDs) {
return fmt.Errorf("archive GC deleted %d rows, expected %d", deleted, len(deleteIDs))
}
}
now := time.Now()
updates := map[string]any{
"scanned_rows": gorm.Expr("scanned_rows + ?", len(candidates)),
"eligible_rows": gorm.Expr("eligible_rows + ?", len(deleteRows)),
"protected_rows": gorm.Expr("protected_rows + ?", protected),
"deleted_rows": gorm.Expr("deleted_rows + ?", deleted),
"recovery_object_count": gorm.Expr("recovery_object_count + 1"),
"lease_owner": "", "lease_until": nil, "last_error": "", "update_time": now,
}
if deleted > 0 {
last := deleteRows[len(deleteRows)-1]
updates["cursor_create_time"] = last.CreateTime
updates["cursor_id"] = last.ID
}
if err := tx.Model(&model.TaskCenterArchiveGCRun{}).Where("id = ?", archiveGCControlID).Updates(updates).Error; err != nil {
return err
}
return nil
})
return deleted, protected, err
}
func (s *Service) completeArchiveGCTick(ctx context.Context, runID, leaseToken string) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var locked model.TaskCenterArchiveGCRun
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", archiveGCControlID).First(&locked).Error; err != nil {
return archiveGCMigrationError(err)
}
if locked.RunID != runID || locked.Status != ArchiveGCStatusRunning || locked.LeaseOwner != leaseToken {
return NewAppError(http.StatusConflict, "archive_gc_lease_lost", "archive GC lease or state changed before completion")
}
return tx.Model(&model.TaskCenterArchiveGCRun{}).Where("id = ?", archiveGCControlID).Updates(map[string]any{
"status": ArchiveGCStatusCompleted, "lease_owner": "", "lease_until": nil,
"last_error": "", "update_time": time.Now(),
}).Error
})
}
func (s *Service) pauseArchiveGCTick(runID, leaseToken string, workErr error) {
// HTTP/cron 超时往往已取消传入 ctx失败状态必须用独立短上下文落库否则控制行会
// 继续显示 RUNNING直到租约过期后再次触发同一批 COS 工作。
failureCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
message := "archive GC tick failed"
if workErr != nil {
message = truncateTaskCenterFailure(workErr.Error())
}
_ = s.db.WithContext(failureCtx).Model(&model.TaskCenterArchiveGCRun{}).
Where("id = ? AND run_id = ? AND status = ? AND lease_owner = ?", archiveGCControlID, runID, ArchiveGCStatusRunning, leaseToken).
Updates(map[string]any{
"status": ArchiveGCStatusPaused, "lease_owner": "", "lease_until": nil,
"last_error": message, "update_time": time.Now(),
}).Error
}
func gzipArchiveGCRecoveryRows(rows []model.TaskCenterEventArchiveOutbox) ([]byte, error) {
var buffer bytes.Buffer
writer := gzip.NewWriter(&buffer)
encoder := json.NewEncoder(writer)
for _, row := range rows {
if err := encoder.Encode(archiveGCRecoveryRow{
ID: row.ID, SysOrigin: row.SysOrigin, EventID: row.EventID, EventType: row.EventType,
UserID: row.UserID, OccurredAt: row.OccurredAt, RawJSON: row.RawJSON, Status: row.Status,
RetryCount: row.RetryCount, COSKey: row.COSKey, FailureReason: row.FailureReason,
CreateTime: row.CreateTime, UpdateTime: row.UpdateTime, ArchivedAt: row.ArchivedAt,
}); err != nil {
_ = writer.Close()
return nil, err
}
}
if err := writer.Close(); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func (s *Service) buildArchiveGCRecoveryKey(runID string, rows []model.TaskCenterEventArchiveOutbox) string {
prefix := strings.Trim(strings.TrimSpace(s.cfg.TaskCenter.Archive.Prefix), "/")
if prefix == "" {
prefix = "task-center-events/v1"
}
return path.Join(prefix, "gc-recovery", "run="+sanitizeArchivePartition(runID), fmt.Sprintf(
"part-%s-%d-%d.jsonl.gz", time.Now().UTC().Format("20060102T150405.000000000Z"), rows[0].ID, len(rows),
))
}
func archiveGCActivityProtects(row model.TaskCenterEventArchiveOutbox, activities []model.YumiGameKingActivity) bool {
if row.EventType != EventTypeGameConsumeGold {
return false
}
for _, activity := range activities {
if activity.SysOrigin == row.SysOrigin && !row.OccurredAt.Before(activity.StartTime) && row.OccurredAt.Before(activity.EndTime) {
return true
}
}
return false
}
func limitArchiveGCSourceObjects(rows []model.TaskCenterEventArchiveOutbox, maximum int) []model.TaskCenterEventArchiveOutbox {
if maximum <= 0 {
return nil
}
seen := make(map[string]struct{}, maximum)
for index, row := range rows {
if _, exists := seen[row.COSKey]; exists {
continue
}
if len(seen) == maximum {
return rows[:index]
}
seen[row.COSKey] = struct{}{}
}
return rows
}
func rowsShareArchiveCOSKey(rows []model.TaskCenterEventArchiveOutbox) bool {
if len(rows) == 0 {
return false
}
key := rows[0].COSKey
for _, row := range rows[1:] {
if row.COSKey != key {
return false
}
}
return true
}
func buildArchiveGCWorkerIdentity() string {
hostname, _ := os.Hostname()
if strings.TrimSpace(hostname) == "" {
hostname = "unknown-host"
}
return fmt.Sprintf("%s:%d", hostname, os.Getpid())
}

View File

@ -1,74 +0,0 @@
package taskcenter
import (
"context"
"time"
"chatapp3-golang/internal/model"
"gorm.io/gorm/clause"
)
const (
archiveManifestObjectArchive = "ARCHIVE"
archiveManifestObjectRecovery = "GC_RECOVERY"
archiveManifestVerified = "VERIFIED"
)
// upsertArchiveManifest 以 COS key 为不可变对象身份。重验历史对象时只刷新校验结果,
// 不创建重复清单GC 删除前可据此确认看到的是同一个对象版本和摘要。
func (s *Service) upsertArchiveManifest(ctx context.Context, row model.TaskCenterArchiveManifest) error {
now := time.Now()
if row.CreateTime.IsZero() {
row.CreateTime = now
}
row.UpdateTime = now
return s.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "cos_key"}},
DoUpdates: clause.Assignments(map[string]any{
"object_type": row.ObjectType,
"run_id": row.RunID,
"parent_cos_key": row.ParentCOSKey,
"sha256": row.SHA256,
"etag": row.ETag,
"compressed_size": row.CompressedSize,
"row_count": row.RowCount,
"min_outbox_id": row.MinOutboxID,
"max_outbox_id": row.MaxOutboxID,
"min_occurred_at": row.MinOccurredAt,
"max_occurred_at": row.MaxOccurredAt,
"verification_status": row.VerificationStatus,
"verified_at": row.VerifiedAt,
"update_time": row.UpdateTime,
}),
}).Create(&row).Error
}
func archiveGroupBounds(rows []model.TaskCenterEventArchiveOutbox) (*int64, *int64, *time.Time, *time.Time) {
if len(rows) == 0 {
return nil, nil, nil, nil
}
minID, maxID := rows[0].ID, rows[0].ID
var minOccurredAt, maxOccurredAt *time.Time
for _, row := range rows {
if row.ID < minID {
minID = row.ID
}
if row.ID > maxID {
maxID = row.ID
}
if row.OccurredAt.IsZero() {
continue
}
value := row.OccurredAt
if minOccurredAt == nil || value.Before(*minOccurredAt) {
copyValue := value
minOccurredAt = &copyValue
}
if maxOccurredAt == nil || value.After(*maxOccurredAt) {
copyValue := value
maxOccurredAt = &copyValue
}
}
return &minID, &maxID, minOccurredAt, maxOccurredAt
}

View File

@ -1,180 +0,0 @@
package taskcenter
import (
"bytes"
"compress/gzip"
"context"
"crypto/md5"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/tencentyun/cos-go-sdk-v5"
)
const (
maxArchiveCOSObjectBytes = 128 << 20
maxArchiveCOSUncompressedBytes = 256 << 20
)
type verifiedArchiveObject struct {
COSKey string
SHA256 string
ETag string
CompressedSize int64
Header http.Header
}
// putAndHeadArchiveObject 将本地摘要同时写入 COS metadata并在 PUT 后重新 HEAD。
// 只有服务端返回的长度和摘要都与本地一致才继续写 manifest/outbox防止网关成功响应、
// 错误对象 key 或被并发覆盖造成“数据库显示已归档但对象不可恢复”。
func putAndHeadArchiveObject(
ctx context.Context,
client *cos.Client,
cosKey string,
body []byte,
metadata map[string]string,
) (verifiedArchiveObject, error) {
sha := archiveSHA256(body)
headers := make(http.Header)
headers.Set("x-cos-meta-sha256", sha)
for key, value := range metadata {
headers.Set("x-cos-meta-"+strings.ToLower(strings.TrimSpace(key)), strings.TrimSpace(value))
}
response, err := client.Object.Put(ctx, cosKey, bytes.NewReader(body), &cos.ObjectPutOptions{
ObjectPutHeaderOptions: &cos.ObjectPutHeaderOptions{
ContentType: "application/gzip",
ContentMD5: archiveContentMD5(body),
ContentLength: int64(len(body)),
XCosMetaXXX: &headers,
},
})
if response != nil && response.Body != nil {
_ = response.Body.Close()
}
if err != nil {
return verifiedArchiveObject{}, err
}
verified, err := headAndVerifyArchiveObject(ctx, client, cosKey, sha, int64(len(body)), true)
if err != nil {
return verifiedArchiveObject{}, err
}
for key, expected := range metadata {
headerName := "x-cos-meta-" + strings.ToLower(strings.TrimSpace(key))
if actual := strings.TrimSpace(verified.Header.Get(headerName)); actual != strings.TrimSpace(expected) {
return verifiedArchiveObject{}, fmt.Errorf("COS HEAD %s metadata %s does not match", cosKey, headerName)
}
}
return verified, nil
}
func headAndVerifyArchiveObject(
ctx context.Context,
client *cos.Client,
cosKey, expectedSHA string,
expectedSize int64,
requireSHAMetadata bool,
) (verifiedArchiveObject, error) {
response, err := client.Object.Head(ctx, cosKey, nil)
if response != nil && response.Body != nil {
defer response.Body.Close()
}
if err != nil {
return verifiedArchiveObject{}, err
}
if response == nil || response.Response == nil {
return verifiedArchiveObject{}, fmt.Errorf("COS HEAD %s returned no response", cosKey)
}
size, err := strconv.ParseInt(strings.TrimSpace(response.Header.Get("Content-Length")), 10, 64)
if err != nil || size < 0 {
return verifiedArchiveObject{}, fmt.Errorf("COS HEAD %s has invalid content length", cosKey)
}
if expectedSize >= 0 && size != expectedSize {
return verifiedArchiveObject{}, fmt.Errorf("COS HEAD %s size %d does not match %d", cosKey, size, expectedSize)
}
remoteSHA := strings.TrimSpace(response.Header.Get("x-cos-meta-sha256"))
if requireSHAMetadata && remoteSHA == "" {
return verifiedArchiveObject{}, fmt.Errorf("COS HEAD %s is missing required sha256 metadata", cosKey)
}
if remoteSHA != "" && expectedSHA != "" && !strings.EqualFold(remoteSHA, expectedSHA) {
return verifiedArchiveObject{}, fmt.Errorf("COS HEAD %s sha256 metadata mismatch", cosKey)
}
return verifiedArchiveObject{
COSKey: cosKey,
SHA256: expectedSHA,
ETag: strings.Trim(strings.TrimSpace(response.Header.Get("ETag")), `"`),
CompressedSize: size,
Header: response.Header.Clone(),
}, nil
}
// downloadArchiveObject 强制 identity 传输并限制压缩对象大小。清理路径必须拿到对象原始
// gzip 字节才能同时验证 SHA256 和 JSONL不能只相信 HEAD/ETag。
func downloadArchiveObject(ctx context.Context, client *cos.Client, cosKey string) ([]byte, http.Header, error) {
headers := make(http.Header)
headers.Set("Accept-Encoding", "identity")
response, err := client.Object.Get(ctx, cosKey, &cos.ObjectGetOptions{XOptionHeader: &headers})
if err != nil {
return nil, nil, err
}
if response == nil || response.Response == nil || response.Body == nil {
return nil, nil, fmt.Errorf("COS GET %s returned no body", cosKey)
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, maxArchiveCOSObjectBytes+1))
if err != nil {
return nil, response.Header, err
}
if len(body) > maxArchiveCOSObjectBytes {
return nil, response.Header, fmt.Errorf("COS object %s exceeds %d bytes", cosKey, maxArchiveCOSObjectBytes)
}
return body, response.Header, nil
}
// archiveObjectLines 解压并返回规范化 JSONL 行。历史对象与新对象都由同一个编码器写入;
// 空行被忽略,但单行上限和总解压大小都有硬门槛,避免损坏对象造成内存放大。
func archiveObjectLines(body []byte) ([]string, error) {
if len(body) < 2 || body[0] != 0x1f || body[1] != 0x8b {
return nil, fmt.Errorf("archive object is not gzip encoded")
}
reader, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
return nil, err
}
defer reader.Close()
uncompressed, err := io.ReadAll(io.LimitReader(reader, maxArchiveCOSUncompressedBytes+1))
if err != nil {
return nil, err
}
if len(uncompressed) > maxArchiveCOSUncompressedBytes {
return nil, fmt.Errorf("archive object expands beyond %d bytes", maxArchiveCOSUncompressedBytes)
}
parts := bytes.Split(uncompressed, []byte{'\n'})
lines := make([]string, 0, len(parts))
for _, part := range parts {
line := strings.TrimSpace(string(part))
if line != "" {
lines = append(lines, line)
}
}
return lines, nil
}
func archiveSHA256(value []byte) string {
sum := sha256.Sum256(value)
return hex.EncodeToString(sum[:])
}
func archiveContentMD5(value []byte) string {
sum := md5.Sum(value)
return base64.StdEncoding.EncodeToString(sum[:])
}
func archiveLineSHA256(value string) string {
return archiveSHA256([]byte(strings.TrimSpace(value)))
}

View File

@ -67,22 +67,6 @@ func (s *Service) ProcessEvent(ctx context.Context, req EventRequest) (*EventRes
} }
occurredAt := parseOccurredAt(req.OccurredAt, time.Now()) occurredAt := parseOccurredAt(req.OccurredAt, time.Now())
receivedAt := 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, "", "") rawJSON := buildArchiveRawJSON(req, sysOrigin, eventID, eventType, userID, req.DeltaValue, occurredAt, receivedAt, "", "")
bundle, err := s.loadEnabledBundle(ctx, sysOrigin) 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 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) { func TestDailyTaskConfigEventListAndClaim(t *testing.T) {
java := &taskCenterTestGateway{events: map[string]bool{}} java := &taskCenterTestGateway{events: map[string]bool{}}
service, db := newTestService(t, java) service, db := newTestService(t, java)

View File

@ -62,24 +62,6 @@ type taskCenterJavaGateway interface {
ExistsGoldEvent(ctx context.Context, eventID string) (bool, error) 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 负责任务配置、进度、领取和事件入库。 // Service 负责任务配置、进度、领取和事件入库。
type Service struct { type Service struct {
cfg config.Config cfg config.Config
@ -87,7 +69,6 @@ type Service struct {
java taskCenterJavaGateway java taskCenterJavaGateway
location *time.Location location *time.Location
rocketConsumer rmq.PushConsumer rocketConsumer rmq.PushConsumer
gameConsumeSink GameConsumeSink
} }
func NewService(cfg config.Config, db taskCenterDB, java taskCenterJavaGateway) *Service { 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} return &Service{cfg: cfg, db: db, java: java, location: location}
} }
// SetGameConsumeSink 把固定游戏消耗事件扇出给活动模块。
func (s *Service) SetGameConsumeSink(sink GameConsumeSink) {
if s != nil {
s.gameConsumeSink = sink
}
}
// TaskConfigPayload 是后台配置页的任务读模型。 // TaskConfigPayload 是后台配置页的任务读模型。
type TaskConfigPayload struct { type TaskConfigPayload struct {
ID int64 `json:"id,string"` ID int64 `json:"id,string"`

View File

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

View File

@ -422,7 +422,7 @@ type StatusResponse struct {
CurrentEnergy int64 `json:"currentEnergy"` CurrentEnergy int64 `json:"currentEnergy"`
NeedEnergy int64 `json:"needEnergy"` NeedEnergy int64 `json:"needEnergy"`
EnergyPercent string `json:"energyPercent"` EnergyPercent string `json:"energyPercent"`
DisplayPercent float64 `json:"displayPercent"` DisplayPercent int `json:"displayPercent"`
LaunchCountdownSeconds int `json:"launchCountdownSeconds,omitempty"` LaunchCountdownSeconds int `json:"launchCountdownSeconds,omitempty"`
Shake bool `json:"shake"` Shake bool `json:"shake"`
PreviewRocketURL string `json:"previewRocketUrl,omitempty"` PreviewRocketURL string `json:"previewRocketUrl,omitempty"`

View File

@ -132,8 +132,6 @@ func (s *Service) PageAdminRecords(
Size: limit, Size: limit,
TotalPaidGold: summary.TotalPaidGold, TotalPaidGold: summary.TotalPaidGold,
TotalRewardGold: summary.TotalRewardGold, TotalRewardGold: summary.TotalRewardGold,
TotalGiftRewardGold: summary.TotalGiftRewardGold,
TotalDecorationRewardGold: summary.TotalDecorationRewardGold,
TotalDrawTimes: summary.TotalDrawTimes, TotalDrawTimes: summary.TotalDrawTimes,
ParticipantCount: summary.ParticipantCount, ParticipantCount: summary.ParticipantCount,
StartTime: formatDateTime(filter.StartTime), StartTime: formatDateTime(filter.StartTime),
@ -254,8 +252,6 @@ type wheelAdminRecordFilter struct {
type wheelAdminRecordSummary struct { type wheelAdminRecordSummary struct {
TotalPaidGold int64 TotalPaidGold int64
TotalRewardGold int64 TotalRewardGold int64
TotalGiftRewardGold int64
TotalDecorationRewardGold int64
TotalDrawTimes int64 TotalDrawTimes int64
ParticipantCount int64 ParticipantCount int64
} }
@ -352,16 +348,9 @@ func (s *Service) adminRecordQuery(ctx context.Context, filter wheelAdminRecordF
func (s *Service) adminRecordSummary(ctx context.Context, filter wheelAdminRecordFilter) (wheelAdminRecordSummary, error) { func (s *Service) adminRecordSummary(ctx context.Context, filter wheelAdminRecordFilter) (wheelAdminRecordSummary, error) {
var summary wheelAdminRecordSummary var summary wheelAdminRecordSummary
// totalRewardGold keeps the historical dashboard number, including direct gold rewards.
// The two split fields only count RESOURCE rewards: GIFT is the gift value, every other
// resourceType is treated as decoration value for the admin cards.
if err := s.adminRecordQuery(ctx, filter). if err := s.adminRecordQuery(ctx, filter).
Select(` Select("COALESCE(SUM(CASE WHEN status = ? THEN CASE WHEN reward_type = ? THEN gold_amount ELSE display_gold_amount END ELSE 0 END), 0)", drawStatusSuccess, rewardTypeGold).
COALESCE(SUM(CASE WHEN status = ? THEN CASE WHEN reward_type = ? THEN gold_amount ELSE display_gold_amount END ELSE 0 END), 0) AS total_reward_gold, Scan(&summary.TotalRewardGold).Error; err != nil {
COALESCE(SUM(CASE WHEN status = ? AND reward_type = ? AND UPPER(resource_type) = ? THEN display_gold_amount ELSE 0 END), 0) AS total_gift_reward_gold,
COALESCE(SUM(CASE WHEN status = ? AND reward_type = ? AND UPPER(resource_type) <> ? THEN display_gold_amount ELSE 0 END), 0) AS total_decoration_reward_gold
`, drawStatusSuccess, rewardTypeGold, drawStatusSuccess, rewardTypeResource, resourceTypeGift, drawStatusSuccess, rewardTypeResource, resourceTypeGift).
Scan(&summary).Error; err != nil {
return summary, err return summary, err
} }
if err := s.adminRecordQuery(ctx, filter). if err := s.adminRecordQuery(ctx, filter).

View File

@ -104,8 +104,8 @@ func TestPageAdminRecordsIncludesUserInfoAndRangeSummary(t *testing.T) {
PaidGold: 200, PaidGold: 200,
RewardType: rewardTypeResource, RewardType: rewardTypeResource,
ResourceID: &giftID, ResourceID: &giftID,
ResourceType: "AVATAR_FRAME", ResourceType: resourceTypeGift,
ResourceName: "Frame B", ResourceName: "Gift B",
DisplayGoldAmount: 30, DisplayGoldAmount: 30,
Probability: 500000, Probability: 500000,
Status: drawStatusSuccess, Status: drawStatusSuccess,
@ -149,12 +149,6 @@ func TestPageAdminRecordsIncludesUserInfoAndRangeSummary(t *testing.T) {
if resp.TotalRewardGold != 150 { if resp.TotalRewardGold != 150 {
t.Fatalf("total reward = %d, want 150", resp.TotalRewardGold) t.Fatalf("total reward = %d, want 150", resp.TotalRewardGold)
} }
if resp.TotalGiftRewardGold != 50 {
t.Fatalf("total gift reward = %d, want 50", resp.TotalGiftRewardGold)
}
if resp.TotalDecorationRewardGold != 30 {
t.Fatalf("total decoration reward = %d, want 30", resp.TotalDecorationRewardGold)
}
if resp.TotalDrawTimes != 12 { if resp.TotalDrawTimes != 12 {
t.Fatalf("total draw times = %d, want 12", resp.TotalDrawTimes) t.Fatalf("total draw times = %d, want 12", resp.TotalDrawTimes)
} }

View File

@ -313,8 +313,6 @@ type RecordPageResponse struct {
Size int `json:"size"` Size int `json:"size"`
TotalPaidGold int64 `json:"totalPaidGold,omitempty"` TotalPaidGold int64 `json:"totalPaidGold,omitempty"`
TotalRewardGold int64 `json:"totalRewardGold,omitempty"` TotalRewardGold int64 `json:"totalRewardGold,omitempty"`
TotalGiftRewardGold int64 `json:"totalGiftRewardGold,omitempty"`
TotalDecorationRewardGold int64 `json:"totalDecorationRewardGold,omitempty"`
TotalDrawTimes int64 `json:"totalDrawTimes,omitempty"` TotalDrawTimes int64 `json:"totalDrawTimes,omitempty"`
ParticipantCount int64 `json:"participantCount,omitempty"` ParticipantCount int64 `json:"participantCount,omitempty"`
StartTime string `json:"startTime,omitempty"` StartTime string `json:"startTime,omitempty"`

View File

@ -1,274 +0,0 @@
package yumigiftchallenge
import (
"context"
"errors"
"net/http"
"sort"
"strings"
"time"
"chatapp3-golang/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (s *Service) normalizeSysOrigin(value string) string {
value = strings.ToUpper(strings.TrimSpace(value))
if value == "" {
value = strings.ToUpper(strings.TrimSpace(s.cfg.YumiGiftChallenge.DefaultSysOrigin))
}
if value == "" {
value = defaultSysOrigin
}
return value
}
func (s *Service) requireYumiOrigin(value string) (string, error) {
origin := s.normalizeSysOrigin(value)
// Yumi 是产品品牌,真实 Java SysOriginPlatformEnum 为 LIKEI。显式拒绝其他来源
// 防止后台误建一个永远收不到 Yumi 送礼事件的孤立活动。
if origin != defaultSysOrigin {
return "", NewAppError(http.StatusBadRequest, "invalid_sys_origin", "Yumi gift challenge sysOrigin must be LIKEI")
}
return origin, nil
}
func resolveLocation(value 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", err.Error())
}
return location, nil
}
func dateOnly(value time.Time, location *time.Location) time.Time {
local := value.In(location)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
}
func dateKey(value time.Time, location *time.Location) string {
return value.In(location).Format("2006-01-02")
}
func parseDateKey(value string, location *time.Location) (time.Time, error) {
parsed, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(value), location)
if err != nil {
return time.Time{}, NewAppError(http.StatusBadRequest, "invalid_stat_date", "statDate must be yyyy-MM-dd")
}
return parsed, nil
}
func activityStatus(activity model.YumiGiftChallengeActivity, now time.Time) string {
if !activity.Enabled {
return "DISABLED"
}
if now.Before(activity.StartTime) {
return "NOT_STARTED"
}
if !now.Before(activity.EndTime) {
return "ENDED"
}
return "ONGOING"
}
func (s *Service) loadActivity(ctx context.Context, id int64) (*model.YumiGiftChallengeActivity, error) {
if id <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_activity_id", "activityId is required")
}
var row model.YumiGiftChallengeActivity
err := s.db.WithContext(ctx).Where("id = ?", id).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, NewAppError(http.StatusNotFound, "activity_not_found", "activity not found")
}
if err != nil {
return nil, err
}
return &row, nil
}
func (s *Service) selectActivity(ctx context.Context, origin string, id int64, enabledOnly bool) (*model.YumiGiftChallengeActivity, error) {
if id > 0 {
row, err := s.loadActivity(ctx, id)
if err != nil {
return nil, err
}
if row.SysOrigin != origin || (enabledOnly && !row.Enabled) {
return nil, NewAppError(http.StatusNotFound, "activity_not_found", "activity not found")
}
return row, nil
}
now := time.Now()
query := s.db.WithContext(ctx).Model(&model.YumiGiftChallengeActivity{}).
Where("sys_origin = ?", origin)
if enabledOnly {
query = query.Where("enabled = ?", true)
}
// 进行中优先其次最近待开始最后最近结束CASE 只在单一来源的少量活动配置上执行,
// 热榜和送礼链路不会走该排序。
var row model.YumiGiftChallengeActivity
err := query.Order(clause.Expr{SQL: "CASE WHEN start_time <= ? AND end_time > ? THEN 0 WHEN start_time > ? THEN 1 ELSE 2 END", Vars: []any{now, now, now}}).
Order(clause.Expr{SQL: "CASE WHEN start_time > ? THEN start_time END ASC", Vars: []any{now}}).
Order("end_time DESC").First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, NewAppError(http.StatusNotFound, "activity_not_found", "activity not found")
}
if err != nil {
return nil, err
}
return &row, nil
}
func (s *Service) loadActivityChildren(ctx context.Context, activityID int64) ([]model.YumiGiftChallengeTaskConfig, []model.YumiGiftChallengeRankReward, error) {
var tasks []model.YumiGiftChallengeTaskConfig
if err := s.db.WithContext(ctx).Where("activity_id = ?", activityID).Order("sort_order ASC, id ASC").Find(&tasks).Error; err != nil {
return nil, nil, err
}
var rewards []model.YumiGiftChallengeRankReward
if err := s.db.WithContext(ctx).Where("activity_id = ?", activityID).
Order("period_type ASC, start_rank ASC, id ASC").Find(&rewards).Error; err != nil {
return nil, nil, err
}
return tasks, rewards, nil
}
func (s *Service) loadRewardSnapshots(ctx context.Context, activityID int64) (map[int64][]model.YumiGiftChallengeRewardSnapshot, error) {
var rows []model.YumiGiftChallengeRewardSnapshot
if err := s.db.WithContext(ctx).Where("activity_id = ?", activityID).
Order("resource_group_id ASC, sort_order ASC, reward_config_id ASC").Find(&rows).Error; err != nil {
return nil, err
}
result := make(map[int64][]model.YumiGiftChallengeRewardSnapshot)
for _, row := range rows {
result[row.ResourceGroupID] = append(result[row.ResourceGroupID], row)
}
return result, nil
}
func rewardItemViews(rows []model.YumiGiftChallengeRewardSnapshot) []RewardItemView {
items := make([]RewardItemView, 0, len(rows))
for _, row := range rows {
items = append(items, RewardItemView{
ID: row.RewardConfigID, Type: row.RewardType, DetailType: row.DetailType,
Content: row.Content, Quantity: row.Quantity, Cover: row.Cover,
SourceURL: row.SourceURL, Name: row.DisplayName, Sort: row.SortOrder,
})
}
return items
}
func buildActivityView(row model.YumiGiftChallengeActivity) ActivityView {
return ActivityView{
ID: row.ID, ActivityCode: row.ActivityCode, ActivityName: row.ActivityName,
ActivityDesc: row.ActivityDesc, SysOrigin: row.SysOrigin, Timezone: row.Timezone,
StartTime: row.StartTime.UnixMilli(), EndTime: row.EndTime.UnixMilli(),
DailySettlementDelayMinutes: row.DailySettlementDelayMinutes,
OverallSettlementDelayMinutes: row.OverallSettlementDelayMinutes,
OverallSettlementTime: row.OverallSettlementTime.UnixMilli(), DisplayTopN: row.DisplayTopN,
Enabled: row.Enabled, OverallSettlementStatus: row.OverallSettlementStatus,
Version: row.Version, CreateTime: row.CreateTime.UnixMilli(), UpdateTime: row.UpdateTime.UnixMilli(),
}
}
func (s *Service) buildDetail(ctx context.Context, activity model.YumiGiftChallengeActivity) (*DetailResponse, error) {
tasks, rewards, err := s.loadActivityChildren(ctx, activity.ID)
if err != nil {
return nil, err
}
snapshots, err := s.loadRewardSnapshots(ctx, activity.ID)
if err != nil {
return nil, err
}
taskViews := make([]TaskView, 0, len(tasks))
for _, row := range tasks {
view := TaskView{
ID: row.ID, ActivityID: row.ActivityID, TaskCode: row.TaskCode, TaskType: row.TaskType,
TaskTitle: row.TaskTitle, TaskDesc: row.TaskDesc, TargetValue: row.TargetValue,
ResourceGroupID: row.ResourceGroupID, Enabled: row.Enabled, SortOrder: row.SortOrder,
RewardItems: []RewardItemView{},
}
if row.ResourceGroupID != nil {
view.RewardItems = rewardItemViews(snapshots[*row.ResourceGroupID])
}
taskViews = append(taskViews, view)
}
rewardViews := make([]RankRewardView, 0, len(rewards))
for _, row := range rewards {
rewardViews = append(rewardViews, RankRewardView{
ID: row.ID, ActivityID: row.ActivityID, PeriodType: row.PeriodType,
StartRank: row.StartRank, EndRank: row.EndRank, ResourceGroupID: row.ResourceGroupID,
RewardName: row.RewardName, RewardItems: rewardItemViews(snapshots[row.ResourceGroupID]),
})
}
return &DetailResponse{
Activity: buildActivityView(activity), Tasks: taskViews, RankRewards: rewardViews,
SupportedPeriods: []string{PeriodDaily, PeriodOverall},
SupportedTaskTypes: []string{TaskEnterPage, TaskSendGiftGold},
ActivityStatus: activityStatus(activity, time.Now()), ServerTime: time.Now().UnixMilli(),
}, nil
}
// GetDetail 返回 Yumi H5 活动详情,用户身份只用于来源隔离,不接受客户端伪造 userId。
func (s *Service) GetDetail(ctx context.Context, user AuthUser, activityID int64) (*DetailResponse, error) {
origin, err := s.requireYumiOrigin(user.SysOrigin)
if err != nil {
return nil, err
}
activity, err := s.selectActivity(ctx, origin, activityID, true)
if err != nil {
return nil, err
}
return s.buildDetail(ctx, *activity)
}
// GetAdminDetail 返回后台完整配置详情。
func (s *Service) GetAdminDetail(ctx context.Context, activityID int64) (*DetailResponse, error) {
activity, err := s.loadActivity(ctx, activityID)
if err != nil {
return nil, err
}
return s.buildDetail(ctx, *activity)
}
// ListActivities 分页读取配置;查询始终命中 sys_origin+时间索引,不扫描送礼明细。
func (s *Service) ListActivities(ctx context.Context, origin string, page, size int) (map[string]any, error) {
origin, err := s.requireYumiOrigin(origin)
if err != nil {
return nil, err
}
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
query := s.db.WithContext(ctx).Model(&model.YumiGiftChallengeActivity{}).Where("sys_origin = ?", origin)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, err
}
var rows []model.YumiGiftChallengeActivity
if err := query.Order("start_time DESC, id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
return nil, err
}
records := make([]ActivityView, 0, len(rows))
for _, row := range rows {
records = append(records, buildActivityView(row))
}
return map[string]any{"records": records, "total": total, "current": page, "size": size}, nil
}
func sortTaskRows(rows []model.YumiGiftChallengeTaskConfig) {
sort.Slice(rows, func(i, j int) bool {
if rows[i].SortOrder == rows[j].SortOrder {
return rows[i].ID < rows[j].ID
}
return rows[i].SortOrder < rows[j].SortOrder
})
}

View File

@ -1,800 +0,0 @@
package yumigiftchallenge
import (
"context"
"errors"
"fmt"
"net/http"
"regexp"
"sort"
"strings"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var taskCodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
// SaveActivity 保存完整配置;活动开始后只允许修改名称和描述,不触碰已生效统计口径。
func (s *Service) SaveActivity(ctx context.Context, req SaveRequest) (*DetailResponse, error) {
activityID := req.ID.Int64()
if activityID > 0 {
current, err := s.loadActivity(ctx, activityID)
if err != nil {
return nil, err
}
if !time.Now().Before(current.StartTime) {
return s.saveStartedActivityMetadata(ctx, *current, req)
}
}
origin, err := s.requireYumiOrigin(req.SysOrigin)
if err != nil {
return nil, err
}
location, startAt, endAt, err := validateActivityInput(req)
if err != nil {
return nil, err
}
tasks, err := normalizeTaskInputs(req.Tasks)
if err != nil {
return nil, err
}
rewards, err := normalizeRankRewardInputs(req.RankRewards, req.DisplayTopN)
if err != nil {
return nil, err
}
if activityID <= 0 {
activityID, err = utils.NextID()
if err != nil {
return nil, err
}
}
now := time.Now()
proposed := model.YumiGiftChallengeActivity{
ID: activityID, ActivityCode: strings.TrimSpace(req.ActivityCode), ActivityName: strings.TrimSpace(req.ActivityName),
ActivityDesc: strings.TrimSpace(req.ActivityDesc), SysOrigin: origin, Timezone: location.String(),
StartTime: startAt, EndTime: endAt,
DailySettlementDelayMinutes: req.DailySettlementDelayMinutes,
OverallSettlementDelayMinutes: req.OverallSettlementDelayMinutes,
OverallSettlementTime: endAt.Add(time.Duration(req.OverallSettlementDelayMinutes) * time.Minute),
DisplayTopN: req.DisplayTopN, Enabled: req.Enabled, OverallSettlementStatus: StatusNotStarted,
CreateTime: now, UpdateTime: now,
}
for i := range tasks {
tasks[i].ActivityID, tasks[i].CreateTime, tasks[i].UpdateTime = activityID, now, now
}
for i := range rewards {
rewards[i].ActivityID, rewards[i].CreateTime, rewards[i].UpdateTime = activityID, now, now
}
var snapshots []model.YumiGiftChallengeRewardSnapshot
var headers []model.YumiGiftChallengePeriodSettlement
if req.Enabled {
// 所有外部奖励组读取与校验都在事务前完成;任何失败都不会先禁用或删除旧配置。
snapshots, err = s.buildRewardSnapshots(ctx, proposed, tasks, rewards)
if err != nil {
return nil, err
}
headers, err = buildPeriodHeaders(proposed)
if err != nil {
return nil, err
}
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if req.ID.Int64() <= 0 {
if req.Enabled {
if !proposed.StartTime.After(time.Now()) || !proposed.EndTime.After(time.Now()) {
return NewAppError(http.StatusConflict, "invalid_enable_window", "enabled activity must start in the future")
}
if err := ensureNoEnabledOverlap(tx, proposed); err != nil {
return err
}
}
if err := tx.Create(&proposed).Error; err != nil {
if isDuplicateKey(err) {
return NewAppError(http.StatusConflict, "duplicate_activity_code", "activityCode already exists")
}
return err
}
} else {
if req.Version == nil {
return NewAppError(http.StatusBadRequest, "version_required", "version is required when updating")
}
var current model.YumiGiftChallengeActivity
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", activityID).First(&current).Error; errors.Is(err, gorm.ErrRecordNotFound) {
return NewAppError(http.StatusNotFound, "activity_not_found", "activity not found")
} else if err != nil {
return err
}
if !time.Now().Before(current.StartTime) {
return NewAppError(http.StatusConflict, "activity_started", "activity started while saving; retry metadata-only update")
}
if current.Version != *req.Version {
return NewAppError(http.StatusConflict, "version_conflict", "activity has been changed; reload before saving")
}
if req.Enabled {
if !proposed.StartTime.After(time.Now()) || !proposed.EndTime.After(time.Now()) || current.OverallSettlementStatus != StatusNotStarted {
return NewAppError(http.StatusConflict, "invalid_enable_window", "enabled activity must start in the future and remain unsettled")
}
if err := ensureNoEnabledOverlap(tx, proposed); err != nil {
return err
}
}
result := tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ? AND version = ?", activityID, *req.Version).
Updates(map[string]any{
"activity_code": proposed.ActivityCode, "activity_name": proposed.ActivityName,
"activity_desc": proposed.ActivityDesc, "sys_origin": proposed.SysOrigin,
"time_zone": proposed.Timezone, "start_time": proposed.StartTime, "end_time": proposed.EndTime,
"daily_settlement_delay_minutes": proposed.DailySettlementDelayMinutes,
"overall_settlement_delay_minutes": proposed.OverallSettlementDelayMinutes,
"overall_settlement_time": proposed.OverallSettlementTime, "display_top_n": proposed.DisplayTopN,
"enabled": proposed.Enabled, "overall_settlement_status": StatusNotStarted,
"version": gorm.Expr("version + 1"), "update_time": now,
})
if result.Error != nil || result.RowsAffected != 1 {
if result.Error != nil {
return result.Error
}
return NewAppError(http.StatusConflict, "version_conflict", "activity has been changed; reload before saving")
}
if err := deletePreStartArtifacts(tx, activityID); err != nil {
return err
}
}
if err := tx.Where("activity_id = ?", activityID).Delete(&model.YumiGiftChallengeTaskConfig{}).Error; err != nil {
return err
}
if err := tx.Where("activity_id = ?", activityID).Delete(&model.YumiGiftChallengeRankReward{}).Error; err != nil {
return err
}
if err := tx.Create(&tasks).Error; err != nil {
return err
}
if err := tx.Create(&rewards).Error; err != nil {
return err
}
if req.Enabled {
if err := tx.CreateInBatches(snapshots, 500).Error; err != nil {
return err
}
if err := tx.CreateInBatches(headers, 100).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
return s.GetAdminDetail(ctx, activityID)
}
func (s *Service) saveStartedActivityMetadata(ctx context.Context, current model.YumiGiftChallengeActivity, req SaveRequest) (*DetailResponse, error) {
if req.Version == nil {
return nil, NewAppError(http.StatusBadRequest, "version_required", "version is required when updating")
}
name, desc := strings.TrimSpace(req.ActivityName), strings.TrimSpace(req.ActivityDesc)
if name == "" || len(name) > 128 || len(desc) > 1000 {
return nil, NewAppError(http.StatusBadRequest, "invalid_activity_text", "activityName is required and activity text is too long")
}
if req.Tasks != nil || req.RankRewards != nil {
return nil, NewAppError(http.StatusConflict, "started_activity_core_readonly", "started activity tasks and rank rewards cannot be changed")
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var locked model.YumiGiftChallengeActivity
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", current.ID).First(&locked).Error; err != nil {
return err
}
if locked.Version != *req.Version {
return NewAppError(http.StatusConflict, "version_conflict", "activity has been changed; reload before saving")
}
if strings.TrimSpace(req.ActivityCode) != locked.ActivityCode ||
strings.ToUpper(strings.TrimSpace(req.SysOrigin)) != locked.SysOrigin || strings.TrimSpace(req.Timezone) != locked.Timezone ||
req.StartTime != locked.StartTime.UnixMilli() || req.EndTime != locked.EndTime.UnixMilli() ||
req.DailySettlementDelayMinutes != locked.DailySettlementDelayMinutes ||
req.OverallSettlementDelayMinutes != locked.OverallSettlementDelayMinutes ||
req.DisplayTopN != locked.DisplayTopN || req.Enabled != locked.Enabled {
return NewAppError(http.StatusConflict, "started_activity_core_readonly", "only activityName and activityDesc can be changed after start")
}
result := tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ? AND version = ?", locked.ID, *req.Version).
Updates(map[string]any{"activity_name": name, "activity_desc": desc, "version": gorm.Expr("version + 1"), "update_time": time.Now()})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return NewAppError(http.StatusConflict, "version_conflict", "activity has been changed; reload before saving")
}
return nil
})
if err != nil {
return nil, err
}
return s.GetAdminDetail(ctx, current.ID)
}
func validateActivityInput(req SaveRequest) (*time.Location, time.Time, time.Time, error) {
if strings.TrimSpace(req.ActivityCode) == "" || len(strings.TrimSpace(req.ActivityCode)) > 64 {
return nil, time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "invalid_activity_code", "activityCode is required and max length is 64")
}
if strings.TrimSpace(req.ActivityName) == "" || len(strings.TrimSpace(req.ActivityName)) > 128 {
return nil, time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "invalid_activity_name", "activityName is required and max length is 128")
}
if len(strings.TrimSpace(req.ActivityDesc)) > 1000 {
return nil, time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "invalid_activity_desc", "activityDesc max length is 1000")
}
location, err := resolveLocation(req.Timezone)
if err != nil {
return nil, time.Time{}, time.Time{}, err
}
startAt := time.UnixMilli(req.StartTime).In(location)
endAt := time.UnixMilli(req.EndTime).In(location)
if req.StartTime <= 0 || req.EndTime <= 0 || !endAt.After(startAt) {
return nil, time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "invalid_time_range", "endTime must be later than startTime")
}
if err := validateSettlementDelays(req.DailySettlementDelayMinutes, req.OverallSettlementDelayMinutes); err != nil {
return nil, time.Time{}, time.Time{}, err
}
if req.DisplayTopN < 1 || req.DisplayTopN > maxDisplayTopN {
return nil, time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "invalid_display_top_n", "displayTopN must be between 1 and 500")
}
days := 0
for day := dateOnly(startAt, location); day.Before(endAt); day = day.AddDate(0, 0, 1) {
days++
if days > maxActivityDays {
return nil, time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "activity_too_long", "activity may cover at most 366 activity days")
}
}
return location, startAt, endAt, nil
}
func validateSettlementDelays(dailyDelay, overallDelay int) error {
if dailyDelay < 0 || dailyDelay > 1440 || overallDelay < 0 || overallDelay > 1440 {
return NewAppError(http.StatusBadRequest, "invalid_settlement_delay", "settlement delay must be between 0 and 1440 minutes")
}
if overallDelay < dailyDelay {
// 总榜门闩关闭后事件会整笔拒绝;若总榜比最终日榜更早到期,两个延迟之间的
// 合法迟到礼物将无法计入仍开放的日榜,因此配置层必须保证总榜最后关门。
return NewAppError(http.StatusBadRequest, "invalid_settlement_delay_order", "overallSettlementDelayMinutes must be greater than or equal to dailySettlementDelayMinutes")
}
return nil
}
func normalizeTaskInputs(inputs []TaskInput) ([]model.YumiGiftChallengeTaskConfig, error) {
if len(inputs) != 3 {
return nil, NewAppError(http.StatusBadRequest, "invalid_tasks", "exactly three tasks are required")
}
rows := make([]model.YumiGiftChallengeTaskConfig, 0, 3)
codes := map[string]struct{}{}
enterCount, giftCount := 0, 0
for _, input := range inputs {
code := strings.TrimSpace(input.TaskCode)
taskType := strings.ToUpper(strings.TrimSpace(input.TaskType))
if !taskCodePattern.MatchString(code) {
return nil, NewAppError(http.StatusBadRequest, "invalid_task_code", "taskCode may contain only letters, digits, underscores, and hyphens")
}
if _, exists := codes[code]; exists {
return nil, NewAppError(http.StatusBadRequest, "duplicate_task_code", "taskCode must be unique")
}
codes[code] = struct{}{}
if strings.TrimSpace(input.TaskTitle) == "" || len(strings.TrimSpace(input.TaskTitle)) > 128 || len(strings.TrimSpace(input.TaskDesc)) > 500 {
return nil, NewAppError(http.StatusBadRequest, "invalid_task_text", "task title is required and task text is too long")
}
if input.SortOrder < 1 || input.SortOrder > 3 || !input.TargetValue.Positive() || input.ResourceGroupID == nil || input.ResourceGroupID.Int64() <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_task", "sortOrder, targetValue and resourceGroupId are required")
}
if taskType == TaskEnterPage {
enterCount++
if input.TargetValue.Compare(model.Decimal24_2("1.00")) != 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_enter_task", "ENTER_PAGE targetValue must be 1")
}
} else if taskType == TaskSendGiftGold {
giftCount++
} else {
return nil, NewAppError(http.StatusBadRequest, "invalid_task_type", "taskType must be ENTER_PAGE or SEND_GIFT_GOLD")
}
id := input.ID.Int64()
if id <= 0 {
var err error
id, err = utils.NextID()
if err != nil {
return nil, err
}
}
enabled := true
if input.Enabled != nil {
enabled = *input.Enabled
}
if !enabled {
return nil, NewAppError(http.StatusBadRequest, "disabled_task", "all three fixed tasks must be enabled")
}
groupID := input.ResourceGroupID.Int64()
rows = append(rows, model.YumiGiftChallengeTaskConfig{
ID: id, TaskCode: code, TaskType: taskType, TaskTitle: strings.TrimSpace(input.TaskTitle),
TaskDesc: strings.TrimSpace(input.TaskDesc), TargetValue: input.TargetValue,
ResourceGroupID: &groupID, Enabled: enabled, SortOrder: input.SortOrder,
})
}
if enterCount != 1 || giftCount != 2 {
return nil, NewAppError(http.StatusBadRequest, "invalid_task_slots", "tasks must contain one ENTER_PAGE and two SEND_GIFT_GOLD")
}
sortTaskRows(rows)
if rows[0].SortOrder != 1 || rows[1].SortOrder != 2 || rows[2].SortOrder != 3 {
return nil, NewAppError(http.StatusBadRequest, "invalid_task_sort", "task sortOrder must be exactly 1, 2, and 3")
}
thresholds := make([]model.Decimal24_2, 0, 2)
for _, row := range rows {
if row.TaskType == TaskSendGiftGold {
thresholds = append(thresholds, row.TargetValue)
}
}
if thresholds[0].Compare(thresholds[1]) >= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_task_thresholds", "SEND_GIFT_GOLD thresholds must be strictly increasing by sortOrder")
}
return rows, nil
}
func normalizeRankRewardInputs(inputs []RankRewardInput, displayTopN int) ([]model.YumiGiftChallengeRankReward, error) {
if len(inputs) == 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_rewards", "DAILY and OVERALL rank rewards are required")
}
rows := make([]model.YumiGiftChallengeRankReward, 0, len(inputs))
for _, input := range inputs {
period := strings.ToUpper(strings.TrimSpace(input.PeriodType))
if period != PeriodDaily && period != PeriodOverall {
return nil, NewAppError(http.StatusBadRequest, "invalid_period", "periodType must be DAILY or OVERALL")
}
if input.StartRank < 1 || input.EndRank < input.StartRank || input.EndRank > displayTopN || input.ResourceGroupID.Int64() <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward", "rank range and resourceGroupId are invalid")
}
id := input.ID.Int64()
if id <= 0 {
var err error
id, err = utils.NextID()
if err != nil {
return nil, err
}
}
rows = append(rows, model.YumiGiftChallengeRankReward{
ID: id, PeriodType: period, StartRank: input.StartRank, EndRank: input.EndRank,
ResourceGroupID: input.ResourceGroupID.Int64(), RewardName: strings.TrimSpace(input.RewardName),
})
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].PeriodType == rows[j].PeriodType {
return rows[i].StartRank < rows[j].StartRank
}
return rows[i].PeriodType < rows[j].PeriodType
})
periodCounts := map[string]int{}
lastEnd := map[string]int{}
for _, row := range rows {
periodCounts[row.PeriodType]++
if row.StartRank <= lastEnd[row.PeriodType] {
return nil, NewAppError(http.StatusBadRequest, "overlapping_rank_rewards", "rank reward ranges must not overlap")
}
lastEnd[row.PeriodType] = row.EndRank
}
if periodCounts[PeriodDaily] == 0 || periodCounts[PeriodOverall] == 0 {
return nil, NewAppError(http.StatusBadRequest, "missing_period_reward", "both DAILY and OVERALL rewards are required")
}
return rows, nil
}
// SetEnabled 启停活动;启用在同一事务冻结奖励模板并预建最多 366 个日榜门闩。
func (s *Service) SetEnabled(ctx context.Context, activityID int64, enabled bool) error {
activity, err := s.loadActivity(ctx, activityID)
if err != nil {
return err
}
if !time.Now().Before(activity.StartTime) {
return NewAppError(http.StatusConflict, "activity_readonly", "started activity cannot be enabled or disabled")
}
if !enabled {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var locked model.YumiGiftChallengeActivity
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", activityID).First(&locked).Error; err != nil {
return err
}
if !time.Now().Before(locked.StartTime) {
return NewAppError(http.StatusConflict, "activity_readonly", "started activity cannot be disabled")
}
if err := deletePreStartArtifacts(tx, activityID); err != nil {
return err
}
return tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ?", activityID).
Updates(map[string]any{"enabled": false, "overall_settlement_status": StatusNotStarted, "version": gorm.Expr("version + 1"), "update_time": time.Now()}).Error
})
}
tasks, rewards, err := s.loadActivityChildren(ctx, activityID)
if err != nil {
return err
}
if err := validateStoredConfig(tasks, rewards, activity.DisplayTopN); err != nil {
return err
}
// 不信任历史/人工写入的数据;即使绕过 SaveEnable 也不能生成总榜早于最终日榜的门闩。
if err := validateSettlementDelays(activity.DailySettlementDelayMinutes, activity.OverallSettlementDelayMinutes); err != nil {
return err
}
snapshots, err := s.buildRewardSnapshots(ctx, *activity, tasks, rewards)
if err != nil {
return err
}
headers, err := buildPeriodHeaders(*activity)
if err != nil {
return err
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var locked model.YumiGiftChallengeActivity
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", activityID).First(&locked).Error; err != nil {
return err
}
now := time.Now()
if !locked.StartTime.After(now) || !locked.EndTime.After(now) || locked.OverallSettlementStatus != StatusNotStarted {
return NewAppError(http.StatusConflict, "activity_readonly", "activity started while enabling")
}
if locked.Version != activity.Version {
return NewAppError(http.StatusConflict, "version_conflict", "activity changed while reward snapshot was loading")
}
if err := validateSettlementDelays(locked.DailySettlementDelayMinutes, locked.OverallSettlementDelayMinutes); err != nil {
return err
}
if err := ensureNoEnabledOverlap(tx, locked); err != nil {
return err
}
if err := deletePreStartArtifacts(tx, activityID); err != nil {
return err
}
if err := tx.CreateInBatches(snapshots, 500).Error; err != nil {
return err
}
if err := tx.CreateInBatches(headers, 100).Error; err != nil {
return err
}
return tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ?", activityID).
Updates(map[string]any{"enabled": true, "overall_settlement_status": StatusNotStarted, "version": gorm.Expr("version + 1"), "update_time": time.Now()}).Error
})
}
func validateStoredConfig(tasks []model.YumiGiftChallengeTaskConfig, rewards []model.YumiGiftChallengeRankReward, displayTopN int) error {
taskInputs := make([]TaskInput, 0, len(tasks))
for _, row := range tasks {
var group *FlexibleInt64
if row.ResourceGroupID != nil {
value := FlexibleInt64(*row.ResourceGroupID)
group = &value
}
enabled := row.Enabled
taskInputs = append(taskInputs, TaskInput{
ID: FlexibleInt64(row.ID), TaskCode: row.TaskCode, TaskType: row.TaskType,
TaskTitle: row.TaskTitle, TaskDesc: row.TaskDesc, TargetValue: row.TargetValue,
ResourceGroupID: group, Enabled: &enabled, SortOrder: row.SortOrder,
})
}
if _, err := normalizeTaskInputs(taskInputs); err != nil {
return err
}
rewardInputs := make([]RankRewardInput, 0, len(rewards))
for _, row := range rewards {
rewardInputs = append(rewardInputs, RankRewardInput{
ID: FlexibleInt64(row.ID), PeriodType: row.PeriodType, StartRank: row.StartRank,
EndRank: row.EndRank, ResourceGroupID: FlexibleInt64(row.ResourceGroupID), RewardName: row.RewardName,
})
}
_, err := normalizeRankRewardInputs(rewardInputs, displayTopN)
return err
}
func (s *Service) buildRewardSnapshots(ctx context.Context, activity model.YumiGiftChallengeActivity, tasks []model.YumiGiftChallengeTaskConfig, rewards []model.YumiGiftChallengeRankReward) ([]model.YumiGiftChallengeRewardSnapshot, error) {
groupIDs := map[int64]struct{}{}
for _, task := range tasks {
if task.ResourceGroupID != nil {
groupIDs[*task.ResourceGroupID] = struct{}{}
}
}
for _, reward := range rewards {
groupIDs[reward.ResourceGroupID] = struct{}{}
}
ids := make([]int64, 0, len(groupIDs))
for id := range groupIDs {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
now := time.Now()
rows := make([]model.YumiGiftChallengeRewardSnapshot, 0)
seen := map[string]struct{}{}
for _, groupID := range ids {
detail, err := s.gateway.GetRewardGroupDetail(ctx, groupID)
if err != nil {
return nil, NewAppError(http.StatusBadGateway, "reward_group_unavailable", fmt.Sprintf("load reward group %d: %v", groupID, err))
}
if int64(detail.ID) != groupID || strings.ToUpper(strings.TrimSpace(detail.SysOrigin)) != activity.SysOrigin {
return nil, NewAppError(http.StatusBadRequest, "reward_group_tenant_mismatch", fmt.Sprintf("reward group %d does not belong to %s", groupID, activity.SysOrigin))
}
if detail.ShelfStatus == nil || !*detail.ShelfStatus {
return nil, NewAppError(http.StatusBadRequest, "reward_group_not_shelved", fmt.Sprintf("reward group %d is not enabled", groupID))
}
if len(detail.RewardConfigList) == 0 {
return nil, NewAppError(http.StatusBadRequest, "empty_reward_group", fmt.Sprintf("reward group %d has no reward items", groupID))
}
sort.Slice(detail.RewardConfigList, func(i, j int) bool {
if detail.RewardConfigList[i].Sort == detail.RewardConfigList[j].Sort {
return int64(detail.RewardConfigList[i].ID) < int64(detail.RewardConfigList[j].ID)
}
return detail.RewardConfigList[i].Sort < detail.RewardConfigList[j].Sort
})
for index, item := range detail.RewardConfigList {
rewardConfigID := int64(item.ID)
if rewardConfigID <= 0 || strings.TrimSpace(item.Type) == "" || int64(item.Quantity) <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_reward_item", fmt.Sprintf("reward group %d contains an invalid item", groupID))
}
key := fmt.Sprintf("%d:%d", groupID, rewardConfigID)
if _, exists := seen[key]; exists {
return nil, NewAppError(http.StatusBadRequest, "duplicate_reward_item", "reward group contains duplicate reward item")
}
seen[key] = struct{}{}
id, idErr := utils.NextID()
if idErr != nil {
return nil, idErr
}
sortOrder := item.Sort
if sortOrder <= 0 {
sortOrder = index + 1
}
displayName := truncate(firstNonBlank(item.Name, item.BadgeName, item.Remark), 500)
rows = append(rows, model.YumiGiftChallengeRewardSnapshot{
ID: id, ActivityID: activity.ID, ResourceGroupID: groupID, RewardConfigID: rewardConfigID,
RewardType: strings.ToUpper(strings.TrimSpace(item.Type)), DetailType: strings.TrimSpace(item.DetailType),
Content: strings.TrimSpace(item.Content), Quantity: int64(item.Quantity), SortOrder: sortOrder,
Remark: strings.TrimSpace(item.Remark), Cover: strings.TrimSpace(item.Cover),
SourceURL: strings.TrimSpace(item.SourceURL), DisplayName: displayName, CreateTime: now,
})
if len(rows) > maxSnapshotItems {
return nil, NewAppError(http.StatusBadRequest, "too_many_reward_items", "activity reward snapshot may contain at most 5000 items")
}
}
}
return rows, nil
}
func firstNonBlank(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func buildPeriodHeaders(activity model.YumiGiftChallengeActivity) ([]model.YumiGiftChallengePeriodSettlement, error) {
// 所有 Save/Enable/任务与榜奖重建路径最终都经过这里;在门闩生成入口再校验一次,
// 防止历史脏数据或后续新增调用方绕过配置层,生成总榜早于最终日榜的关闭时间。
if err := validateSettlementDelays(activity.DailySettlementDelayMinutes, activity.OverallSettlementDelayMinutes); err != nil {
return nil, err
}
location, err := resolveLocation(activity.Timezone)
if err != nil {
return nil, err
}
now := time.Now()
rows := make([]model.YumiGiftChallengePeriodSettlement, 0, maxActivityDays+1)
for day := dateOnly(activity.StartTime, location); day.Before(activity.EndTime); day = day.AddDate(0, 0, 1) {
periodEnd := day.AddDate(0, 0, 1)
if periodEnd.After(activity.EndTime) {
periodEnd = activity.EndTime
}
id, idErr := utils.NextID()
if idErr != nil {
return nil, idErr
}
storedDate := dateKey(day, location)
rows = append(rows, model.YumiGiftChallengePeriodSettlement{
ID: id, ActivityID: activity.ID, PeriodType: PeriodDaily, PeriodKey: dateKey(day, location),
StatDate: &storedDate, SnapshotDueTime: periodEnd.Add(time.Duration(activity.DailySettlementDelayMinutes) * time.Minute),
Status: StatusNotStarted, CreateTime: now, UpdateTime: now,
})
if len(rows) > maxActivityDays {
return nil, NewAppError(http.StatusBadRequest, "activity_too_long", "activity may cover at most 366 activity days")
}
}
id, err := utils.NextID()
if err != nil {
return nil, err
}
rows = append(rows, model.YumiGiftChallengePeriodSettlement{
ID: id, ActivityID: activity.ID, PeriodType: PeriodOverall, PeriodKey: PeriodOverall,
SnapshotDueTime: activity.OverallSettlementTime, Status: StatusNotStarted, CreateTime: now, UpdateTime: now,
})
return rows, nil
}
func ensureNoEnabledOverlap(tx *gorm.DB, activity model.YumiGiftChallengeActivity) error {
var count int64
err := tx.Model(&model.YumiGiftChallengeActivity{}).
Where("sys_origin = ? AND enabled = ? AND id <> ? AND start_time < ? AND end_time > ?",
activity.SysOrigin, true, activity.ID, activity.EndTime, activity.StartTime).
Count(&count).Error
if err != nil {
return err
}
if count > 0 {
return NewAppError(http.StatusConflict, "activity_time_overlap", "enabled activity time windows must not overlap")
}
return nil
}
func deletePreStartArtifacts(tx *gorm.DB, activityID int64) error {
if err := tx.Where("activity_id = ?", activityID).Delete(&model.YumiGiftChallengeRewardSnapshot{}).Error; err != nil {
return err
}
return tx.Where("activity_id = ? AND status = ?", activityID, StatusNotStarted).
Delete(&model.YumiGiftChallengePeriodSettlement{}).Error
}
func isDuplicateKey(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "duplicate entry") || strings.Contains(message, "unique constraint")
}
// SaveTasks 单独保存三个任务;若活动已启用则在同一次后台操作里重建完整冻结产物。
func (s *Service) SaveTasks(ctx context.Context, activityID int64, req TaskSaveRequest) (*DetailResponse, error) {
if req.ActivityID.Int64() > 0 && req.ActivityID.Int64() != activityID {
return nil, NewAppError(http.StatusBadRequest, "activity_id_mismatch", "path id and activityId must match")
}
rows, err := normalizeTaskInputs(req.Tasks)
if err != nil {
return nil, err
}
activity, err := s.loadActivity(ctx, activityID)
if err != nil {
return nil, err
}
if !time.Now().Before(activity.StartTime) {
return nil, NewAppError(http.StatusConflict, "activity_readonly", "started activity cannot be edited")
}
_, rewards, err := s.loadActivityChildren(ctx, activityID)
if err != nil {
return nil, err
}
now := time.Now()
for i := range rows {
rows[i].ActivityID, rows[i].CreateTime, rows[i].UpdateTime = activityID, now, now
}
var snapshots []model.YumiGiftChallengeRewardSnapshot
var headers []model.YumiGiftChallengePeriodSettlement
if activity.Enabled {
if err := validateStoredConfig(rows, rewards, activity.DisplayTopN); err != nil {
return nil, err
}
snapshots, err = s.buildRewardSnapshots(ctx, *activity, rows, rewards)
if err != nil {
return nil, err
}
headers, err = buildPeriodHeaders(*activity)
if err != nil {
return nil, err
}
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var locked model.YumiGiftChallengeActivity
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", activityID).First(&locked).Error; err != nil {
return err
}
now := time.Now()
if !locked.StartTime.After(now) || (locked.Enabled && (!locked.EndTime.After(now) || locked.OverallSettlementStatus != StatusNotStarted)) {
return NewAppError(http.StatusConflict, "activity_readonly", "started activity cannot be edited")
}
if locked.Version != activity.Version || locked.Enabled != activity.Enabled {
return NewAppError(http.StatusConflict, "version_conflict", "activity changed while reward snapshot was loading")
}
if err := deletePreStartArtifacts(tx, activityID); err != nil {
return err
}
if err := tx.Where("activity_id = ?", activityID).Delete(&model.YumiGiftChallengeTaskConfig{}).Error; err != nil {
return err
}
if err := tx.Create(&rows).Error; err != nil {
return err
}
if activity.Enabled {
if err := tx.CreateInBatches(snapshots, 500).Error; err != nil {
return err
}
if err := tx.CreateInBatches(headers, 100).Error; err != nil {
return err
}
}
return tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ?", activityID).
Updates(map[string]any{"version": gorm.Expr("version + 1"), "update_time": now}).Error
})
if err != nil {
return nil, err
}
return s.GetAdminDetail(ctx, activityID)
}
// SaveRankRewards 单独保存日榜/总榜奖励区间,并按启用状态重建活动级奖励快照。
func (s *Service) SaveRankRewards(ctx context.Context, activityID int64, req RankRewardSaveRequest) (*DetailResponse, error) {
if req.ActivityID.Int64() > 0 && req.ActivityID.Int64() != activityID {
return nil, NewAppError(http.StatusBadRequest, "activity_id_mismatch", "path id and activityId must match")
}
activity, err := s.loadActivity(ctx, activityID)
if err != nil {
return nil, err
}
rows, err := normalizeRankRewardInputs(req.RankRewards, activity.DisplayTopN)
if err != nil {
return nil, err
}
if !time.Now().Before(activity.StartTime) {
return nil, NewAppError(http.StatusConflict, "activity_readonly", "started activity cannot be edited")
}
tasks, _, err := s.loadActivityChildren(ctx, activityID)
if err != nil {
return nil, err
}
now := time.Now()
for i := range rows {
rows[i].ActivityID, rows[i].CreateTime, rows[i].UpdateTime = activityID, now, now
}
var snapshots []model.YumiGiftChallengeRewardSnapshot
var headers []model.YumiGiftChallengePeriodSettlement
if activity.Enabled {
if err := validateStoredConfig(tasks, rows, activity.DisplayTopN); err != nil {
return nil, err
}
snapshots, err = s.buildRewardSnapshots(ctx, *activity, tasks, rows)
if err != nil {
return nil, err
}
headers, err = buildPeriodHeaders(*activity)
if err != nil {
return nil, err
}
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var locked model.YumiGiftChallengeActivity
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", activityID).First(&locked).Error; err != nil {
return err
}
now := time.Now()
if !locked.StartTime.After(now) || (locked.Enabled && (!locked.EndTime.After(now) || locked.OverallSettlementStatus != StatusNotStarted)) {
return NewAppError(http.StatusConflict, "activity_readonly", "started activity cannot be edited")
}
if locked.Version != activity.Version || locked.Enabled != activity.Enabled {
return NewAppError(http.StatusConflict, "version_conflict", "activity changed while reward snapshot was loading")
}
if err := deletePreStartArtifacts(tx, activityID); err != nil {
return err
}
if err := tx.Where("activity_id = ?", activityID).Delete(&model.YumiGiftChallengeRankReward{}).Error; err != nil {
return err
}
if err := tx.Create(&rows).Error; err != nil {
return err
}
if activity.Enabled {
if err := tx.CreateInBatches(snapshots, 500).Error; err != nil {
return err
}
if err := tx.CreateInBatches(headers, 100).Error; err != nil {
return err
}
}
return tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ?", activityID).
Updates(map[string]any{"version": gorm.Expr("version + 1"), "update_time": now}).Error
})
if err != nil {
return nil, err
}
return s.GetAdminDetail(ctx, activityID)
}

View File

@ -1,305 +0,0 @@
package yumigiftchallenge
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (s *Service) createDeliveryItemsTx(tx *gorm.DB, ownerType string, ownerID, activityID, userID int64, resourceGroupID *int64) error {
if resourceGroupID == nil || *resourceGroupID <= 0 {
return NewAppError(http.StatusConflict, "reward_group_missing", "reward group is missing")
}
var count int64
if err := tx.Model(&model.YumiGiftChallengeDeliveryItem{}).
Where("owner_type = ? AND owner_id = ?", ownerType, ownerID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
var snapshots []model.YumiGiftChallengeRewardSnapshot
if err := tx.Where("activity_id = ? AND resource_group_id = ?", activityID, *resourceGroupID).
Order("sort_order ASC, reward_config_id ASC").Find(&snapshots).Error; err != nil {
return err
}
if len(snapshots) == 0 {
return NewAppError(http.StatusConflict, "reward_snapshot_missing", "frozen reward snapshot is missing")
}
now := time.Now()
items := make([]model.YumiGiftChallengeDeliveryItem, 0, len(snapshots))
for _, snapshot := range snapshots {
id, err := utils.NextID()
if err != nil {
return err
}
items = append(items, model.YumiGiftChallengeDeliveryItem{
ID: id, OwnerType: ownerType, OwnerID: ownerID, ActivityID: activityID, UserID: userID,
RewardConfigID: snapshot.RewardConfigID, ResourceGroupID: snapshot.ResourceGroupID,
RewardType: snapshot.RewardType, DetailType: snapshot.DetailType, Content: snapshot.Content,
Quantity: snapshot.Quantity, SortOrder: snapshot.SortOrder, Remark: snapshot.Remark,
DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now,
})
}
// owner_type+owner_id+reward_config_id 唯一键是逐奖励项幂等边界;若并发已经物化,整个事务回滚后调用方重读即可。
if err := tx.Create(&items).Error; err != nil && !isDuplicateKey(err) {
return err
}
return nil
}
func (s *Service) deliverOwnerItems(ctx context.Context, ownerType string, ownerID int64) error {
if err := ctx.Err(); err != nil {
return err
}
if err := s.expireOwnerProcessing(ctx, ownerType, ownerID); err != nil {
return err
}
for {
// 请求或 worker 取消后必须在下一次 claim 前停住,未抢占的奖励项保持 PENDING
// 不能拿已取消的 context 批量调用 Java 并把整期奖励污染成 UNKNOWN。
if err := ctx.Err(); err != nil {
return err
}
item, err := s.claimNextDeliveryItem(ctx, ownerType, ownerID)
if err != nil {
return err
}
if item == nil {
break
}
request := integration.SendFrozenActivityRewardRequest{
// 外层 trackId 是当前 delivery item 主键,保证不同用户和不同奖励项互不冲突。
TrackID: item.ID, AcceptUserID: item.UserID, Origin: "ACTIVITY_REWARD", SysOrigin: defaultSysOrigin,
Prizes: []integration.FrozenActivityReward{{
// Java PrizeDescribe.trackId 沿用资源组商品ID真正的发奖幂等号是外层 delivery item ID。
TrackID: item.ResourceGroupID, Type: item.RewardType, DetailType: item.DetailType,
Content: item.Content, Quantity: item.Quantity, Remark: item.Remark,
}},
}
deliveryErr := s.gateway.SendFrozenActivityReward(ctx, request)
// 外部调用一旦发出,即使原 context 已取消也必须用独立短 context 落下 SUCCESS/UNKNOWN
// 状态落库失败时停止后续发送,避免失去可核账的幂等边界。
stateCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
finishErr := s.finishDeliveryAttempt(stateCtx, *item, deliveryErr)
cancel()
if finishErr != nil {
return finishErr
}
if deliveryErr != nil && (errors.Is(deliveryErr, context.Canceled) || errors.Is(deliveryErr, context.DeadlineExceeded) || ctx.Err() != nil) {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
return deliveryErr
}
}
stateCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
err := s.refreshOwnerAndPeriod(stateCtx, ownerType, ownerID)
cancel()
return err
}
func (s *Service) lockOwner(tx *gorm.DB, ownerType string, ownerID int64) error {
if ownerType == OwnerTask {
var row model.YumiGiftChallengeUserTaskDaily
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", ownerID).First(&row).Error
}
if ownerType == OwnerSettlement {
var row model.YumiGiftChallengeSettlement
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", ownerID).First(&row).Error
}
return NewAppError(http.StatusBadRequest, "invalid_owner_type", "invalid delivery owner type")
}
func (s *Service) expireOwnerProcessing(ctx context.Context, ownerType string, ownerID int64) error {
leaseSeconds := s.cfg.YumiGiftChallenge.DeliveryProcessingLeaseSeconds
if leaseSeconds < 30 {
leaseSeconds = 300
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 所有状态聚合统一 owner→item 锁序,避免补发、核账和正常发送互相死锁。
if err := s.lockOwner(tx, ownerType, ownerID); err != nil {
return err
}
return tx.Model(&model.YumiGiftChallengeDeliveryItem{}).
Where("owner_type = ? AND owner_id = ? AND delivery_status = ? AND update_time < ?",
ownerType, ownerID, DeliveryProcessing, time.Now().Add(-time.Duration(leaseSeconds)*time.Second)).
Updates(map[string]any{
"delivery_status": DeliveryUnknown, "failure_reason": "DELIVERY_RESULT_UNKNOWN: processing lease expired; reconcile downstream before resolving",
"update_time": time.Now(),
}).Error
})
}
func (s *Service) claimNextDeliveryItem(ctx context.Context, ownerType string, ownerID int64) (*model.YumiGiftChallengeDeliveryItem, error) {
var claimed *model.YumiGiftChallengeDeliveryItem
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.lockOwner(tx, ownerType, ownerID); err != nil {
return err
}
var item model.YumiGiftChallengeDeliveryItem
err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
Where("owner_type = ? AND owner_id = ? AND delivery_status = ?", ownerType, ownerID, DeliveryPending).
Order("sort_order ASC, id ASC").First(&item).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
now := time.Now()
result := tx.Model(&model.YumiGiftChallengeDeliveryItem{}).
Where("id = ? AND delivery_status = ?", item.ID, DeliveryPending).
Updates(map[string]any{"delivery_status": DeliveryProcessing, "retry_count": gorm.Expr("retry_count + 1"), "update_time": now})
if result.Error != nil || result.RowsAffected != 1 {
return result.Error
}
item.DeliveryStatus = DeliveryProcessing
claimed = &item
return nil
})
return claimed, err
}
func (s *Service) finishDeliveryAttempt(ctx context.Context, candidate model.YumiGiftChallengeDeliveryItem, deliveryErr error) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.lockOwner(tx, candidate.OwnerType, candidate.OwnerID); err != nil {
return err
}
var item model.YumiGiftChallengeDeliveryItem
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", candidate.ID).First(&item).Error; err != nil {
return err
}
if item.DeliveryStatus != DeliveryProcessing {
return nil
}
now := time.Now()
updates := map[string]any{"update_time": now}
if deliveryErr == nil {
updates["delivery_status"], updates["failure_reason"], updates["deliver_time"] = DeliverySuccess, "", now
} else {
// 外部调用超时、断连或返回异常时无法证明未到账,统一 UNKNOWN自动流程绝不盲目重发。
updates["delivery_status"] = DeliveryUnknown
updates["failure_reason"] = truncate(fmt.Sprintf("DELIVERY_RESULT_UNKNOWN: %v", deliveryErr), 500)
}
if err := tx.Model(&model.YumiGiftChallengeDeliveryItem{}).Where("id = ?", item.ID).Updates(updates).Error; err != nil {
return err
}
return s.finishOwnerStatusLocked(tx, candidate.OwnerType, candidate.OwnerID)
})
}
func (s *Service) finishOwnerStatusLocked(tx *gorm.DB, ownerType string, ownerID int64) error {
var total, unfinished, unknown int64
base := tx.Model(&model.YumiGiftChallengeDeliveryItem{}).Where("owner_type = ? AND owner_id = ?", ownerType, ownerID)
if err := base.Count(&total).Error; err != nil {
return err
}
if total == 0 {
return NewAppError(http.StatusConflict, "delivery_items_missing", "delivery items are missing")
}
if err := tx.Model(&model.YumiGiftChallengeDeliveryItem{}).
Where("owner_type = ? AND owner_id = ? AND delivery_status <> ?", ownerType, ownerID, DeliverySuccess).Count(&unfinished).Error; err != nil {
return err
}
if err := tx.Model(&model.YumiGiftChallengeDeliveryItem{}).
Where("owner_type = ? AND owner_id = ? AND delivery_status = ?", ownerType, ownerID, DeliveryUnknown).Count(&unknown).Error; err != nil {
return err
}
status, reason := DeliveryProcessing, ""
updates := map[string]any{"update_time": time.Now()}
if unfinished == 0 {
status, updates["deliver_time"] = DeliverySuccess, time.Now()
} else if unknown > 0 {
status, reason = DeliveryUnknown, "MANUAL_RECONCILIATION_REQUIRED: resolve UNKNOWN items after downstream audit"
}
updates["delivery_status"], updates["failure_reason"] = status, reason
if ownerType == OwnerTask {
return tx.Model(&model.YumiGiftChallengeUserTaskDaily{}).Where("id = ?", ownerID).Updates(updates).Error
}
return tx.Model(&model.YumiGiftChallengeSettlement{}).Where("id = ?", ownerID).Updates(updates).Error
}
func (s *Service) refreshOwnerAndPeriod(ctx context.Context, ownerType string, ownerID int64) error {
var activityID int64
var period, periodKey string
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.lockOwner(tx, ownerType, ownerID); err != nil {
return err
}
if err := s.finishOwnerStatusLocked(tx, ownerType, ownerID); err != nil {
return err
}
if ownerType == OwnerSettlement {
var row model.YumiGiftChallengeSettlement
if err := tx.Where("id = ?", ownerID).First(&row).Error; err != nil {
return err
}
activityID, period, periodKey = row.ActivityID, row.PeriodType, row.PeriodKey
}
return nil
})
if err != nil || ownerType != OwnerSettlement {
return err
}
return s.refreshPeriodStatus(ctx, activityID, period, periodKey)
}
// ResolveUnknownDeliveryItem 只有运营核账后才能把 UNKNOWN 标成到账或重置为 PENDING 补发。
func (s *Service) ResolveUnknownDeliveryItem(ctx context.Context, itemID int64, delivered bool) error {
var candidate model.YumiGiftChallengeDeliveryItem
if err := s.db.WithContext(ctx).Where("id = ?", itemID).First(&candidate).Error; errors.Is(err, gorm.ErrRecordNotFound) {
return NewAppError(http.StatusNotFound, "delivery_item_not_found", "delivery item not found")
} else if err != nil {
return err
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.lockOwner(tx, candidate.OwnerType, candidate.OwnerID); err != nil {
return err
}
var item model.YumiGiftChallengeDeliveryItem
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", itemID).First(&item).Error; err != nil {
return err
}
if item.DeliveryStatus != DeliveryUnknown {
return NewAppError(http.StatusConflict, "delivery_item_not_unknown", "only UNKNOWN item can be reconciled")
}
status := DeliveryPending
updates := map[string]any{"delivery_status": status, "failure_reason": "", "update_time": time.Now()}
if delivered {
status = DeliverySuccess
updates["delivery_status"], updates["deliver_time"] = status, time.Now()
}
if err := tx.Model(&model.YumiGiftChallengeDeliveryItem{}).Where("id = ?", itemID).Updates(updates).Error; err != nil {
return err
}
return s.finishOwnerStatusLocked(tx, candidate.OwnerType, candidate.OwnerID)
})
if err != nil {
return err
}
if !delivered {
if err := s.deliverOwnerItems(ctx, candidate.OwnerType, candidate.OwnerID); err != nil {
return err
}
}
return s.refreshOwnerAndPeriod(ctx, candidate.OwnerType, candidate.OwnerID)
}
func truncate(value string, max int) string {
value = strings.TrimSpace(value)
if len(value) <= max {
return value
}
return value[:max]
}

View File

@ -1,221 +0,0 @@
package yumigiftchallenge
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var errMalformedGiftPayload = errors.New("malformed yumi gift challenge payload")
// ProcessGiftPayload 解析 RocketMQ 原始消息并复用活动入账逻辑。
func (s *Service) ProcessGiftPayload(ctx context.Context, payload string) error {
payload = strings.TrimSpace(payload)
if payload == "" {
return nil
}
event, err := decodeGiftEvent(payload, s.cfg.YumiGiftChallenge.RocketMQ.Tag)
if err != nil {
return fmt.Errorf("%w: %v", errMalformedGiftPayload, err)
}
return s.ProcessGiftEvent(ctx, event)
}
// ProcessGiftEvent 只接受普通 GOLD 非背包礼物;幸运礼物和 MAGIC 在金额判断前强制排除。
func (s *Service) ProcessGiftEvent(ctx context.Context, event giftEvent) error {
trackID := strings.TrimSpace(event.TrackID.String())
userID := event.SendUserID.Int64()
giftID := event.GiftConfig.ID.Int64()
if trackID == "" || userID <= 0 || giftID <= 0 || event.CreateTime.Int64() <= 0 {
return nil
}
origin := strings.ToUpper(strings.TrimSpace(event.SysOrigin))
if origin == "" || origin != defaultSysOrigin {
return nil
}
giftType := strings.ToUpper(strings.TrimSpace(event.GiftConfig.Type))
giftTab := strings.ToUpper(strings.TrimSpace(event.GiftConfig.GiftTab))
acceptCount, acceptsValid := countDistinctAcceptUsers(event.Accepts)
quantity, giftCandy := event.Quantity.Int64(), event.GiftConfig.GiftCandy
// give_gift_v3 不保证存在可直接信任的 actualAmount礼物主流水总消耗口径是
// 单价 × 数量 × 去重有效收礼人数。缺失收礼人必须忽略,不能默认按 1 人计分。
if event.BagGift == nil || *event.BagGift || (event.GiftValue.Bag != nil && *event.GiftValue.Bag) || strings.TrimSpace(giftTab) == "" ||
!acceptsValid || quantity <= 0 || !giftCandy.Positive() {
return nil
}
amount, err := giftCandy.MultiplyInt(quantity)
if err != nil {
return nil
}
amount, err = amount.MultiplyInt(int64(acceptCount))
if err != nil || !amount.Positive() {
return nil
}
if giftType != "GOLD" || giftTab == "LUCKY_GIFT" || giftTab == "MAGIC" {
return nil
}
eventTime := time.UnixMilli(event.CreateTime.Int64())
var activity model.YumiGiftChallengeActivity
err = s.db.WithContext(ctx).Where(
"sys_origin = ? AND enabled = ? AND start_time <= ? AND end_time > ?",
origin, true, eventTime, eventTime,
).Order("start_time DESC").First(&activity).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
location, err := resolveLocation(activity.Timezone)
if err != nil {
return err
}
statDate := dateOnly(eventTime, location)
periodKey := dateKey(statDate, location)
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 热链路只持有共享门闩锁,同一活动的送礼事件可以并发;结算使用排他锁,
// 会等待已经进入事务的事件提交,再冻结确定的边界。
var dailyHeader model.YumiGiftChallengePeriodSettlement
if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where(
"activity_id = ? AND period_type = ? AND period_key = ?", activity.ID, PeriodDaily, periodKey,
).First(&dailyHeader).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
var overallHeader model.YumiGiftChallengePeriodSettlement
if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where(
"activity_id = ? AND period_type = ? AND period_key = ?", activity.ID, PeriodOverall, PeriodOverall,
).First(&overallHeader).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
// 总榜门闩关闭后整笔拒绝;日榜门闩关闭只阻止日榜和当日任务,合法迟到礼物仍计入尚未冻结的总榜。
if overallHeader.Status != StatusNotStarted {
return nil
}
ledgerID, err := utils.NextID()
if err != nil {
return err
}
ledger := model.YumiGiftChallengeGiftLedger{
ID: ledgerID, ActivityID: activity.ID, SourceEventTrackID: trackID, UserID: userID,
GiftID: giftID, GiftTab: giftTab, Amount: amount, EventTime: eventTime,
StatDate: periodKey, CreateTime: time.Now(),
}
if err := tx.Create(&ledger).Error; err != nil {
if isDuplicateKey(err) {
return nil
}
return err
}
now := time.Now()
// score_reached_time 取已消费事件时间的最大值;乱序迟到事件不能把并列分数的到达时间倒拨,
// 从而保证 score DESC、reached_time ASC、user_id ASC 的排序稳定。
if err := tx.Exec(`
INSERT INTO yumi_gift_challenge_user_score
(activity_id, user_id, score, score_reached_time, create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
score = score + VALUES(score),
score_reached_time = GREATEST(score_reached_time, VALUES(score_reached_time)),
update_time = VALUES(update_time)`,
activity.ID, userID, amount, eventTime, now, now).Error; err != nil {
return err
}
if dailyHeader.Status == StatusNotStarted {
if err := tx.Exec(`
INSERT INTO yumi_gift_challenge_user_daily_score
(activity_id, stat_date, user_id, score, score_reached_time, create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
score = score + VALUES(score),
score_reached_time = GREATEST(score_reached_time, VALUES(score_reached_time)),
update_time = VALUES(update_time)`,
activity.ID, periodKey, userID, amount, eventTime, now, now).Error; err != nil {
return err
}
if err := s.ensureTaskSnapshotsTx(tx, activity, periodKey, userID); err != nil {
return err
}
// 每个事件只执行一次本更新,因为 ledger 唯一键已在同一事务内成功占位。
if err := tx.Exec(`
UPDATE yumi_gift_challenge_user_task_daily
SET progress_value = LEAST(target_value, progress_value + ?),
completed_time = CASE
WHEN completed_time IS NULL AND progress_value + ? >= target_value THEN ?
ELSE completed_time
END,
update_time = ?
WHERE activity_id = ? AND stat_date = ? AND user_id = ? AND task_type = ?`,
amount, amount, eventTime, now, activity.ID, periodKey, userID, TaskSendGiftGold).Error; err != nil {
return err
}
}
return nil
})
}
func countDistinctAcceptUsers(users []*giftEventUser) (int, bool) {
if len(users) == 0 {
return 0, false
}
seen := make(map[int64]struct{}, len(users))
for _, user := range users {
if user == nil {
return 0, false
}
id := user.AcceptUserID.Int64()
if id <= 0 {
// 任一收礼元素不完整都说明事件口径不可信,整条拒绝而不是静默少算人数。
return 0, false
}
seen[id] = struct{}{}
}
return len(seen), len(seen) > 0
}
func decodeGiftEvent(payload, expectedTag string) (giftEvent, error) {
var event giftEvent
if err := json.Unmarshal([]byte(payload), &event); err != nil {
return event, err
}
if event.TrackID.String() != "" || event.SendUserID.Int64() > 0 || event.GiftConfig.ID.Int64() > 0 {
return event, nil
}
var envelope messageEnvelope
if err := json.Unmarshal([]byte(payload), &envelope); err != nil {
return event, err
}
if tag := strings.TrimSpace(envelope.Tag); tag != "" && strings.TrimSpace(expectedTag) != "" && tag != strings.TrimSpace(expectedTag) {
return giftEvent{}, nil
}
body := strings.TrimSpace(string(envelope.Body))
if body == "" || body == "null" {
return giftEvent{}, nil
}
if strings.HasPrefix(body, `"`) {
if err := json.Unmarshal(envelope.Body, &body); err != nil {
return event, err
}
}
if err := json.Unmarshal([]byte(body), &event); err != nil {
return event, err
}
return event, nil
}

View File

@ -1,113 +0,0 @@
package yumigiftchallenge
import (
"context"
"encoding/json"
"errors"
"log"
"strings"
"time"
rmq "github.com/apache/rocketmq-clients/golang/v5"
"github.com/apache/rocketmq-clients/golang/v5/credentials"
)
// StartMessageConsumer 异步启动 Yumi 独立消费组MQ 临时不可用不能阻断 API 启动。
func (s *Service) StartMessageConsumer(ctx context.Context) error {
if !s.cfg.YumiGiftChallenge.RocketMQ.Enabled {
log.Printf("yumi gift challenge rocketmq consumer disabled")
return nil
}
mqConfig := s.cfg.YumiGiftChallenge.RocketMQ
if strings.TrimSpace(mqConfig.Endpoint) == "" || strings.TrimSpace(mqConfig.AccessKey) == "" || strings.TrimSpace(mqConfig.AccessSecret) == "" {
log.Printf("yumi gift challenge rocketmq consumer skipped: endpoint or credentials missing")
return nil
}
go s.startRocketMQConsumerWithRetry(ctx)
return nil
}
func (s *Service) startRocketMQConsumerWithRetry(ctx context.Context) {
retryDelay := 5 * time.Second
for ctx.Err() == nil {
if err := s.startRocketMQConsumer(ctx); err == nil {
return
} else {
log.Printf("start yumi gift challenge 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
}
}
}
}
func (s *Service) startRocketMQConsumer(ctx context.Context) error {
mqConfig := s.cfg.YumiGiftChallenge.RocketMQ
filter := rmq.SUB_ALL
if tag := strings.TrimSpace(mqConfig.Tag); tag != "" {
filter = rmq.NewFilterExpression(tag)
}
consumer, err := rmq.NewPushConsumer(&rmq.Config{
Endpoint: mqConfig.Endpoint, NameSpace: mqConfig.Namespace, ConsumerGroup: mqConfig.ConsumerGroup,
Credentials: &credentials.SessionCredentials{
AccessKey: mqConfig.AccessKey, AccessSecret: mqConfig.AccessSecret, SecurityToken: mqConfig.SecurityToken,
},
},
rmq.WithPushSubscriptionExpressions(map[string]*rmq.FilterExpression{mqConfig.Topic: filter}),
rmq.WithPushConsumptionThreadCount(8),
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("yumi gift challenge 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("yumi gift challenge rocketmq consumer started. topic=%s group=%s tag=%s", mqConfig.Topic, mqConfig.ConsumerGroup, mqConfig.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.ProcessGiftPayload(ctx, payload); err != nil {
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
if errors.Is(err, errMalformedGiftPayload) || errors.As(err, &syntaxErr) || errors.As(err, &typeErr) {
// 毒消息重试不会自愈;记录 messageId 后 ACK数据库或下游错误仍返回 FAILURE 重试。
log.Printf("drop malformed yumi gift challenge message. messageId=%s err=%v", messageView.GetMessageId(), err)
return nil
}
return err
}
return nil
}

View File

@ -1,320 +0,0 @@
package yumigiftchallenge
import (
"context"
"errors"
"net/http"
"strings"
"sync"
"time"
"chatapp3-golang/internal/model"
"gorm.io/gorm"
)
type rawRankRow struct {
UserID int64
Score model.Decimal24_2
ScoreReachedTime time.Time
Rank int
}
// GetRanking 返回 H5 排行榜;神秘人不出现在他人的公开榜,但本人仍可查看真实名次。
func (s *Service) GetRanking(ctx context.Context, user AuthUser, activityID int64, period, statDate string, limit int) (*RankingResponse, error) {
origin, err := s.requireYumiOrigin(user.SysOrigin)
if err != nil {
return nil, err
}
activity, err := s.selectActivity(ctx, origin, activityID, true)
if err != nil {
return nil, err
}
return s.ranking(ctx, *activity, user.UserID, period, statDate, limit, false)
}
// GetAdminRanking 返回运营榜单,不应用用户公开隐身过滤,但仍保持同一确定性排序。
func (s *Service) GetAdminRanking(ctx context.Context, activityID int64, period, statDate string, limit int) (*RankingResponse, error) {
activity, err := s.loadActivity(ctx, activityID)
if err != nil {
return nil, err
}
return s.ranking(ctx, *activity, 0, period, statDate, limit, true)
}
func (s *Service) ranking(ctx context.Context, activity model.YumiGiftChallengeActivity, viewerID int64, period, statDate string, limit int, admin bool) (*RankingResponse, error) {
period = strings.ToUpper(strings.TrimSpace(period))
if period == "" {
period = PeriodDaily
}
if period != PeriodDaily && period != PeriodOverall {
return nil, NewAppError(http.StatusBadRequest, "invalid_period", "period must be DAILY or OVERALL")
}
if limit < 1 {
if admin {
limit = 100
} else {
limit = maxPublicRankingLimit
}
}
if limit > activity.DisplayTopN {
limit = activity.DisplayTopN
}
if limit > maxDisplayTopN {
limit = maxDisplayTopN
}
if !admin && limit > maxPublicRankingLimit {
// Java 目前只提供单用户 VIP ability 接口,公开榜必须逐人按隐私优先核验;
// 因而在服务端而非仅 H5 端限流,避免恶意 limit=500 放大为 500 次下游 HTTP。
limit = maxPublicRankingLimit
}
location, err := resolveLocation(activity.Timezone)
if err != nil {
return nil, err
}
var date *time.Time
periodKey := PeriodOverall
if period == PeriodDaily {
selected, err := selectRankingDate(activity, statDate, location)
if err != nil {
return nil, err
}
date, periodKey = &selected, dateKey(selected, location)
}
var header model.YumiGiftChallengePeriodSettlement
err = s.db.WithContext(ctx).Where("activity_id = ? AND period_type = ? AND period_key = ?", activity.ID, period, periodKey).First(&header).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, NewAppError(http.StatusConflict, "settlement_gate_missing", "period settlement gate is missing")
}
if err != nil {
return nil, err
}
settled := header.Status != StatusNotStarted
// 与 Aslan 一致:只读取请求的原始 Top N再过滤神秘人被隐藏的名次不从 N 之后补位。
fetchLimit := limit
rows, err := s.loadRankRows(ctx, activity.ID, period, periodKey, date, settled, fetchLimit)
if err != nil {
return nil, err
}
entries := make([]RankEntryView, 0, limit)
var my *RankEntryView
decorated := s.decorateRankEntries(ctx, activity.SysOrigin, rows, viewerID, admin)
for index, row := range rows {
view, visible := decorated[index].view, decorated[index].visible
if row.UserID == viewerID {
copy := view
my = &copy
}
if visible && len(entries) < limit {
entries = append(entries, view)
}
}
if viewerID > 0 && my == nil {
myRow, err := s.loadMyRankRow(ctx, activity.ID, period, periodKey, date, settled, viewerID)
if err != nil {
return nil, err
}
if myRow != nil {
view, _ := s.decorateRankEntry(ctx, activity.SysOrigin, *myRow, viewerID, false)
my = &view
}
}
s.hydrateRankProfiles(ctx, entries, my)
response := &RankingResponse{PeriodType: period, Settled: settled, Entries: entries, My: my}
if date != nil {
response.StatDate = dateKey(*date, location)
}
return response, nil
}
type decoratedRankEntry struct {
view RankEntryView
visible bool
}
func (s *Service) decorateRankEntries(ctx context.Context, origin string, rows []rawRankRow, viewerID int64, admin bool) []decoratedRankEntry {
results := make([]decoratedRankEntry, len(rows))
if len(rows) == 0 {
return results
}
workerCount := 8
if len(rows) < workerCount {
workerCount = len(rows)
}
jobs := make(chan int, len(rows))
var workers sync.WaitGroup
workers.Add(workerCount)
for worker := 0; worker < workerCount; worker++ {
go func() {
defer workers.Done()
for index := range jobs {
if ctx.Err() != nil {
continue
}
view, visible := s.decorateRankEntry(ctx, origin, rows[index], viewerID, admin)
results[index] = decoratedRankEntry{view: view, visible: visible}
}
}()
}
for index := range rows {
jobs <- index
}
close(jobs)
workers.Wait()
return results
}
func selectRankingDate(activity model.YumiGiftChallengeActivity, value string, location *time.Location) (time.Time, error) {
if strings.TrimSpace(value) != "" {
date, err := parseDateKey(value, location)
if err != nil {
return time.Time{}, err
}
if date.Before(dateOnly(activity.StartTime, location)) || !date.Before(activity.EndTime) {
return time.Time{}, NewAppError(http.StatusBadRequest, "stat_date_out_of_range", "statDate is outside activity window")
}
if date.After(dateOnly(time.Now(), location)) {
return time.Time{}, NewAppError(http.StatusBadRequest, "future_stat_date", "future daily ranking is not available")
}
return date, nil
}
now := time.Now().In(location)
if now.Before(activity.StartTime) {
return dateOnly(activity.StartTime, location), nil
}
if !now.Before(activity.EndTime) {
return dateOnly(activity.EndTime.Add(-time.Millisecond), location), nil
}
return dateOnly(now, location), nil
}
func (s *Service) loadRankRows(ctx context.Context, activityID int64, period, periodKey string, statDate *time.Time, settled bool, limit int) ([]rawRankRow, error) {
rows := make([]rawRankRow, 0, limit)
_ = periodKey
_ = settled
_ = statDate
// 门闩进入 PROCESSING 后对应 score 聚合表不再写入,因此它本身就是完整冻结榜;
// settlement 只包含命中奖励区间的获奖 parent绝不能拿它替代完整排行榜。
if period == PeriodDaily {
var live []model.YumiGiftChallengeUserDailyScore
if err := s.db.WithContext(ctx).Where("activity_id = ? AND stat_date = ? AND score > 0", activityID, periodKey).
Order("score DESC, score_reached_time ASC, user_id ASC").Limit(limit).Find(&live).Error; err != nil {
return nil, err
}
for index, row := range live {
rows = append(rows, rawRankRow{UserID: row.UserID, Score: row.Score, ScoreReachedTime: row.ScoreReachedTime, Rank: index + 1})
}
return rows, nil
}
var live []model.YumiGiftChallengeUserScore
if err := s.db.WithContext(ctx).Where("activity_id = ? AND score > 0", activityID).
Order("score DESC, score_reached_time ASC, user_id ASC").Limit(limit).Find(&live).Error; err != nil {
return nil, err
}
for index, row := range live {
rows = append(rows, rawRankRow{UserID: row.UserID, Score: row.Score, ScoreReachedTime: row.ScoreReachedTime, Rank: index + 1})
}
return rows, nil
}
func (s *Service) loadMyRankRow(ctx context.Context, activityID int64, period, periodKey string, statDate *time.Time, settled bool, userID int64) (*rawRankRow, error) {
_ = settled
_ = statDate
if period == PeriodDaily {
var row model.YumiGiftChallengeUserDailyScore
err := s.db.WithContext(ctx).Where("activity_id = ? AND stat_date = ? AND user_id = ? AND score > 0", activityID, periodKey, userID).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
rank, err := s.currentUserRank(ctx, activityID, &periodKey, userID, row.Score, row.ScoreReachedTime)
if err != nil || rank == nil {
return nil, err
}
return &rawRankRow{UserID: userID, Score: row.Score, ScoreReachedTime: row.ScoreReachedTime, Rank: *rank}, nil
}
var row model.YumiGiftChallengeUserScore
err := s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ? AND score > 0", activityID, userID).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
rank, err := s.currentUserRank(ctx, activityID, nil, userID, row.Score, row.ScoreReachedTime)
if err != nil || rank == nil {
return nil, err
}
return &rawRankRow{UserID: userID, Score: row.Score, ScoreReachedTime: row.ScoreReachedTime, Rank: *rank}, nil
}
func (s *Service) decorateRankEntry(ctx context.Context, origin string, row rawRankRow, viewerID int64, admin bool) (RankEntryView, bool) {
view := RankEntryView{Rank: row.Rank, UserID: row.UserID, Score: row.Score, Me: row.UserID == viewerID}
if !admin && row.UserID != viewerID {
hidden, err := s.userHidden(ctx, origin, row.UserID)
if err != nil || hidden {
// 隐身能力下游不可用时按隐私优先隐藏候选;先判断可见性再取 profile
// 避免对不会展示的用户产生多余资料请求。
return view, false
}
}
return view, true
}
func (s *Service) hydrateRankProfiles(ctx context.Context, entries []RankEntryView, my *RankEntryView) {
userIDs := make([]int64, 0, len(entries)+1)
seen := make(map[int64]struct{}, len(entries)+1)
for _, entry := range entries {
if _, exists := seen[entry.UserID]; !exists {
seen[entry.UserID] = struct{}{}
userIDs = append(userIDs, entry.UserID)
}
}
if my != nil {
if _, exists := seen[my.UserID]; !exists {
userIDs = append(userIDs, my.UserID)
}
}
profiles, err := s.gateway.MapUserProfiles(ctx, userIDs)
if err != nil {
return
}
for index := range entries {
if profile, exists := profiles[entries[index].UserID]; exists {
entries[index].Account, entries[index].Nickname, entries[index].Avatar = profile.Account, profile.UserNickname, profile.UserAvatar
}
}
if my != nil {
if profile, exists := profiles[my.UserID]; exists {
my.Account, my.Nickname, my.Avatar = profile.Account, profile.UserNickname, profile.UserAvatar
}
}
}
func (s *Service) userHidden(ctx context.Context, origin string, userID int64) (bool, error) {
now := time.Now()
s.visibilityMu.Lock()
for cachedUserID, item := range s.visibility {
if !now.Before(item.expiresAt) {
delete(s.visibility, cachedUserID)
}
}
if item, ok := s.visibility[userID]; ok && now.Before(item.expiresAt) {
s.visibilityMu.Unlock()
return item.hidden, nil
}
s.visibilityMu.Unlock()
hidden, err := s.gateway.GetUserMysteriousInvisibility(ctx, origin, userID)
if err != nil {
return false, err
}
if hidden {
// 只缓存“隐藏”结论;公开状态必须实时复核,用户刚开启神秘人后不会继续沿用旧 false 暴露资料。
s.visibilityMu.Lock()
s.visibility[userID] = visibilityCacheEntry{hidden: true, expiresAt: now.Add(2 * time.Minute)}
s.visibilityMu.Unlock()
}
return hidden, nil
}

View File

@ -1,129 +0,0 @@
package yumigiftchallenge
import (
"context"
"fmt"
"time"
"chatapp3-golang/internal/model"
)
const (
defaultSettleDueBatch = 20
maxSettleDueBatch = 100
settleDueItemTimeout = 30 * time.Second
)
// SettleDue 是 chatapp-cron 的单轮幂等 runner冻结到期周期并恢复 PENDING/过租期 PROCESSING。
// 本仓库不启动 ticker同一端点可被多个实例重复调用数据库门闩和 delivery item 唯一键负责并发幂等。
func (s *Service) SettleDue(ctx context.Context, batch int) (*SettleDueResult, error) {
batch = normalizeSettleDueBatch(batch)
result := &SettleDueResult{FailedKeys: []string{}}
var periods []model.YumiGiftChallengePeriodSettlement
if err := s.db.WithContext(ctx).
Where("status IN ? AND snapshot_due_time <= ?", []string{StatusNotStarted, StatusProcessing}, time.Now()).
Order("snapshot_due_time ASC, id ASC").Limit(batch).Find(&periods).Error; err != nil {
return nil, err
}
for _, period := range periods {
if err := ctx.Err(); err != nil {
return result, err
}
result.PeriodsScanned++
request := SettlementRequest{PeriodType: period.PeriodType}
if period.StatDate != nil {
request.StatDate = *period.StatDate
}
itemCtx, cancel := context.WithTimeout(ctx, settleDueItemTimeout)
target, err := s.resolveSettlementTarget(itemCtx, period.ActivityID, request)
if err == nil {
err = s.freezeSettlementTarget(itemCtx, target)
}
if err == nil {
// 空榜直接完成;有获奖 parent 时保持 PROCESSING后面的有界 delivery 恢复负责逐项发放。
err = s.refreshPeriodStatus(itemCtx, target.activity.ID, target.period, target.periodKey)
}
cancel()
if err != nil {
result.FailedKeys = append(result.FailedKeys, fmt.Sprintf("PERIOD:%d:%s:%s", period.ActivityID, period.PeriodType, period.PeriodKey))
continue
}
result.PeriodsFrozen++
}
if err := s.recoverTaskOwners(ctx, batch, result); err != nil {
return result, err
}
if err := s.recoverSettlementOwners(ctx, batch, result); err != nil {
return result, err
}
return result, nil
}
func (s *Service) recoverTaskOwners(ctx context.Context, batch int, result *SettleDueResult) error {
var rows []model.YumiGiftChallengeUserTaskDaily
if err := s.db.WithContext(ctx).Select("id").
Where("delivery_status = ? OR (delivery_status = ? AND update_time <= ?)", DeliveryPending, DeliveryProcessing, s.deliveryLeaseCutoff()).
Order("update_time ASC, id ASC").Limit(batch).Find(&rows).Error; err != nil {
return err
}
for _, row := range rows {
if err := ctx.Err(); err != nil {
return err
}
result.TaskOwnersScanned++
itemCtx, cancel := context.WithTimeout(ctx, settleDueItemTimeout)
err := s.deliverOwnerItems(itemCtx, OwnerTask, row.ID)
cancel()
if err != nil {
result.FailedKeys = append(result.FailedKeys, fmt.Sprintf("TASK:%d", row.ID))
continue
}
result.OwnersProcessed++
}
return nil
}
func (s *Service) recoverSettlementOwners(ctx context.Context, batch int, result *SettleDueResult) error {
var rows []model.YumiGiftChallengeSettlement
if err := s.db.WithContext(ctx).Select("id").
Where("delivery_status = ? OR (delivery_status = ? AND update_time <= ?)", DeliveryPending, DeliveryProcessing, s.deliveryLeaseCutoff()).
Order("update_time ASC, id ASC").Limit(batch).Find(&rows).Error; err != nil {
return err
}
for _, row := range rows {
if err := ctx.Err(); err != nil {
return err
}
result.SettlementOwnersScanned++
itemCtx, cancel := context.WithTimeout(ctx, settleDueItemTimeout)
err := s.ensureSettlementDeliveryItems(itemCtx, row.ID)
if err == nil {
err = s.deliverOwnerItems(itemCtx, OwnerSettlement, row.ID)
}
cancel()
if err != nil {
result.FailedKeys = append(result.FailedKeys, fmt.Sprintf("SETTLEMENT:%d", row.ID))
continue
}
result.OwnersProcessed++
}
return nil
}
func normalizeSettleDueBatch(batch int) int {
if batch <= 0 {
return defaultSettleDueBatch
}
if batch > maxSettleDueBatch {
return maxSettleDueBatch
}
return batch
}
func (s *Service) deliveryLeaseCutoff() time.Time {
leaseSeconds := s.cfg.YumiGiftChallenge.DeliveryProcessingLeaseSeconds
if leaseSeconds < 30 {
leaseSeconds = 300
}
return time.Now().Add(-time.Duration(leaseSeconds) * time.Second)
}

View File

@ -1,314 +0,0 @@
package yumigiftchallenge
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Settle 使用两阶段提交:先冻结门闩和获奖 parent提交后再物化并发送奖励项。
func (s *Service) Settle(ctx context.Context, activityID int64, req SettlementRequest) error {
target, err := s.resolveSettlementTarget(ctx, activityID, req)
if err != nil {
return err
}
if err := s.freezeSettlementTarget(ctx, target); err != nil {
return err
}
return s.resumeSettlementTarget(ctx, target)
}
type settlementTarget struct {
activity *model.YumiGiftChallengeActivity
period string
periodKey string
statDate *string
}
func (s *Service) resolveSettlementTarget(ctx context.Context, activityID int64, req SettlementRequest) (settlementTarget, error) {
activity, err := s.loadActivity(ctx, activityID)
if err != nil {
return settlementTarget{}, err
}
if !activity.Enabled {
return settlementTarget{}, NewAppError(http.StatusConflict, "activity_disabled", "activity is disabled")
}
period := strings.ToUpper(strings.TrimSpace(req.PeriodType))
if period != PeriodDaily && period != PeriodOverall {
return settlementTarget{}, NewAppError(http.StatusBadRequest, "invalid_period", "periodType must be DAILY or OVERALL")
}
location, err := resolveLocation(activity.Timezone)
if err != nil {
return settlementTarget{}, err
}
periodKey := PeriodOverall
var statDate *string
if period == PeriodDaily {
if strings.TrimSpace(req.StatDate) == "" {
return settlementTarget{}, NewAppError(http.StatusBadRequest, "stat_date_required", "statDate is required for DAILY settlement")
}
date, err := parseDateKey(req.StatDate, location)
if err != nil {
return settlementTarget{}, err
}
periodKey = dateKey(date, location)
statDate = &periodKey
}
return settlementTarget{activity: activity, period: period, periodKey: periodKey, statDate: statDate}, nil
}
func (s *Service) freezeSettlementTarget(ctx context.Context, target settlementTarget) error {
// 阶段一独立事务只负责关闭写门闩并冻结获奖 parent阶段二失败不会重新开放榜单或重算获奖人。
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var header model.YumiGiftChallengePeriodSettlement
queryErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where(
"activity_id = ? AND period_type = ? AND period_key = ?", target.activity.ID, target.period, target.periodKey,
).First(&header).Error
if errors.Is(queryErr, gorm.ErrRecordNotFound) {
return NewAppError(http.StatusNotFound, "settlement_gate_not_found", "settlement gate not found")
}
if queryErr != nil {
return queryErr
}
if time.Now().Before(header.SnapshotDueTime) {
return NewAppError(http.StatusConflict, "settlement_not_due", "settlement delay window has not elapsed")
}
if header.Status != StatusNotStarted {
return nil
}
if err := tx.Model(&model.YumiGiftChallengePeriodSettlement{}).Where("id = ?", header.ID).
Updates(map[string]any{"status": StatusProcessing, "update_time": time.Now()}).Error; err != nil {
return err
}
if target.period == PeriodOverall {
if err := tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ?", target.activity.ID).
Updates(map[string]any{"overall_settlement_status": StatusProcessing, "update_time": time.Now()}).Error; err != nil {
return err
}
}
return s.freezeSettlementParentsTx(tx, *target.activity, target.period, target.periodKey, target.statDate)
})
}
func (s *Service) resumeSettlementTarget(ctx context.Context, target settlementTarget) error {
var parents []model.YumiGiftChallengeSettlement
if err := s.db.WithContext(ctx).Where("activity_id = ? AND period_type = ? AND period_key = ?", target.activity.ID, target.period, target.periodKey).
Order("rank_no ASC").Find(&parents).Error; err != nil {
return err
}
for _, parent := range parents {
if err := ctx.Err(); err != nil {
return err
}
if err := s.ensureSettlementDeliveryItems(ctx, parent.ID); err != nil {
// parent 和门闩已经提交,返回错误让运营看到;再次执行同一结算会从固定 parent 继续,不重算榜单。
return err
}
if err := s.deliverOwnerItems(ctx, OwnerSettlement, parent.ID); err != nil {
return err
}
}
return s.refreshPeriodStatus(ctx, target.activity.ID, target.period, target.periodKey)
}
func (s *Service) freezeSettlementParentsTx(tx *gorm.DB, activity model.YumiGiftChallengeActivity, period, periodKey string, statDate *string) error {
var rewardConfigs []model.YumiGiftChallengeRankReward
if err := tx.Where("activity_id = ? AND period_type = ?", activity.ID, period).
Order("start_rank ASC").Find(&rewardConfigs).Error; err != nil {
return err
}
maxRank := 0
for _, reward := range rewardConfigs {
if reward.EndRank > maxRank {
maxRank = reward.EndRank
}
}
if maxRank == 0 {
return NewAppError(http.StatusConflict, "rank_reward_missing", "rank reward configuration is missing")
}
rows, err := s.loadRankRowsTx(tx, activity.ID, period, statDate, maxRank)
if err != nil {
return err
}
now := time.Now()
for _, row := range rows {
reward := matchRankReward(rewardConfigs, row.Rank)
if reward == nil {
continue
}
id, err := utils.NextID()
if err != nil {
return err
}
parent := model.YumiGiftChallengeSettlement{
ID: id, ActivityID: activity.ID, PeriodType: period, PeriodKey: periodKey, StatDate: statDate,
UserID: row.UserID, RankNo: row.Rank, Score: row.Score, RankRewardID: reward.ID,
ResourceGroupID: reward.ResourceGroupID,
BusinessNo: fmt.Sprintf("YUMI_GIFT_CHALLENGE:RANK:%d:%s:%s:%d", activity.ID, period, periodKey, row.UserID),
DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now,
}
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&parent).Error; err != nil {
return err
}
}
return nil
}
func (s *Service) loadRankRowsTx(tx *gorm.DB, activityID int64, period string, statDate *string, limit int) ([]rawRankRow, error) {
result := make([]rawRankRow, 0, limit)
if period == PeriodDaily {
var rows []model.YumiGiftChallengeUserDailyScore
if err := tx.Where("activity_id = ? AND stat_date = ? AND score > 0", activityID, *statDate).
Order("score DESC, score_reached_time ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil {
return nil, err
}
for index, row := range rows {
result = append(result, rawRankRow{UserID: row.UserID, Score: row.Score, Rank: index + 1})
}
return result, nil
}
var rows []model.YumiGiftChallengeUserScore
if err := tx.Where("activity_id = ? AND score > 0", activityID).
Order("score DESC, score_reached_time ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil {
return nil, err
}
for index, row := range rows {
result = append(result, rawRankRow{UserID: row.UserID, Score: row.Score, Rank: index + 1})
}
return result, nil
}
func matchRankReward(rows []model.YumiGiftChallengeRankReward, rank int) *model.YumiGiftChallengeRankReward {
for index := range rows {
if rank >= rows[index].StartRank && rank <= rows[index].EndRank {
return &rows[index]
}
}
return nil
}
func (s *Service) ensureSettlementDeliveryItems(ctx context.Context, settlementID int64) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var parent model.YumiGiftChallengeSettlement
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", settlementID).First(&parent).Error; err != nil {
return err
}
groupID := parent.ResourceGroupID
return s.createDeliveryItemsTx(tx, OwnerSettlement, parent.ID, parent.ActivityID, parent.UserID, &groupID)
})
}
func (s *Service) refreshPeriodStatus(ctx context.Context, activityID int64, period, periodKey string) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var header model.YumiGiftChallengePeriodSettlement
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where(
"activity_id = ? AND period_type = ? AND period_key = ?", activityID, period, periodKey,
).First(&header).Error; err != nil {
return err
}
var total, unfinished, unknown int64
base := tx.Model(&model.YumiGiftChallengeSettlement{}).
Where("activity_id = ? AND period_type = ? AND period_key = ?", activityID, period, periodKey)
if err := base.Count(&total).Error; err != nil {
return err
}
if total > 0 {
if err := tx.Model(&model.YumiGiftChallengeSettlement{}).
Where("activity_id = ? AND period_type = ? AND period_key = ? AND delivery_status <> ?", activityID, period, periodKey, DeliverySuccess).
Count(&unfinished).Error; err != nil {
return err
}
if err := tx.Model(&model.YumiGiftChallengeSettlement{}).
Where("activity_id = ? AND period_type = ? AND period_key = ? AND delivery_status = ?", activityID, period, periodKey, DeliveryUnknown).
Count(&unknown).Error; err != nil {
return err
}
}
status := StatusCompleted
if unfinished > 0 {
status = StatusProcessing
if unknown > 0 {
status = StatusReconciliationRequired
}
}
if err := tx.Model(&model.YumiGiftChallengePeriodSettlement{}).Where("id = ?", header.ID).
Updates(map[string]any{"status": status, "update_time": time.Now()}).Error; err != nil {
return err
}
if period == PeriodOverall {
return tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ?", activityID).
Updates(map[string]any{"overall_settlement_status": status, "update_time": time.Now()}).Error
}
return nil
})
}
// PageSettlementRecords 分页查询获奖 parent 及逐奖励项状态,供运营核账。
func (s *Service) PageSettlementRecords(ctx context.Context, activityID int64, period, statDate, status string, page, size int) (map[string]any, error) {
if _, err := s.loadActivity(ctx, activityID); err != nil {
return nil, err
}
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
query := s.db.WithContext(ctx).Model(&model.YumiGiftChallengeSettlement{}).Where("activity_id = ?", activityID)
if period = strings.ToUpper(strings.TrimSpace(period)); period != "" {
query = query.Where("period_type = ?", period)
}
if strings.TrimSpace(statDate) != "" {
query = query.Where("period_key = ?", strings.TrimSpace(statDate))
}
if status = strings.ToUpper(strings.TrimSpace(status)); status != "" {
query = query.Where("delivery_status = ?", status)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, err
}
var rows []model.YumiGiftChallengeSettlement
if err := query.Order("create_time DESC, rank_no ASC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
return nil, err
}
records := make([]SettlementRecordView, 0, len(rows))
for _, row := range rows {
var items []model.YumiGiftChallengeDeliveryItem
if err := s.db.WithContext(ctx).Where("owner_type = ? AND owner_id = ?", OwnerSettlement, row.ID).
Order("sort_order ASC, id ASC").Find(&items).Error; err != nil {
return nil, err
}
itemViews := make([]DeliveryItemView, 0, len(items))
for _, item := range items {
itemViews = append(itemViews, DeliveryItemView{
ID: item.ID, OwnerType: item.OwnerType, OwnerID: item.OwnerID, RewardConfigID: item.RewardConfigID,
ResourceGroupID: item.ResourceGroupID, RewardType: item.RewardType, DetailType: item.DetailType,
Content: item.Content, Quantity: item.Quantity, SortOrder: item.SortOrder, Remark: item.Remark,
DeliveryStatus: item.DeliveryStatus, RetryCount: item.RetryCount,
FailureReason: item.FailureReason, DeliverTime: millis(item.DeliverTime),
})
}
view := SettlementRecordView{
ID: row.ID, ActivityID: row.ActivityID, PeriodType: row.PeriodType, PeriodKey: row.PeriodKey,
UserID: row.UserID, Rank: row.RankNo, Score: row.Score, RankRewardID: row.RankRewardID,
ResourceGroupID: row.ResourceGroupID, BusinessNo: row.BusinessNo, DeliveryStatus: row.DeliveryStatus,
RetryCount: row.RetryCount, FailureReason: row.FailureReason, CreateTime: row.CreateTime.UnixMilli(),
DeliverTime: millis(row.DeliverTime), DeliveryItems: itemViews,
}
if row.StatDate != nil {
view.StatDate = *row.StatDate
}
records = append(records, view)
}
return map[string]any{"records": records, "total": total, "current": page, "size": size}, nil
}

View File

@ -1,241 +0,0 @@
package yumigiftchallenge
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Enter 在活动时区创建当天任务快照,并幂等完成进入页面任务。
func (s *Service) Enter(ctx context.Context, user AuthUser, req EnterRequest) (*UserStateResponse, error) {
origin, err := s.requireYumiOrigin(user.SysOrigin)
if err != nil {
return nil, err
}
if user.UserID <= 0 {
return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "authenticated user is required")
}
activity, err := s.selectActivity(ctx, origin, req.ActivityID.Int64(), true)
if err != nil {
return nil, err
}
if activityStatus(*activity, time.Now()) != "ONGOING" {
return nil, NewAppError(http.StatusConflict, "activity_not_ongoing", "activity is not ongoing")
}
location, err := resolveLocation(activity.Timezone)
if err != nil {
return nil, err
}
statDate := dateKey(time.Now(), location)
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var header model.YumiGiftChallengePeriodSettlement
if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where(
"activity_id = ? AND period_type = ? AND period_key = ?", activity.ID, PeriodDaily, statDate,
).First(&header).Error; err != nil {
return err
}
if header.Status != StatusNotStarted {
return NewAppError(http.StatusConflict, "daily_period_closed", "daily task period is already frozen")
}
if err := s.ensureTaskSnapshotsTx(tx, *activity, statDate, user.UserID); err != nil {
return err
}
now := time.Now()
return tx.Model(&model.YumiGiftChallengeUserTaskDaily{}).
Where("activity_id = ? AND stat_date = ? AND user_id = ? AND task_type = ? AND completed_time IS NULL",
activity.ID, statDate, user.UserID, TaskEnterPage).
Updates(map[string]any{"progress_value": model.Decimal24_2("1.00"), "completed_time": now, "update_time": now}).Error
})
if err != nil {
return nil, err
}
return s.buildUserState(ctx, *activity, statDate, user.UserID)
}
func (s *Service) ensureTaskSnapshotsTx(tx *gorm.DB, activity model.YumiGiftChallengeActivity, statDate string, userID int64) error {
var configs []model.YumiGiftChallengeTaskConfig
if err := tx.Where("activity_id = ? AND enabled = ?", activity.ID, true).
Order("sort_order ASC, id ASC").Find(&configs).Error; err != nil {
return err
}
if len(configs) != 3 {
return NewAppError(http.StatusConflict, "invalid_activity_tasks", "enabled activity must contain exactly three tasks")
}
now := time.Now()
for _, config := range configs {
id, err := utils.NextID()
if err != nil {
return err
}
row := model.YumiGiftChallengeUserTaskDaily{
ID: id, ActivityID: activity.ID, StatDate: statDate, UserID: userID,
TaskConfigID: config.ID, TaskCode: config.TaskCode, TaskType: config.TaskType,
TaskTitle: config.TaskTitle, SortOrder: config.SortOrder, TargetValue: config.TargetValue,
ResourceGroupID: config.ResourceGroupID,
BusinessNo: fmt.Sprintf("YUMI_GIFT_CHALLENGE:TASK:%d:%s:%d:%s", activity.ID, statDate, userID, config.TaskCode),
DeliveryStatus: DeliveryNotClaimed, CreateTime: now, UpdateTime: now,
}
// 活动、统计日、用户和 taskCode 唯一键同时承接页面重进和送礼并发,只有第一份任务配置成为当天快照。
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
return err
}
}
return nil
}
func mustLocation(value string) *time.Location {
location, err := time.LoadLocation(strings.TrimSpace(value))
if err != nil {
return time.UTC
}
return location
}
// ClaimTask 先在本地事务抢占任务 parent 并物化 delivery item再逐项调用外部发奖。
func (s *Service) ClaimTask(ctx context.Context, user AuthUser, taskCode string, req ClaimRequest) (*UserStateResponse, error) {
origin, err := s.requireYumiOrigin(user.SysOrigin)
if err != nil {
return nil, err
}
taskCode = strings.TrimSpace(taskCode)
if !taskCodePattern.MatchString(taskCode) {
return nil, NewAppError(http.StatusBadRequest, "invalid_task_code", "invalid taskCode")
}
requestID := strings.TrimSpace(req.RequestID)
if requestID == "" || len(requestID) > 128 {
return nil, NewAppError(http.StatusBadRequest, "invalid_request_id", "requestId is required and max length is 128")
}
activity, err := s.selectActivity(ctx, origin, req.ActivityID.Int64(), true)
if err != nil {
return nil, err
}
if activityStatus(*activity, time.Now()) != "ONGOING" {
return nil, NewAppError(http.StatusConflict, "activity_not_ongoing", "activity is not ongoing")
}
location, err := resolveLocation(activity.Timezone)
if err != nil {
return nil, err
}
statDate := dateKey(time.Now(), location)
var ownerID int64
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.ensureTaskSnapshotsTx(tx, *activity, statDate, user.UserID); err != nil {
return err
}
var task model.YumiGiftChallengeUserTaskDaily
queryErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("activity_id = ? AND stat_date = ? AND user_id = ? AND task_code = ?", activity.ID, statDate, user.UserID, taskCode).
First(&task).Error
if errors.Is(queryErr, gorm.ErrRecordNotFound) {
return NewAppError(http.StatusNotFound, "task_not_found", "task not found")
}
if queryErr != nil {
return queryErr
}
ownerID = task.ID
if task.CompletedTime == nil || task.ProgressValue.Compare(task.TargetValue) < 0 {
return NewAppError(http.StatusConflict, "task_not_completed", "task is not completed")
}
switch task.DeliveryStatus {
case DeliverySuccess:
return nil
case DeliveryUnknown:
return NewAppError(http.StatusConflict, "delivery_unknown", "reward result is unknown; contact support for reconciliation")
case DeliveryProcessing, DeliveryPending:
// 已经抢占的领取由同一 owner/item 幂等边界继续处理,重复 requestId 不会创建第二份奖励。
case DeliveryNotClaimed:
now := time.Now()
if err := tx.Model(&model.YumiGiftChallengeUserTaskDaily{}).Where("id = ?", task.ID).
Updates(map[string]any{"delivery_status": DeliveryPending, "claim_request_id": requestID, "claimed_time": now, "update_time": now}).Error; err != nil {
return err
}
default:
return NewAppError(http.StatusConflict, "invalid_delivery_status", "task reward state is invalid")
}
return s.createDeliveryItemsTx(tx, OwnerTask, task.ID, activity.ID, user.UserID, task.ResourceGroupID)
})
if err != nil {
return nil, err
}
if ownerID > 0 {
if err := s.deliverOwnerItems(ctx, OwnerTask, ownerID); err != nil {
return nil, err
}
}
return s.buildUserState(ctx, *activity, statDate, user.UserID)
}
func (s *Service) buildUserState(ctx context.Context, activity model.YumiGiftChallengeActivity, statDate string, userID int64) (*UserStateResponse, error) {
var total model.YumiGiftChallengeUserScore
if err := s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", activity.ID, userID).First(&total).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
var daily model.YumiGiftChallengeUserDailyScore
if err := s.db.WithContext(ctx).Where("activity_id = ? AND stat_date = ? AND user_id = ?", activity.ID, statDate, userID).First(&daily).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
overallRank, err := s.currentUserRank(ctx, activity.ID, nil, userID, total.Score, total.ScoreReachedTime)
if err != nil {
return nil, err
}
dailyRank, err := s.currentUserRank(ctx, activity.ID, &statDate, userID, daily.Score, daily.ScoreReachedTime)
if err != nil {
return nil, err
}
var tasks []model.YumiGiftChallengeUserTaskDaily
if err := s.db.WithContext(ctx).Where("activity_id = ? AND stat_date = ? AND user_id = ?", activity.ID, statDate, userID).
Order("sort_order ASC, id ASC").Find(&tasks).Error; err != nil {
return nil, err
}
snapshots, err := s.loadRewardSnapshots(ctx, activity.ID)
if err != nil {
return nil, err
}
views := make([]UserTaskView, 0, len(tasks))
for _, row := range tasks {
items := []RewardItemView{}
if row.ResourceGroupID != nil {
items = rewardItemViews(snapshots[*row.ResourceGroupID])
}
views = append(views, UserTaskView{
ID: row.ID, TaskConfigID: row.TaskConfigID, StatDate: row.StatDate,
TaskCode: row.TaskCode, TaskType: row.TaskType, TaskTitle: row.TaskTitle, SortOrder: row.SortOrder,
TargetValue: row.TargetValue, ProgressValue: row.ProgressValue,
Completed: row.CompletedTime != nil, CompletedTime: millis(row.CompletedTime), ResourceGroupID: row.ResourceGroupID,
DeliveryStatus: row.DeliveryStatus, ClaimedTime: millis(row.ClaimedTime), DeliverTime: millis(row.DeliverTime), RewardItems: items,
})
}
return &UserStateResponse{
ActivityID: activity.ID, StatDate: statDate,
TotalScore: total.Score, DailyScore: daily.Score, OverallRank: overallRank, DailyRank: dailyRank, Tasks: views,
}, nil
}
func (s *Service) currentUserRank(ctx context.Context, activityID int64, statDate *string, userID int64, score model.Decimal24_2, reached time.Time) (*int, error) {
if !score.Positive() || reached.IsZero() {
return nil, nil
}
var count int64
query := s.db.WithContext(ctx)
if statDate == nil {
query = query.Model(&model.YumiGiftChallengeUserScore{}).Where("activity_id = ?", activityID)
} else {
query = query.Model(&model.YumiGiftChallengeUserDailyScore{}).Where("activity_id = ? AND stat_date = ?", activityID, *statDate)
}
err := query.Where("score > ? OR (score = ? AND score_reached_time < ?) OR (score = ? AND score_reached_time = ? AND user_id < ?)",
score, score, reached, score, reached, userID).Count(&count).Error
if err != nil {
return nil, err
}
rank := int(count) + 1
return &rank, nil
}

View File

@ -1,448 +0,0 @@
package yumigiftchallenge
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"sync"
"time"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
rmq "github.com/apache/rocketmq-clients/golang/v5"
"gorm.io/gorm"
)
const (
PeriodDaily = "DAILY"
PeriodOverall = "OVERALL"
TaskEnterPage = "ENTER_PAGE"
TaskSendGiftGold = "SEND_GIFT_GOLD"
StatusNotStarted = "NOT_STARTED"
StatusProcessing = "PROCESSING"
StatusCompleted = "COMPLETED"
StatusReconciliationRequired = "RECONCILIATION_REQUIRED"
DeliveryNotClaimed = "NOT_CLAIMED"
DeliveryPending = "PENDING"
DeliveryProcessing = "PROCESSING"
DeliverySuccess = "SUCCESS"
DeliveryUnknown = "UNKNOWN"
OwnerTask = "TASK"
OwnerSettlement = "SETTLEMENT"
// Yumi 客户端的业务来源仍是 Java SysOriginPlatformEnum.LIKEIYUMI 只是 H5/App 品牌码。
defaultSysOrigin = "LIKEI"
defaultTimezone = "Asia/Riyadh"
maxActivityDays = 366
maxDisplayTopN = 500
maxSnapshotItems = 5000
// H5 设计只展示 30 名;公开接口固定上限可把当前无批量 Java VIP 能力接口的
// 隐私核验严格控制在单请求 30 次内,后台榜单与结算仍使用可配置的 TopN最大 500
maxPublicRankingLimit = 30
)
type AppError = common.AppError
type AuthUser = common.AuthUser
var NewAppError = common.NewAppError
// FlexibleInt64 同时兼容 JSON 数字和字符串,避免管理端 Snowflake ID 精度丢失。
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 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, `"`) {
var value string
if err := json.Unmarshal(data, &value); err != nil {
return err
}
*v = flexibleString(strings.TrimSpace(value))
return nil
}
*v = flexibleString(raw)
return nil
}
func (v flexibleString) String() string { return string(v) }
type yumiGiftDB interface {
WithContext(context.Context) *gorm.DB
}
type yumiGiftGateway interface {
GetRewardGroupDetail(context.Context, int64) (integration.RewardGroupDetail, error)
MapUserProfiles(context.Context, []int64) (map[int64]integration.UserProfile, error)
GetUserMysteriousInvisibility(context.Context, string, int64) (bool, error)
SendFrozenActivityReward(context.Context, integration.SendFrozenActivityRewardRequest) error
}
// Service 负责 Yumi 礼物挑战配置、用户态、送礼入账、榜单冻结与逐项发奖。
type Service struct {
cfg config.Config
db yumiGiftDB
gateway yumiGiftGateway
rocketConsumer rmq.PushConsumer
visibilityMu sync.Mutex
visibility map[int64]visibilityCacheEntry
}
type visibilityCacheEntry struct {
hidden bool
expiresAt time.Time
}
func NewService(cfg config.Config, db yumiGiftDB, gateway yumiGiftGateway) *Service {
return &Service{cfg: cfg, db: db, gateway: gateway, visibility: map[int64]visibilityCacheEntry{}}
}
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"`
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"`
DailySettlementDelayMinutes int `json:"dailySettlementDelayMinutes"`
OverallSettlementDelayMinutes int `json:"overallSettlementDelayMinutes"`
OverallSettlementTime int64 `json:"overallSettlementTime"`
DisplayTopN int `json:"displayTopN"`
Enabled bool `json:"enabled"`
OverallSettlementStatus string `json:"overallSettlementStatus"`
Version int `json:"version"`
CreateTime int64 `json:"createTime"`
UpdateTime int64 `json:"updateTime"`
}
type TaskView struct {
ID int64 `json:"id,string"`
ActivityID int64 `json:"activityId,string"`
TaskCode string `json:"taskCode"`
TaskType string `json:"taskType"`
TaskTitle string `json:"taskTitle"`
TaskDesc string `json:"taskDesc"`
TargetValue model.Decimal24_2 `json:"targetValue"`
ResourceGroupID *int64 `json:"resourceGroupId,string,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"`
PeriodType string `json:"periodType"`
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"`
Tasks []TaskView `json:"tasks"`
RankRewards []RankRewardView `json:"rankRewards"`
SupportedPeriods []string `json:"supportedPeriods"`
SupportedTaskTypes []string `json:"supportedTaskTypes"`
ActivityStatus string `json:"activityStatus"`
ServerTime int64 `json:"serverTime"`
}
type UserTaskView struct {
ID int64 `json:"id,string"`
TaskConfigID int64 `json:"taskConfigId,string"`
StatDate string `json:"statDate"`
TaskCode string `json:"taskCode"`
TaskType string `json:"taskType"`
TaskTitle string `json:"taskTitle"`
SortOrder int `json:"sortOrder"`
TargetValue model.Decimal24_2 `json:"targetValue"`
ProgressValue model.Decimal24_2 `json:"progressValue"`
Completed bool `json:"completed"`
CompletedTime int64 `json:"completedTime,omitempty"`
ResourceGroupID *int64 `json:"resourceGroupId,string,omitempty"`
DeliveryStatus string `json:"deliveryStatus"`
ClaimedTime int64 `json:"claimedTime,omitempty"`
DeliverTime int64 `json:"deliverTime,omitempty"`
RewardItems []RewardItemView `json:"rewardItems"`
}
type UserStateResponse struct {
ActivityID int64 `json:"activityId,string"`
StatDate string `json:"statDate"`
TotalScore model.Decimal24_2 `json:"totalScore"`
DailyScore model.Decimal24_2 `json:"dailyScore"`
OverallRank *int `json:"overallRank"`
DailyRank *int `json:"dailyRank"`
Tasks []UserTaskView `json:"tasks"`
}
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 model.Decimal24_2 `json:"score"`
Me bool `json:"me"`
}
type RankingResponse struct {
PeriodType string `json:"periodType"`
StatDate string `json:"statDate,omitempty"`
Settled bool `json:"settled"`
Entries []RankEntryView `json:"entries"`
My *RankEntryView `json:"my"`
}
type EnterRequest struct {
ActivityID FlexibleInt64 `json:"activityId"`
}
type ClaimRequest struct {
ActivityID FlexibleInt64 `json:"activityId"`
RequestID string `json:"requestId"`
}
type TaskInput struct {
ID FlexibleInt64 `json:"id"`
TaskCode string `json:"taskCode"`
TaskType string `json:"taskType"`
TaskTitle string `json:"taskTitle"`
TaskDesc string `json:"taskDesc"`
TargetValue model.Decimal24_2 `json:"targetValue"`
ResourceGroupID *FlexibleInt64 `json:"resourceGroupId"`
Enabled *bool `json:"enabled"`
SortOrder int `json:"sortOrder"`
}
type RankRewardInput struct {
ID FlexibleInt64 `json:"id"`
PeriodType string `json:"periodType"`
StartRank int `json:"startRank"`
EndRank int `json:"endRank"`
ResourceGroupID FlexibleInt64 `json:"resourceGroupId"`
RewardName string `json:"rewardName"`
}
type SaveRequest 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"`
DailySettlementDelayMinutes int `json:"dailySettlementDelayMinutes"`
OverallSettlementDelayMinutes int `json:"overallSettlementDelayMinutes"`
DisplayTopN int `json:"displayTopN"`
Enabled bool `json:"enabled"`
Version *int `json:"version"`
Tasks []TaskInput `json:"tasks"`
RankRewards []RankRewardInput `json:"rankRewards"`
}
type TaskSaveRequest struct {
ActivityID FlexibleInt64 `json:"activityId"`
Tasks []TaskInput `json:"tasks"`
}
type RankRewardSaveRequest struct {
ActivityID FlexibleInt64 `json:"activityId"`
RankRewards []RankRewardInput `json:"rankRewards"`
}
type SettlementRequest struct {
PeriodType string `json:"periodType"`
StatDate string `json:"statDate"`
}
type ResolveDeliveryRequest struct {
Delivered bool `json:"delivered"`
}
type SettleDueResult struct {
PeriodsScanned int `json:"periodsScanned"`
PeriodsFrozen int `json:"periodsFrozen"`
TaskOwnersScanned int `json:"taskOwnersScanned"`
SettlementOwnersScanned int `json:"settlementOwnersScanned"`
OwnersProcessed int `json:"ownersProcessed"`
FailedKeys []string `json:"failedKeys"`
}
type DeliveryItemView struct {
ID int64 `json:"id,string"`
OwnerType string `json:"ownerType"`
OwnerID int64 `json:"ownerId,string"`
RewardConfigID int64 `json:"rewardConfigId,string"`
ResourceGroupID int64 `json:"resourceGroupId,string"`
RewardType string `json:"rewardType"`
DetailType string `json:"detailType"`
Content string `json:"content"`
Quantity int64 `json:"quantity"`
SortOrder int `json:"sortOrder"`
Remark string `json:"remark"`
DeliveryStatus string `json:"deliveryStatus"`
RetryCount int `json:"retryCount"`
FailureReason string `json:"failureReason"`
DeliverTime int64 `json:"deliverTime,omitempty"`
}
type SettlementRecordView struct {
ID int64 `json:"id,string"`
ActivityID int64 `json:"activityId,string"`
PeriodType string `json:"periodType"`
PeriodKey string `json:"periodKey"`
StatDate string `json:"statDate,omitempty"`
UserID int64 `json:"userId,string"`
Rank int `json:"rank"`
Score model.Decimal24_2 `json:"score"`
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"`
}
// giftEvent 与真实 give_gift_v3 事件字段保持一致;资格判断不能只看金额。
type giftEvent struct {
TrackID flexibleString `json:"trackId"`
SysOrigin string `json:"sysOrigin"`
SendUserID FlexibleInt64 `json:"sendUserId"`
Quantity exactInt64 `json:"quantity"`
CreateTime FlexibleInt64 `json:"createTime"`
BagGift *bool `json:"bagGift"`
Accepts []*giftEventUser `json:"accepts"`
GiftConfig giftEventGift `json:"giftConfig"`
GiftValue giftEventValue `json:"giftValue"`
}
type giftEventUser struct {
AcceptUserID FlexibleInt64 `json:"acceptUserId"`
}
type giftEventGift struct {
ID FlexibleInt64 `json:"id"`
GiftCandy model.Decimal24_2 `json:"giftCandy"`
Type string `json:"type"`
GiftTab string `json:"giftTab"`
}
type giftEventValue struct {
Bag *bool `json:"bag"`
}
// exactInt64 对送礼金额字段只接受整数,禁止把小数金币四舍五入后改变活动积分。
type exactInt64 int64
func (v *exactInt64) UnmarshalJSON(data []byte) error {
raw := strings.TrimSpace(string(data))
if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) {
unquoted, err := strconv.Unquote(raw)
if err != nil {
return err
}
raw = strings.TrimSpace(unquoted)
}
if raw == "" || raw == "null" {
*v = 0
return nil
}
integerPart := raw
if dot := strings.IndexByte(raw, '.'); dot >= 0 {
integerPart = raw[:dot]
fraction := raw[dot+1:]
// 金币字段允许生产者输出 30.00,但拒绝指数和任何非零小数;全程不经 float64
// 避免大于 2^53 的合法 int64 被 IEEE-754 静默改值。
if integerPart == "" || fraction == "" || strings.Trim(fraction, "0") != "" || strings.ContainsAny(integerPart, "eE") {
*v = 0
return nil
}
}
if strings.ContainsAny(integerPart, "eE") {
*v = 0
return nil
}
parsed, err := strconv.ParseInt(integerPart, 10, 64)
if err != nil {
*v = 0
return nil
}
*v = exactInt64(parsed)
return nil
}
func (v exactInt64) Int64() int64 { return int64(v) }
type messageEnvelope struct {
Tag string `json:"tag"`
Body json.RawMessage `json:"body"`
}
func millis(value *time.Time) int64 {
if value == nil || value.IsZero() {
return 0
}
return value.UnixMilli()
}

View File

@ -2,7 +2,6 @@ CREATE TABLE IF NOT EXISTS `first_recharge_reward_config` (
`id` bigint NOT NULL COMMENT '主键ID', `id` bigint NOT NULL COMMENT '主键ID',
`sys_origin` varchar(32) NOT NULL COMMENT '来源系统', `sys_origin` varchar(32) NOT NULL COMMENT '来源系统',
`enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用', `enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用',
`min_app_version` varchar(32) NOT NULL DEFAULT '1.5.0' COMMENT '最低生效App版本',
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
`update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
@ -15,7 +14,6 @@ CREATE TABLE IF NOT EXISTS `first_recharge_reward_level` (
`config_id` bigint NOT NULL COMMENT '首冲奖励主配置ID', `config_id` bigint NOT NULL COMMENT '首冲奖励主配置ID',
`level` int NOT NULL COMMENT '档位', `level` int NOT NULL COMMENT '档位',
`recharge_amount_cents` bigint NOT NULL DEFAULT '0' COMMENT '充值金额门槛,单位为分', `recharge_amount_cents` bigint NOT NULL DEFAULT '0' COMMENT '充值金额门槛,单位为分',
`google_product_id` varchar(128) NOT NULL DEFAULT '' COMMENT 'Google商品ID',
`reward_group_id` bigint NOT NULL DEFAULT '0' COMMENT '奖励资源组ID', `reward_group_id` bigint NOT NULL DEFAULT '0' COMMENT '奖励资源组ID',
`reward_group_name` varchar(255) NOT NULL DEFAULT '' COMMENT '奖励资源组名称快照', `reward_group_name` varchar(255) NOT NULL DEFAULT '' COMMENT '奖励资源组名称快照',
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用', `enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用',
@ -24,7 +22,6 @@ CREATE TABLE IF NOT EXISTS `first_recharge_reward_level` (
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `uk_first_recharge_reward_level` (`config_id`, `level`), UNIQUE KEY `uk_first_recharge_reward_level` (`config_id`, `level`),
UNIQUE KEY `uk_first_recharge_reward_amount` (`config_id`, `recharge_amount_cents`), UNIQUE KEY `uk_first_recharge_reward_amount` (`config_id`, `recharge_amount_cents`),
KEY `idx_first_recharge_reward_level_google_product` (`google_product_id`),
KEY `idx_first_recharge_reward_level_config` (`config_id`) KEY `idx_first_recharge_reward_level_config` (`config_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励充值档位'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励充值档位';
@ -45,7 +42,6 @@ CREATE TABLE IF NOT EXISTS `first_recharge_reward_grant_record` (
`level` int NOT NULL COMMENT '命中档位', `level` int NOT NULL COMMENT '命中档位',
`pay_platform` varchar(64) NOT NULL DEFAULT '' COMMENT '支付平台GOOGLE/MIFA_PAY/PAYER_MAX等', `pay_platform` varchar(64) NOT NULL DEFAULT '' COMMENT '支付平台GOOGLE/MIFA_PAY/PAYER_MAX等',
`payment_method` varchar(32) NOT NULL DEFAULT '' COMMENT '充值方式GOOGLE/THIRD_PARTY', `payment_method` varchar(32) NOT NULL DEFAULT '' COMMENT '充值方式GOOGLE/THIRD_PARTY',
`google_product_id` varchar(128) NOT NULL DEFAULT '' COMMENT 'Google商品ID',
`source_order_id` varchar(128) NOT NULL DEFAULT '' COMMENT '来源订单ID', `source_order_id` varchar(128) NOT NULL DEFAULT '' COMMENT '来源订单ID',
`reward_group_id` bigint NOT NULL DEFAULT '0' COMMENT '奖励资源组ID', `reward_group_id` bigint NOT NULL DEFAULT '0' COMMENT '奖励资源组ID',
`reward_group_name` varchar(255) NOT NULL DEFAULT '' COMMENT '奖励资源组名称快照', `reward_group_name` varchar(255) NOT NULL DEFAULT '' COMMENT '奖励资源组名称快照',
@ -62,6 +58,5 @@ CREATE TABLE IF NOT EXISTS `first_recharge_reward_grant_record` (
UNIQUE KEY `uk_first_recharge_reward_event` (`event_id`), UNIQUE KEY `uk_first_recharge_reward_event` (`event_id`),
UNIQUE KEY `uk_first_recharge_reward_user` (`sys_origin`, `user_id`), UNIQUE KEY `uk_first_recharge_reward_user` (`sys_origin`, `user_id`),
KEY `idx_first_recharge_reward_status` (`sys_origin`, `user_id`, `status`), KEY `idx_first_recharge_reward_status` (`sys_origin`, `user_id`, `status`),
KEY `idx_first_recharge_reward_grant_google_product` (`google_product_id`),
KEY `idx_first_recharge_reward_order` (`source_order_id`) KEY `idx_first_recharge_reward_order` (`source_order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励发放记录'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励发放记录';

View File

@ -1,71 +0,0 @@
SET @current_schema := DATABASE();
SET @add_first_recharge_reward_min_app_version_sql := IF(
NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'first_recharge_reward_config'
AND column_name = 'min_app_version'
),
'ALTER TABLE `first_recharge_reward_config` ADD COLUMN `min_app_version` varchar(32) NOT NULL DEFAULT ''1.5.0'' COMMENT ''最低生效App版本'' AFTER `enabled`',
'SELECT 1'
);
PREPARE add_first_recharge_reward_min_app_version_stmt FROM @add_first_recharge_reward_min_app_version_sql;
EXECUTE add_first_recharge_reward_min_app_version_stmt;
DEALLOCATE PREPARE add_first_recharge_reward_min_app_version_stmt;
SET @add_first_recharge_reward_level_google_product_sql := IF(
NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'first_recharge_reward_level'
AND column_name = 'google_product_id'
),
'ALTER TABLE `first_recharge_reward_level` ADD COLUMN `google_product_id` varchar(128) NOT NULL DEFAULT '''' COMMENT ''Google商品ID'' AFTER `recharge_amount_cents`',
'SELECT 1'
);
PREPARE add_first_recharge_reward_level_google_product_stmt FROM @add_first_recharge_reward_level_google_product_sql;
EXECUTE add_first_recharge_reward_level_google_product_stmt;
DEALLOCATE PREPARE add_first_recharge_reward_level_google_product_stmt;
SET @add_first_recharge_reward_record_google_product_sql := IF(
NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'first_recharge_reward_grant_record'
AND column_name = 'google_product_id'
),
'ALTER TABLE `first_recharge_reward_grant_record` ADD COLUMN `google_product_id` varchar(128) NOT NULL DEFAULT '''' COMMENT ''Google商品ID'' AFTER `payment_method`',
'SELECT 1'
);
PREPARE add_first_recharge_reward_record_google_product_stmt FROM @add_first_recharge_reward_record_google_product_sql;
EXECUTE add_first_recharge_reward_record_google_product_stmt;
DEALLOCATE PREPARE add_first_recharge_reward_record_google_product_stmt;
SET @add_first_recharge_reward_level_google_product_idx_sql := IF(
NOT EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = @current_schema
AND table_name = 'first_recharge_reward_level'
AND index_name = 'idx_first_recharge_reward_level_google_product'
),
'ALTER TABLE `first_recharge_reward_level` ADD INDEX `idx_first_recharge_reward_level_google_product` (`google_product_id`)',
'SELECT 1'
);
PREPARE add_first_recharge_reward_level_google_product_idx_stmt FROM @add_first_recharge_reward_level_google_product_idx_sql;
EXECUTE add_first_recharge_reward_level_google_product_idx_stmt;
DEALLOCATE PREPARE add_first_recharge_reward_level_google_product_idx_stmt;
SET @add_first_recharge_reward_record_google_product_idx_sql := IF(
NOT EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = @current_schema
AND table_name = 'first_recharge_reward_grant_record'
AND index_name = 'idx_first_recharge_reward_grant_google_product'
),
'ALTER TABLE `first_recharge_reward_grant_record` ADD INDEX `idx_first_recharge_reward_grant_google_product` (`google_product_id`)',
'SELECT 1'
);
PREPARE add_first_recharge_reward_record_google_product_idx_stmt FROM @add_first_recharge_reward_record_google_product_idx_sql;
EXECUTE add_first_recharge_reward_record_google_product_idx_stmt;
DEALLOCATE PREPARE add_first_recharge_reward_record_google_product_idx_stmt;

View File

@ -1,17 +0,0 @@
-- Limit LIKEI app game-list rows to the Turkey region.
-- Production Turkey Region observed in sys_region_config: 2046066409959649281.
-- Re-check sys_region_config before production execution if region data has been rebuilt.
-- After direct SQL execution, clear Redis GAME_LIST* cache or wait for its 1-hour TTL.
START TRANSACTION;
SET @sys_origin := 'LIKEI';
SET @turkey_region_id := '2046066409959649281';
UPDATE sys_game_list_config
SET regions = @turkey_region_id,
update_time = NOW(3)
WHERE sys_origin = @sys_origin
AND is_showcase = 1;
COMMIT;

View File

@ -1,106 +0,0 @@
CREATE TABLE IF NOT EXISTS hotgame_provider_config (
id BIGINT NOT NULL PRIMARY KEY,
sys_origin VARCHAR(32) NOT NULL,
profile VARCHAR(32) NOT NULL DEFAULT 'PROD',
active TINYINT(1) NOT NULL DEFAULT 1,
app_key VARCHAR(255) NOT NULL DEFAULT '',
callback_base_url VARCHAR(1024) NOT NULL DEFAULT '',
test_uid VARCHAR(64) DEFAULT NULL,
test_token VARCHAR(1024) DEFAULT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_hotgame_provider_sys_origin_profile (sys_origin, profile),
KEY idx_hotgame_provider_active (sys_origin, active, update_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS hotgame_game_catalog (
id BIGINT NOT NULL PRIMARY KEY,
sys_origin VARCHAR(32) NOT NULL,
profile VARCHAR(32) NOT NULL DEFAULT 'PROD',
internal_game_id VARCHAR(64) NOT NULL,
vendor_game_id VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
cover VARCHAR(1024) DEFAULT NULL,
preview_url VARCHAR(1024) DEFAULT NULL,
download_url VARCHAR(1024) DEFAULT NULL,
package_version VARCHAR(32) DEFAULT NULL,
raw_json LONGTEXT DEFAULT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'ENABLED',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_hotgame_catalog_sys_profile_vendor_game (sys_origin, profile, vendor_game_id),
KEY idx_hotgame_catalog_profile_internal_game (sys_origin, profile, internal_game_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO hotgame_provider_config (
id, sys_origin, profile, active, app_key, callback_base_url, create_time, update_time
) VALUES
(202606100520001, 'LIKEI', 'PROD', 1, 'laluprodappkey', 'https://api.global-interaction.com/', NOW(), NOW()),
(202606100520002, 'LIKEI', 'TEST', 0, 'lalutestappkey', 'https://api-test.global-interaction.com/', NOW(), NOW())
ON DUPLICATE KEY UPDATE
app_key = IF(TRIM(app_key) = '', VALUES(app_key), app_key),
callback_base_url = IF(TRIM(callback_base_url) = '', VALUES(callback_base_url), callback_base_url),
update_time = NOW();
INSERT INTO hotgame_game_catalog (
id, sys_origin, profile, internal_game_id, vendor_game_id, name, cover,
preview_url, download_url, package_version, raw_json, status, create_time, update_time
) VALUES
(202606100520101, 'LIKEI', 'TEST', 'hg_1', '1', '幸运77(Lucky77)', '',
'https://hk.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}',
'https://hk.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}',
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
(202606100520102, 'LIKEI', 'TEST', 'hg_13', '13', '海盗捕鱼(PirateFishing)', '',
'https://hk.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'https://hk.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
(202606100520103, 'LIKEI', 'TEST', 'hg_19', '19', 'FortuneSlot (宝石solt)', '',
'https://hk.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'https://hk.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
(202606100520104, 'LIKEI', 'TEST', 'hg_15', '15', '美味摩天轮(Delicious)', '',
'https://hk.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'https://hk.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
(202606100520105, 'LIKEI', 'TEST', 'hg_20', '20', '水果派对(FruitParty)', '',
'https://hk.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'https://hk.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
(202606100520106, 'LIKEI', 'TEST', 'hg_16', '16', '太空摩天轮(GreedyStar)', '',
'https://hk.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'https://hk.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
(202606100520201, 'LIKEI', 'PROD', 'hg_1', '1', '幸运77(Lucky77)', '',
'https://cdn-res.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}',
'https://cdn-res.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}',
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
(202606100520202, 'LIKEI', 'PROD', 'hg_13', '13', '海盗捕鱼(PirateFishing)', '',
'https://cdn-res.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'https://cdn-res.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
(202606100520203, 'LIKEI', 'PROD', 'hg_19', '19', 'FortuneSlot (宝石solt)', '',
'https://cdn-res.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'https://cdn-res.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
(202606100520204, 'LIKEI', 'PROD', 'hg_15', '15', '美味摩天轮(Delicious)', '',
'https://cdn-res.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'https://cdn-res.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
(202606100520205, 'LIKEI', 'PROD', 'hg_20', '20', '水果派对(FruitParty)', '',
'https://cdn-res.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'https://cdn-res.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
(202606100520206, 'LIKEI', 'PROD', 'hg_16', '16', '太空摩天轮(GreedyStar)', '',
'https://cdn-res.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'https://cdn-res.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW())
ON DUPLICATE KEY UPDATE
internal_game_id = VALUES(internal_game_id),
name = VALUES(name),
cover = VALUES(cover),
preview_url = VALUES(preview_url),
download_url = VALUES(download_url),
package_version = VALUES(package_version),
raw_json = VALUES(raw_json),
status = VALUES(status),
update_time = NOW();

View File

@ -1,17 +0,0 @@
SET @current_schema := DATABASE();
SET @modify_sys_game_list_cover_sql := IF(
EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'sys_game_list_config'
AND column_name = 'cover'
),
'ALTER TABLE `sys_game_list_config` MODIFY COLUMN `cover` VARCHAR(1024) DEFAULT NULL COMMENT ''封面图''',
'SELECT 1'
);
PREPARE modify_sys_game_list_cover_stmt FROM @modify_sys_game_list_cover_sql;
EXECUTE modify_sys_game_list_cover_stmt;
DEALLOCATE PREPARE modify_sys_game_list_cover_stmt;

View File

@ -1,61 +0,0 @@
SET @table_schema = DATABASE();
SET @ddl = IF(
EXISTS(
SELECT 1
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @table_schema
AND TABLE_NAME = 'team_manager_base_info'
AND COLUMN_NAME = 'can_add_bd_leader'
),
'SELECT 1',
"ALTER TABLE `team_manager_base_info` ADD COLUMN `can_add_bd_leader` tinyint(1) NOT NULL DEFAULT 1 COMMENT '经理中心是否允许添加BD Leader' AFTER `region_id`"
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = IF(
EXISTS(
SELECT 1
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @table_schema
AND TABLE_NAME = 'team_manager_base_info'
AND COLUMN_NAME = 'can_gift_props'
),
'SELECT 1',
"ALTER TABLE `team_manager_base_info` ADD COLUMN `can_gift_props` tinyint(1) NOT NULL DEFAULT 1 COMMENT '经理中心是否允许赠送道具' AFTER `can_add_bd_leader`"
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = IF(
EXISTS(
SELECT 1
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @table_schema
AND TABLE_NAME = 'team_manager_base_info'
AND COLUMN_NAME = 'can_ban_user'
),
'SELECT 1',
"ALTER TABLE `team_manager_base_info` ADD COLUMN `can_ban_user` tinyint(1) NOT NULL DEFAULT 1 COMMENT '经理中心是否允许封禁用户' AFTER `can_gift_props`"
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = IF(
EXISTS(
SELECT 1
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @table_schema
AND TABLE_NAME = 'team_manager_base_info'
AND COLUMN_NAME = 'can_unban_user'
),
'SELECT 1',
"ALTER TABLE `team_manager_base_info` ADD COLUMN `can_unban_user` tinyint(1) NOT NULL DEFAULT 1 COMMENT '经理中心是否允许解封用户' AFTER `can_ban_user`"
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -1,16 +0,0 @@
SET @table_schema = DATABASE();
SET @ddl = IF(
EXISTS(
SELECT 1
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @table_schema
AND TABLE_NAME = 'team_manager_base_info'
AND COLUMN_NAME = 'can_change_country'
),
'SELECT 1',
"ALTER TABLE `team_manager_base_info` ADD COLUMN `can_change_country` tinyint(1) NOT NULL DEFAULT 1 COMMENT '经理中心是否允许修改用户国家' AFTER `can_unban_user`"
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

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