feat: add Yumi activity services

This commit is contained in:
hy001 2026-07-18 15:32:58 +08:00
parent fb43e3e9fa
commit 3c9f10f21a
37 changed files with 8224 additions and 17 deletions

View File

@ -18,6 +18,7 @@ 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"
@ -41,6 +42,7 @@ 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() {
@ -67,6 +69,7 @@ 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)
@ -80,6 +83,7 @@ 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)
@ -88,6 +92,8 @@ 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()
@ -97,6 +103,9 @@ 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)
} }
@ -129,6 +138,7 @@ func main() {
FirstRechargeReward: firstRechargeRewardService, FirstRechargeReward: firstRechargeRewardService,
Lingxian: lingxianService, Lingxian: lingxianService,
GameOpen: gameOpenService, GameOpen: gameOpenService,
GameKing: gameKingService,
GameProviders: gameProviders, GameProviders: gameProviders,
GameVisibility: gameVisibility, GameVisibility: gameVisibility,
LuckyGift: luckyGiftService, LuckyGift: luckyGiftService,
@ -150,6 +160,7 @@ 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,12 +4,14 @@ 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"
@ -28,7 +30,11 @@ 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)
@ -46,6 +52,9 @@ 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

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

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

@ -25,6 +25,7 @@ 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
@ -172,6 +173,13 @@ 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
@ -315,6 +323,12 @@ 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,6 +474,22 @@ 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"},

View File

@ -56,6 +56,16 @@ 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)
@ -126,6 +136,11 @@ 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)
@ -141,6 +156,16 @@ func (g *Gateways) GetUserProfile(ctx context.Context, userID int64) (UserProfil
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 用户国家更新接口。 // UpdateUserCountry 透传到 Java 用户国家更新接口。
func (g *Gateways) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error { func (g *Gateways) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error {
return g.Profile.UpdateUserCountry(ctx, userID, countryID) return g.Profile.UpdateUserCountry(ctx, userID, countryID)
@ -281,6 +306,16 @@ 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
@ -370,6 +405,11 @@ 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
@ -405,6 +445,16 @@ 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 更新用户国家。 // UpdateUserCountry 通过 Java other 更新用户国家。
func (g *ProfileGateway) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error { func (g *ProfileGateway) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error {
return g.client.UpdateUserCountry(ctx, userID, countryID) return g.client.UpdateUserCountry(ctx, userID, countryID)

View File

@ -41,6 +41,11 @@ 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"`
@ -199,6 +204,30 @@ 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
@ -245,17 +274,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"`
} }
@ -346,11 +378,13 @@ 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"`
Body T `json:"body"` ErrorMsg string `json:"errorMsg"`
Data T `json:"data"` Body T `json:"body"`
Success *bool `json:"success"` Data T `json:"data"`
Success *bool `json:"success"`
Status *bool `json:"status"`
} }
type Int64Value int64 type Int64Value int64
@ -513,6 +547,63 @@ 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))
@ -632,6 +723,22 @@ 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 ") {
@ -649,7 +756,40 @@ 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 {
return c.postJSON(ctx, c.cfg.Java.OtherBaseURL+"/props-activity-cnf/client/sendActivityReward", req, nil, nil) var resp resultResponse[any]
if err := c.postJSON(ctx, c.cfg.Java.OtherBaseURL+"/props-activity-cnf/client/sendActivityReward", req, nil, &resp); err != nil {
return err
}
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
message := strings.TrimSpace(resp.Message)
if message == "" {
message = "send activity reward failed"
}
return errors.New(message)
}
return nil
}
// SendFrozenActivityReward 通过 Java 已有的 PrizeDescribe 接口发放一个冻结奖励项。
func (c *Client) SendFrozenActivityReward(ctx context.Context, req SendFrozenActivityRewardRequest) error {
if req.TrackID <= 0 || req.AcceptUserID <= 0 || len(req.Prizes) != 1 {
return fmt.Errorf("invalid frozen activity reward request")
}
var resp resultResponse[any]
if err := c.postJSON(ctx, strings.TrimRight(c.cfg.Java.OtherBaseURL, "/")+"/props/tool/client/sendProps", req, nil, &resp); err != nil {
return err
}
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
message := strings.TrimSpace(resp.ErrorMsg)
if message == "" {
message = strings.TrimSpace(resp.Message)
}
if message == "" {
message = "send frozen activity reward failed"
}
return errors.New(message)
}
return nil
} }
func (c *Client) GivePropsBackpack(ctx context.Context, req GivePropsBackpackRequest) error { func (c *Client) GivePropsBackpack(ctx context.Context, req GivePropsBackpackRequest) error {
@ -974,8 +1114,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 { if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
message := strings.TrimSpace(resp.Message) message := strings.TrimSpace(firstNonEmpty(resp.ErrorMsg, resp.Message))
if message == "" { if message == "" {
message = "freight balance change failed" message = "freight balance change failed"
} }
@ -1034,6 +1174,60 @@ 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 处理。 // UpdateUserCountry 只提交稳定的用户 ID 和国家 ID国家码、名称以及并发锁统一由 Java other 处理。
func (c *Client) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error { func (c *Client) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error {
if userID <= 0 { if userID <= 0 {

View File

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

@ -116,12 +116,12 @@ 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"` ID int64 `gorm:"column:id;primaryKey;index:idx_task_center_archive_claim_create,priority:3;index:idx_task_center_archive_claim_stale,priority:3;index:idx_task_center_game_king_backfill,priority:4"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_task_center_archive_event,priority:1"` 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"`
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"` EventType string `gorm:"column:event_type;size:64;index:idx_task_center_game_king_backfill,priority:2"`
UserID int64 `gorm:"column:user_id"` UserID int64 `gorm:"column:user_id"`
OccurredAt time.Time `gorm:"column:occurred_at"` OccurredAt time.Time `gorm:"column:occurred_at;index:idx_task_center_game_king_backfill,priority:3"`
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;index:idx_task_center_archive_claim_create,priority:1;index:idx_task_center_archive_claim_stale,priority:1"`
RetryCount int `gorm:"column:retry_count"` RetryCount int `gorm:"column:retry_count"`

View File

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

View File

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

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

@ -2,6 +2,7 @@ package router
import ( import (
"context" "context"
"crypto/subtle"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@ -19,6 +20,14 @@ 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) {
@ -73,6 +82,58 @@ 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) {
@ -92,6 +153,29 @@ 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)

View File

@ -11,6 +11,7 @@ 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"
@ -34,6 +35,7 @@ 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"
) )
@ -48,6 +50,7 @@ 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 GameVisibility *gameprovider.VisibilityPolicy
LuckyGift *luckygift.LuckyGiftService LuckyGift *luckygift.LuckyGiftService
@ -69,6 +72,7 @@ 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 路由,并按模块注册所有接口。
@ -92,6 +96,7 @@ 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)
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)
@ -100,6 +105,7 @@ 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)

View File

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

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

View File

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

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

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

View File

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

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

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

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

@ -67,6 +67,22 @@ 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

@ -62,13 +62,32 @@ 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
db taskCenterDB db taskCenterDB
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 {
@ -83,6 +102,13 @@ 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

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

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

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

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

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

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

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

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

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

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

@ -0,0 +1,221 @@
-- Yumi Game King: canonical GAME_CONSUME_GOLD -> draw chances, deterministic ranking and settlement.
--
-- Performance review:
-- 1. The event path first resolves the very small set of enabled activities by
-- (sys_origin, enabled, start_time, end_time), then inserts a unique activity/event ledger row
-- and updates one aggregate primary key. It never scans wallet shards or arbitrary expenses.
-- 2. Ranking reads the materialized aggregate through
-- (activity_id, total_consumed DESC, consume_reached_time ASC, user_id ASC); settlement freezes
-- only Top 30 and does not GROUP BY the append-only ledger.
-- 3. Draw idempotency, settlement idempotency, finite-stock reservation and reward delivery claims
-- all use primary/unique keys. Unlimited stock (-1) is not updated and therefore avoids a hot row.
-- 4. Backfill binds sys_origin + GAME_CONSUME_GOLD and keysets by (occurred_at,id) through the added
-- index. The public lastId is resolved to its scoped occurred_at before each page, preserving the
-- lastId-only contract without sorting/rescanning the whole activity window.
-- 5. The activity tables are empty, but the backfill index targets the existing production
-- task_center_event_archive_outbox (about 35 million rows / 50 GiB at review time). Treat that ALTER
-- as an independent online DDL: run it before the application rollout, monitor metadata-lock waits,
-- temporary disk usage and replica lag until completion, and do not hide it inside a service restart.
-- 6. The ALTER explicitly requires ALGORITHM=INPLACE and LOCK=NONE. If the target MySQL version or table
-- state cannot honor those guarantees, the migration must fail instead of silently falling back to a
-- table-copy or write-blocking algorithm. It performs no UPDATE/DELETE/backfill.
-- 7. The one-row-per-sysOrigin scope lock is touched only by admin enable/save transactions; it keeps
-- concurrent overlap checks correct without serializing the high-volume game event path.
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS `yumi_game_king_activity` (
`id` BIGINT NOT NULL,
`activity_code` VARCHAR(64) NOT NULL,
`activity_name` VARCHAR(128) NOT NULL,
`activity_desc` VARCHAR(1000) NOT NULL DEFAULT '',
`sys_origin` VARCHAR(32) NOT NULL,
`time_zone` VARCHAR(64) NOT NULL DEFAULT 'Asia/Riyadh',
`start_time` DATETIME(3) NOT NULL,
`end_time` DATETIME(3) NOT NULL,
`settlement_delay_minutes` INT NOT NULL DEFAULT 30,
`settlement_time` DATETIME(3) NOT NULL,
`coin_per_draw` BIGINT NOT NULL,
`ranking_type` VARCHAR(32) NOT NULL DEFAULT 'TYCOON',
`ranking_period` VARCHAR(32) NOT NULL DEFAULT 'OVERALL',
`enabled` TINYINT(1) NOT NULL DEFAULT 0,
`settlement_status` VARCHAR(32) NOT NULL DEFAULT 'NOT_STARTED',
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_yumi_gk_activity_code` (`activity_code`),
KEY `idx_yumi_gk_activity_window` (`sys_origin`, `enabled`, `start_time`, `end_time`),
KEY `idx_yumi_gk_activity_settle` (`settlement_status`, `settlement_time`, `end_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Yumi Game King activity';
CREATE TABLE IF NOT EXISTS `yumi_game_king_scope_lock` (
`sys_origin` VARCHAR(32) NOT NULL,
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`sys_origin`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Serializes enabled-window checks per Yumi tenant';
CREATE TABLE IF NOT EXISTS `yumi_game_king_prize` (
`id` BIGINT NOT NULL,
`activity_id` BIGINT NOT NULL,
`prize_name` VARCHAR(128) NOT NULL,
`prize_image` VARCHAR(500) NOT NULL DEFAULT '',
`weight` BIGINT NOT NULL,
`stock` BIGINT NOT NULL DEFAULT -1 COMMENT '-1 unlimited, 0 depleted, positive remaining',
`resource_group_id` BIGINT NOT NULL,
`reward_group_name` VARCHAR(128) NOT NULL DEFAULT '',
`reward_items_json` TEXT NOT NULL,
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`sort_order` TINYINT NOT NULL,
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_yumi_gk_prize_position` (`activity_id`, `sort_order`),
KEY `idx_yumi_gk_prize_enabled` (`activity_id`, `enabled`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Seven fixed Yumi Game King prizes';
CREATE TABLE IF NOT EXISTS `yumi_game_king_rank_reward` (
`id` BIGINT NOT NULL,
`activity_id` BIGINT NOT NULL,
`start_rank` SMALLINT NOT NULL,
`end_rank` SMALLINT NOT NULL,
`resource_group_id` BIGINT NOT NULL,
`reward_name` VARCHAR(128) NOT NULL DEFAULT '',
`reward_items_json` TEXT NOT NULL,
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_yumi_gk_rank_range` (`activity_id`, `start_rank`, `end_rank`),
KEY `idx_yumi_gk_rank_start` (`activity_id`, `start_rank`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Six fixed end-settlement rank reward ranges';
CREATE TABLE IF NOT EXISTS `yumi_game_king_consume_ledger` (
`id` BIGINT NOT NULL,
`activity_id` BIGINT NOT NULL,
`event_id` VARCHAR(128) NOT NULL,
`user_id` BIGINT NOT NULL,
`sys_origin` VARCHAR(32) NOT NULL,
`amount` BIGINT NOT NULL,
`event_time` DATETIME(3) NOT NULL,
`payload_json` TEXT NOT NULL,
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_yumi_gk_ledger_event` (`activity_id`, `event_id`),
KEY `idx_yumi_gk_ledger_user` (`activity_id`, `user_id`, `event_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Idempotent canonical game-consumption ledger';
CREATE TABLE IF NOT EXISTS `yumi_game_king_user` (
`activity_id` BIGINT NOT NULL,
`user_id` BIGINT NOT NULL,
`total_consumed` BIGINT NOT NULL DEFAULT 0,
`used_chances` BIGINT NOT NULL DEFAULT 0,
`consume_reached_time` DATETIME(3) NOT NULL,
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`activity_id`, `user_id`),
KEY `idx_yumi_gk_user_rank` (
`activity_id`, `total_consumed` DESC, `consume_reached_time` ASC, `user_id` ASC
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Overall game-consumption aggregate and draw chance usage';
CREATE TABLE IF NOT EXISTS `yumi_game_king_user_daily` (
`activity_id` BIGINT NOT NULL,
`stat_date` DATE NOT NULL,
`user_id` BIGINT NOT NULL,
`total_consumed` BIGINT NOT NULL DEFAULT 0,
`consume_reached_time` DATETIME(3) NOT NULL,
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`activity_id`, `stat_date`, `user_id`),
KEY `idx_yumi_gk_daily_rank` (
`activity_id`, `stat_date`, `total_consumed` DESC, `consume_reached_time` ASC, `user_id` ASC
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Activity-timezone daily game-consumption aggregate';
CREATE TABLE IF NOT EXISTS `yumi_game_king_draw_record` (
`id` BIGINT NOT NULL,
`activity_id` BIGINT NOT NULL,
`user_id` BIGINT NOT NULL,
`request_id` VARCHAR(64) NOT NULL,
`prize_id` BIGINT NOT NULL,
`prize_name` VARCHAR(128) NOT NULL,
`prize_image` VARCHAR(500) NOT NULL DEFAULT '',
`resource_group_id` BIGINT NOT NULL,
`delivery_status` VARCHAR(32) NOT NULL,
`retry_count` INT NOT NULL DEFAULT 0,
`failure_reason` VARCHAR(500) NOT NULL DEFAULT '',
`draw_time` DATETIME(3) NOT NULL,
`deliver_time` DATETIME(3) NULL,
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_yumi_gk_draw_request` (`activity_id`, `user_id`, `request_id`),
KEY `idx_yumi_gk_draw_user` (`activity_id`, `user_id`, `create_time`),
KEY `idx_yumi_gk_draw_manage` (`activity_id`, `delivery_status`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Idempotent Yumi Game King draw records';
CREATE TABLE IF NOT EXISTS `yumi_game_king_settlement_record` (
`id` BIGINT NOT NULL,
`activity_id` BIGINT NOT NULL,
`user_id` BIGINT NOT NULL,
`rank_no` SMALLINT NOT NULL,
`total_consumed` BIGINT NOT NULL,
`rank_reward_id` BIGINT NOT NULL,
`resource_group_id` BIGINT NOT NULL,
`business_no` VARCHAR(128) NOT NULL,
`delivery_status` VARCHAR(32) NOT NULL,
`retry_count` INT NOT NULL DEFAULT 0,
`failure_reason` VARCHAR(500) NOT NULL DEFAULT '',
`deliver_time` DATETIME(3) NULL,
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_yumi_gk_settle_user` (`activity_id`, `user_id`),
UNIQUE KEY `uk_yumi_gk_settle_rank` (`activity_id`, `rank_no`),
UNIQUE KEY `uk_yumi_gk_settle_business` (`business_no`),
KEY `idx_yumi_gk_settle_status` (`activity_id`, `delivery_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Frozen deterministic Top30 settlement records';
CREATE TABLE IF NOT EXISTS `yumi_game_king_delivery_item` (
`id` BIGINT NOT NULL,
`owner_type` VARCHAR(20) NOT NULL COMMENT 'DRAW or SETTLEMENT',
`owner_id` BIGINT NOT NULL,
`activity_id` BIGINT NOT NULL,
`user_id` BIGINT NOT NULL,
`resource_group_id` BIGINT NOT NULL,
`delivery_status` VARCHAR(32) NOT NULL,
`retry_count` INT NOT NULL DEFAULT 0,
`failure_reason` VARCHAR(500) NOT NULL DEFAULT '',
`deliver_time` DATETIME(3) NULL,
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_yumi_gk_delivery_owner` (`owner_type`, `owner_id`),
KEY `idx_yumi_gk_delivery_status` (`delivery_status`, `update_time`, `id`),
KEY `idx_yumi_gk_delivery_activity` (`activity_id`, `delivery_status`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Group-level reward delivery and UNKNOWN reconciliation';
SET @current_schema := DATABASE();
SET @add_task_center_game_king_backfill_sql := IF(
NOT EXISTS (
SELECT 1
FROM INFORMATION_SCHEMA.STATISTICS
WHERE table_schema = @current_schema
AND table_name = 'task_center_event_archive_outbox'
AND index_name = 'idx_task_center_game_king_backfill'
),
'ALTER TABLE `task_center_event_archive_outbox` ADD INDEX `idx_task_center_game_king_backfill` (`sys_origin`, `event_type`, `occurred_at`, `id`), ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
);
PREPARE add_task_center_game_king_backfill_stmt FROM @add_task_center_game_king_backfill_sql;
EXECUTE add_task_center_game_king_backfill_stmt;
DEALLOCATE PREPARE add_task_center_game_king_backfill_stmt;

View File

@ -0,0 +1,243 @@
-- Yumi Gift Challenge: ordinary GOLD gift ledger, daily/overall ranking, three daily tasks,
-- frozen reward templates and two-phase item-level settlement.
--
-- Performance review:
-- 1. The MQ hot path resolves at most one enabled activity through
-- (sys_origin, enabled, start_time, end_time), deduplicates by a unique event key, and updates
-- one overall plus one daily aggregate primary key. Ranking never GROUP BYs the gift ledger.
-- 2. Daily/overall ranks use covering order indexes matching
-- score DESC, score_reached_time ASC, user_id ASC; user-rank counts remain activity/date scoped.
-- 3. Settlement discovery uses (status, snapshot_due_time), while parent and delivery claims use
-- unique owner/business keys and narrow owner/status indexes. UNKNOWN items are never auto-retried.
-- 4. This migration creates empty tables only: no UPDATE, DELETE, backfill, table-copy ALTER, or
-- unbounded scan is executed. Run DDL under the target database's normal online-DDL policy.
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS `yumi_gift_challenge_activity` (
`id` BIGINT NOT NULL,
`activity_code` VARCHAR(64) NOT NULL,
`activity_name` VARCHAR(128) NOT NULL,
`activity_desc` VARCHAR(1000) NOT NULL DEFAULT '',
`sys_origin` VARCHAR(32) NOT NULL,
`time_zone` VARCHAR(64) NOT NULL DEFAULT 'Asia/Riyadh',
`start_time` DATETIME(3) NOT NULL,
`end_time` DATETIME(3) NOT NULL,
`daily_settlement_delay_minutes` INT NOT NULL DEFAULT 30,
`overall_settlement_delay_minutes` INT NOT NULL DEFAULT 30,
`overall_settlement_time` DATETIME(3) NOT NULL,
`display_top_n` INT NOT NULL DEFAULT 100,
`enabled` TINYINT(1) NOT NULL DEFAULT 0,
`overall_settlement_status` VARCHAR(32) NOT NULL DEFAULT 'NOT_STARTED',
`version` INT NOT NULL DEFAULT 0,
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_yumi_gc_activity_code` (`activity_code`),
KEY `idx_yumi_gc_activity_window` (`sys_origin`, `enabled`, `start_time`, `end_time`),
KEY `idx_yumi_gc_activity_settle` (`overall_settlement_status`, `overall_settlement_time`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Yumi gift challenge activity cycle';
CREATE TABLE IF NOT EXISTS `yumi_gift_challenge_task_config` (
`id` BIGINT NOT NULL,
`activity_id` BIGINT NOT NULL,
`task_code` VARCHAR(64) NOT NULL,
`task_type` VARCHAR(32) NOT NULL,
`task_title` VARCHAR(128) NOT NULL,
`task_desc` VARCHAR(500) NOT NULL DEFAULT '',
`target_value` DECIMAL(24,2) NOT NULL,
`resource_group_id` BIGINT NULL,
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`sort_order` TINYINT NOT NULL,
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_yumi_gc_task_code` (`activity_id`, `task_code`),
UNIQUE KEY `uk_yumi_gc_task_sort` (`activity_id`, `sort_order`),
KEY `idx_yumi_gc_task_enabled` (`activity_id`, `enabled`, `sort_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Exactly three Yumi gift challenge daily tasks';
CREATE TABLE IF NOT EXISTS `yumi_gift_challenge_rank_reward` (
`id` BIGINT NOT NULL,
`activity_id` BIGINT NOT NULL,
`period_type` VARCHAR(16) NOT NULL,
`start_rank` INT NOT NULL,
`end_rank` INT NOT NULL,
`resource_group_id` BIGINT NOT NULL,
`reward_name` VARCHAR(128) NOT NULL DEFAULT '',
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_yumi_gc_rank_range` (`activity_id`, `period_type`, `start_rank`, `end_rank`),
KEY `idx_yumi_gc_rank_match` (`activity_id`, `period_type`, `start_rank`, `end_rank`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Configurable daily and overall rank reward ranges';
CREATE TABLE IF NOT EXISTS `yumi_gift_challenge_reward_snapshot` (
`id` BIGINT NOT NULL,
`activity_id` BIGINT NOT NULL,
`resource_group_id` BIGINT NOT NULL,
`reward_config_id` BIGINT NOT NULL,
`reward_type` VARCHAR(64) NOT NULL,
`detail_type` VARCHAR(64) NOT NULL DEFAULT '',
`content` TEXT NOT NULL,
`quantity` BIGINT NOT NULL,
`sort_order` INT NOT NULL DEFAULT 0,
`remark` VARCHAR(500) NOT NULL DEFAULT '',
`cover` TEXT NOT NULL,
`source_url` TEXT NOT NULL,
`display_name` VARCHAR(500) NOT NULL DEFAULT '',
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_yumi_gc_snapshot_item` (`activity_id`, `resource_group_id`, `reward_config_id`),
KEY `idx_yumi_gc_snapshot_group` (`activity_id`, `resource_group_id`, `sort_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Full reward item template frozen when activity is enabled';
CREATE TABLE IF NOT EXISTS `yumi_gift_challenge_gift_ledger` (
`id` BIGINT NOT NULL,
`activity_id` BIGINT NOT NULL,
`source_event_track_id` VARCHAR(128) NOT NULL,
`user_id` BIGINT NOT NULL,
`gift_id` BIGINT NOT NULL,
`gift_tab` VARCHAR(32) NOT NULL,
`amount` DECIMAL(24,2) NOT NULL,
`event_time` DATETIME(3) NOT NULL,
`stat_date` DATE NOT NULL,
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_yumi_gc_ledger_event` (`activity_id`, `source_event_track_id`),
KEY `idx_yumi_gc_ledger_user` (`activity_id`, `user_id`, `event_time`),
KEY `idx_yumi_gc_ledger_date` (`activity_id`, `stat_date`, `event_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Idempotent ordinary GOLD gift activity ledger';
CREATE TABLE IF NOT EXISTS `yumi_gift_challenge_user_score` (
`activity_id` BIGINT NOT NULL,
`user_id` BIGINT NOT NULL,
`score` DECIMAL(24,2) NOT NULL DEFAULT 0.00,
`score_reached_time` DATETIME(3) NOT NULL,
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`activity_id`, `user_id`),
KEY `idx_yumi_gc_overall_rank` (`activity_id`, `score` DESC, `score_reached_time` ASC, `user_id` ASC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Materialized overall gift challenge score';
CREATE TABLE IF NOT EXISTS `yumi_gift_challenge_user_daily_score` (
`activity_id` BIGINT NOT NULL,
`stat_date` DATE NOT NULL,
`user_id` BIGINT NOT NULL,
`score` DECIMAL(24,2) NOT NULL DEFAULT 0.00,
`score_reached_time` DATETIME(3) NOT NULL,
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`activity_id`, `stat_date`, `user_id`),
KEY `idx_yumi_gc_daily_rank` (`activity_id`, `stat_date`, `score` DESC, `score_reached_time` ASC, `user_id` ASC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Materialized activity-timezone daily gift challenge score';
CREATE TABLE IF NOT EXISTS `yumi_gift_challenge_user_task_daily` (
`id` BIGINT NOT NULL,
`activity_id` BIGINT NOT NULL,
`stat_date` DATE NOT NULL,
`user_id` BIGINT NOT NULL,
`task_config_id` BIGINT NOT NULL,
`task_code` VARCHAR(64) NOT NULL,
`task_type` VARCHAR(32) NOT NULL,
`task_title` VARCHAR(128) NOT NULL,
`sort_order` TINYINT NOT NULL,
`target_value` DECIMAL(24,2) NOT NULL,
`progress_value` DECIMAL(24,2) NOT NULL DEFAULT 0.00,
`completed_time` DATETIME(3) NULL,
`resource_group_id` BIGINT NULL,
`business_no` VARCHAR(160) NOT NULL,
`claim_request_id` VARCHAR(128) NOT NULL DEFAULT '',
`delivery_status` VARCHAR(32) NOT NULL DEFAULT 'NOT_CLAIMED',
`retry_count` INT NOT NULL DEFAULT 0,
`failure_reason` VARCHAR(500) NOT NULL DEFAULT '',
`claimed_time` DATETIME(3) NULL,
`deliver_time` DATETIME(3) NULL,
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_yumi_gc_user_task` (`activity_id`, `stat_date`, `user_id`, `task_code`),
UNIQUE KEY `uk_yumi_gc_task_business` (`business_no`),
KEY `idx_yumi_gc_user_task_page` (`activity_id`, `stat_date`, `user_id`, `sort_order`),
KEY `idx_yumi_gc_task_delivery` (`delivery_status`, `update_time`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Frozen per-user per-day task state and reward owner';
CREATE TABLE IF NOT EXISTS `yumi_gift_challenge_period_settlement` (
`id` BIGINT NOT NULL,
`activity_id` BIGINT NOT NULL,
`period_type` VARCHAR(16) NOT NULL,
`period_key` VARCHAR(16) NOT NULL,
`stat_date` DATE NULL,
`snapshot_due_time` DATETIME(3) NOT NULL,
`status` VARCHAR(32) NOT NULL DEFAULT 'NOT_STARTED',
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_yumi_gc_period` (`activity_id`, `period_type`, `period_key`),
KEY `idx_yumi_gc_period_due` (`status`, `snapshot_due_time`, `id`),
KEY `idx_yumi_gc_period_activity` (`activity_id`, `status`, `period_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Daily and overall write gates including empty periods';
CREATE TABLE IF NOT EXISTS `yumi_gift_challenge_settlement` (
`id` BIGINT NOT NULL,
`activity_id` BIGINT NOT NULL,
`period_type` VARCHAR(16) NOT NULL,
`period_key` VARCHAR(16) NOT NULL,
`stat_date` DATE NULL,
`user_id` BIGINT NOT NULL,
`rank_no` INT NOT NULL,
`score` DECIMAL(24,2) NOT NULL,
`rank_reward_id` BIGINT NOT NULL,
`resource_group_id` BIGINT NOT NULL,
`business_no` VARCHAR(180) NOT NULL,
`delivery_status` VARCHAR(32) NOT NULL DEFAULT 'PENDING',
`retry_count` INT NOT NULL DEFAULT 0,
`failure_reason` VARCHAR(500) NOT NULL DEFAULT '',
`deliver_time` DATETIME(3) NULL,
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_yumi_gc_settle_user` (`activity_id`, `period_type`, `period_key`, `user_id`),
UNIQUE KEY `uk_yumi_gc_settle_rank` (`activity_id`, `period_type`, `period_key`, `rank_no`),
UNIQUE KEY `uk_yumi_gc_settle_business` (`business_no`),
KEY `idx_yumi_gc_settle_status` (`activity_id`, `period_type`, `period_key`, `delivery_status`, `rank_no`),
KEY `idx_yumi_gc_settle_recovery` (`delivery_status`, `update_time`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Frozen rank winner parent records';
CREATE TABLE IF NOT EXISTS `yumi_gift_challenge_delivery_item` (
`id` BIGINT NOT NULL,
`owner_type` VARCHAR(16) NOT NULL COMMENT 'TASK or SETTLEMENT',
`owner_id` BIGINT NOT NULL,
`activity_id` BIGINT NOT NULL,
`user_id` BIGINT NOT NULL,
`reward_config_id` BIGINT NOT NULL,
`resource_group_id` BIGINT NOT NULL,
`reward_type` VARCHAR(64) NOT NULL,
`detail_type` VARCHAR(64) NOT NULL DEFAULT '',
`content` TEXT NOT NULL,
`quantity` BIGINT NOT NULL,
`sort_order` INT NOT NULL DEFAULT 0,
`remark` VARCHAR(500) NOT NULL DEFAULT '',
`delivery_status` VARCHAR(32) NOT NULL DEFAULT 'PENDING',
`retry_count` INT NOT NULL DEFAULT 0,
`failure_reason` VARCHAR(500) NOT NULL DEFAULT '',
`deliver_time` DATETIME(3) NULL,
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_yumi_gc_delivery_item` (`owner_type`, `owner_id`, `reward_config_id`),
KEY `idx_yumi_gc_delivery_claim` (`owner_type`, `owner_id`, `delivery_status`, `sort_order`, `id`),
KEY `idx_yumi_gc_delivery_stale` (`delivery_status`, `update_time`, `id`),
KEY `idx_yumi_gc_delivery_activity` (`activity_id`, `delivery_status`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Item-level idempotent frozen reward delivery';