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/errorlog"
"chatapp3-golang/internal/service/firstrechargereward"
"chatapp3-golang/internal/service/gameking"
"chatapp3-golang/internal/service/gameopen"
"chatapp3-golang/internal/service/gameprovider"
"chatapp3-golang/internal/service/hostcenter"
@ -41,6 +42,7 @@ import (
"chatapp3-golang/internal/service/voiceroomrocket"
"chatapp3-golang/internal/service/weekstar"
"chatapp3-golang/internal/service/wheel"
"chatapp3-golang/internal/service/yumigiftchallenge"
)
func main() {
@ -67,6 +69,7 @@ func main() {
firstRechargeRewardService := firstrechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways)
signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
gameKingService := gameking.NewService(app.Repository.DB, &app.Gateways)
userBadgeService := userbadge.NewService(app.Repository.DB)
vipService := vip.NewService(app.Config, app.Repository.DB, &app.Gateways)
wheelService := wheel.NewService(app.Config, app.Repository.DB, &app.Gateways)
@ -80,6 +83,7 @@ func main() {
voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
voiceRoomRocketService.SetRegionBroadcaster(regionIMGroupService)
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
yumiGiftChallengeService := yumigiftchallenge.NewService(app.Config, app.Repository.DB, &app.Gateways)
roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways)
hostCenterService := hostcenter.NewService(app.Config, &app.Gateways)
managerCenterService := managercenter.NewService(app.Repository.DB, &app.Gateways)
@ -88,6 +92,8 @@ func main() {
baishunService.SetTaskEventReporter(taskCenterService)
gameOpenService.SetTaskEventReporter(taskCenterService)
weekStarService.SetTaskEventReporter(taskCenterService)
// GAME_CONSUME_GOLD 在任务中心完成统一校验后扇出HTTP 与 MQ 重投由活动账本幂等。
taskCenterService.SetGameConsumeSink(gameKingService)
workerCtx, workerCancel := context.WithCancel(context.Background())
defer workerCancel()
@ -97,6 +103,9 @@ func main() {
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start week star message consumer failed: %v", err)
}
if err := yumiGiftChallengeService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start yumi gift challenge message consumer failed: %v", err)
}
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start task center message consumer failed: %v", err)
}
@ -129,6 +138,7 @@ func main() {
FirstRechargeReward: firstRechargeRewardService,
Lingxian: lingxianService,
GameOpen: gameOpenService,
GameKing: gameKingService,
GameProviders: gameProviders,
GameVisibility: gameVisibility,
LuckyGift: luckyGiftService,
@ -150,6 +160,7 @@ func main() {
VoiceRoomRedPacket: voiceRoomRedPacketService,
VoiceRoomRocket: voiceRoomRocketService,
WeekStar: weekStarService,
YumiGiftChallenge: yumiGiftChallengeService,
})
server := &http.Server{
Addr: app.Config.HTTP.ListenAddr,

View File

@ -4,12 +4,14 @@ import (
"chatapp3-golang/internal/bootstrap"
"chatapp3-golang/internal/service/cprelationbroadcast"
"chatapp3-golang/internal/service/firstrechargereward"
"chatapp3-golang/internal/service/gameking"
"chatapp3-golang/internal/service/regionimgroup"
"chatapp3-golang/internal/service/registerreward"
"chatapp3-golang/internal/service/taskcenter"
"chatapp3-golang/internal/service/voiceroomredpacket"
"chatapp3-golang/internal/service/voiceroomrocket"
"chatapp3-golang/internal/service/weekstar"
"chatapp3-golang/internal/service/yumigiftchallenge"
"context"
"log"
"os/signal"
@ -28,7 +30,11 @@ func main() {
registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
yumiGiftChallengeService := yumigiftchallenge.NewService(app.Config, app.Repository.DB, &app.Gateways)
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
gameKingService := gameking.NewService(app.Repository.DB, &app.Gateways)
// consumer 是生产 MQ 的主要入口,必须与 API 直报共用同一个活动幂等账本。
taskCenterService.SetGameConsumeSink(gameKingService)
firstRechargeRewardService := firstrechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways)
regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB, &app.Gateways)
cpRelationBroadcastService := cprelationbroadcast.NewService(app.Config, app.Repository.DB, regionIMGroupService)
@ -46,6 +52,9 @@ func main() {
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start week star message consumer failed: %v", err)
}
if err := yumiGiftChallengeService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start yumi gift challenge message consumer failed: %v", err)
}
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start task center message consumer failed: %v", err)
}

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
TaskCenter TaskCenterConfig
WeekStar WeekStarConfig
YumiGiftChallenge YumiGiftChallengeConfig
RoomTurnoverReward RoomTurnoverRewardConfig
VoiceRoomRedPacket VoiceRoomRedPacketConfig
VoiceRoomRocket VoiceRoomRocketConfig
@ -172,6 +173,13 @@ type WeekStarRocketMQConfig struct {
Tag string
}
// YumiGiftChallengeConfig 保存 Yumi 礼物挑战的独立送礼消费者和发奖租期配置。
type YumiGiftChallengeConfig struct {
DefaultSysOrigin string
DeliveryProcessingLeaseSeconds int
RocketMQ WeekStarRocketMQConfig
}
// RoomTurnoverRewardConfig 保存房间流水奖励模块配置。
type RoomTurnoverRewardConfig struct {
DefaultSysOrigin string
@ -315,6 +323,12 @@ func Load() Config {
weekStarRocketMQEnabledDefault := strings.TrimSpace(weekStarRocketMQEndpoint) != "" &&
strings.TrimSpace(weekStarRocketMQAccessKey) != "" &&
strings.TrimSpace(weekStarRocketMQAccessSecret) != ""
yumiGiftRocketMQEndpoint := getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT", "CHATAPP_WEEK_STAR_ROCKETMQ_ENDPOINT"}, "")
yumiGiftRocketMQAccessKey := getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY", "CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_KEY"}, "")
yumiGiftRocketMQAccessSecret := getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_ACCESS_SECRET", "LIKEI_ROCKETMQ_SECRET_KEY", "CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_SECRET"}, "")
yumiGiftRocketMQEnabledDefault := strings.TrimSpace(yumiGiftRocketMQEndpoint) != "" &&
strings.TrimSpace(yumiGiftRocketMQAccessKey) != "" &&
strings.TrimSpace(yumiGiftRocketMQAccessSecret) != ""
taskCenterMQEndpoint := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ENDPOINT", "TASK_CENTER_EVENT_MQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT"}, "")
taskCenterMQAccessKey := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ACCESS_KEY", "TASK_CENTER_EVENT_MQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY"}, "")
taskCenterMQAccessSecret := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ACCESS_SECRET", "TASK_CENTER_EVENT_MQ_SECRET_KEY", "LIKEI_ROCKETMQ_SECRET_KEY"}, "")
@ -460,6 +474,22 @@ func Load() Config {
Tag: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_TAG", "WEEK_STAR_ROCKETMQ_TAG"}, "give_gift_v3"),
},
},
YumiGiftChallenge: YumiGiftChallengeConfig{
// Java 的 Yumi 业务来源枚举是 LIKEI此默认值不能使用仅供品牌识别的 YUMI。
DefaultSysOrigin: strings.ToUpper(getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_DEFAULT_SYS_ORIGIN"}, "LIKEI")),
DeliveryProcessingLeaseSeconds: getEnvIntAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_DELIVERY_LEASE_SECONDS"}, 300),
RocketMQ: WeekStarRocketMQConfig{
Enabled: getEnvBoolAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_ENABLED"}, yumiGiftRocketMQEnabledDefault),
Endpoint: yumiGiftRocketMQEndpoint,
Namespace: getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_NAMESPACE", "LIKEI_ROCKETMQ_NAMESPACE"}, ""),
AccessKey: yumiGiftRocketMQAccessKey,
AccessSecret: yumiGiftRocketMQAccessSecret,
SecurityToken: getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_SECURITY_TOKEN"}, ""),
ConsumerGroup: getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_CONSUMER_GROUP"}, "YUMI_GIFT_CHALLENGE_GIFT"),
Topic: getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_TOPIC", "CHATAPP_WEEK_STAR_ROCKETMQ_TOPIC"}, "RC_DEFAULT_APP_ORDINARY"),
Tag: getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_TAG", "CHATAPP_WEEK_STAR_ROCKETMQ_TAG"}, "give_gift_v3"),
},
},
RoomTurnoverReward: RoomTurnoverRewardConfig{
DefaultSysOrigin: strings.ToUpper(getEnvAny(
[]string{"CHATAPP_ROOM_TURNOVER_REWARD_DEFAULT_SYS_ORIGIN", "ROOM_TURNOVER_REWARD_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"},

View File

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

View File

@ -41,6 +41,11 @@ type ConsoleAccount struct {
Token string `json:"token"`
}
type ConsoleMenu struct {
Alias string `json:"alias"`
Childrens []ConsoleMenu `json:"childrens"`
}
type UserProfile struct {
ID Int64Value `json:"id"`
Account string `json:"account"`
@ -199,6 +204,30 @@ type SendActivityRewardRequest struct {
AcceptUserID int64 `json:"acceptUserId"`
}
// SendFrozenActivityRewardRequest 直接携带启用时冻结的单个奖励项,避免活动发奖时重新读取已变化的共享奖励组。
type SendFrozenActivityRewardRequest struct {
TrackID int64 `json:"trackId"`
AcceptUserID int64 `json:"acceptUserId"`
Origin string `json:"origin"`
SysOrigin string `json:"sysOrigin"`
Prizes []FrozenActivityReward `json:"prizes"`
}
// FrozenActivityReward 对齐 Java PrizeDescribe内层 TrackID 是资源组商品 ID外层请求 TrackID 才是 delivery item 幂等号。
type FrozenActivityReward struct {
TrackID int64 `json:"trackId"`
Type string `json:"type"`
DetailType string `json:"detailType,omitempty"`
Content string `json:"content,omitempty"`
Quantity int64 `json:"quantity"`
Remark string `json:"remark,omitempty"`
}
type UserVipAbility struct {
UserID Int64Value `json:"userId"`
MysteriousInvisibility bool `json:"mysteriousInvisibility"`
}
type TeamPolicyReleaseQuery struct {
SysOrigin string
Region string
@ -245,17 +274,20 @@ type RewardGroupItem struct {
Type string `json:"type"`
DetailType string `json:"detailType"`
Name string `json:"name"`
BadgeName string `json:"badgeName"`
Content string `json:"content"`
Quantity Int64Value `json:"quantity"`
Cover string `json:"cover"`
SourceURL string `json:"sourceUrl"`
Remark string `json:"remark"`
Sort int `json:"sort"`
}
type RewardGroupDetail struct {
ID Int64Value `json:"id"`
Name string `json:"name"`
SysOrigin string `json:"sysOrigin"`
ShelfStatus *bool `json:"shelfStatus,omitempty"`
RewardConfigList []RewardGroupItem `json:"rewardConfigList"`
}
@ -346,11 +378,13 @@ type freightSellerPageResponse struct {
}
type resultResponse[T any] struct {
Code int `json:"code"`
Message string `json:"message"`
Body T `json:"body"`
Data T `json:"data"`
Success *bool `json:"success"`
Code int `json:"code"`
Message string `json:"message"`
ErrorMsg string `json:"errorMsg"`
Body T `json:"body"`
Data T `json:"data"`
Success *bool `json:"success"`
Status *bool `json:"status"`
}
type Int64Value int64
@ -513,6 +547,63 @@ func (c *Client) AuthenticateConsoleToken(ctx context.Context, authorization str
return resp.Body, nil
}
// ListConsoleButtonAliases 返回当前管理员真实拥有的按钮权限码,供 Go 写接口做服务端授权。
func (c *Client) ListConsoleButtonAliases(ctx context.Context, authorization string) (map[string]struct{}, error) {
authorization = strings.TrimSpace(authorization)
if authorization == "" {
return nil, fmt.Errorf("authorization is blank")
}
endpoint := strings.TrimRight(c.cfg.Java.ConsoleBaseURL, "/") + "/account/buttons/aliases"
var resp resultResponse[[]string]
if err := c.getJSON(ctx, endpoint, javaConsoleHeaders(authorization), &resp); err != nil {
return nil, err
}
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
return nil, errors.New(firstNonEmpty(resp.ErrorMsg, resp.Message, "list console button aliases failed"))
}
aliases := resp.Body
if len(aliases) == 0 {
aliases = resp.Data
}
result := make(map[string]struct{}, len(aliases))
for _, alias := range aliases {
if alias = strings.TrimSpace(alias); alias != "" {
result[alias] = struct{}{}
}
}
return result, nil
}
// HasConsoleMenuAlias 递归检查当前管理员菜单树,作为 Go 管理端读接口的服务端页面授权。
func (c *Client) HasConsoleMenuAlias(ctx context.Context, authorization, requiredAlias string) (bool, error) {
authorization = strings.TrimSpace(authorization)
if authorization == "" {
return false, fmt.Errorf("authorization is blank")
}
endpoint := strings.TrimRight(c.cfg.Java.ConsoleBaseURL, "/") + "/account/menus"
var resp resultResponse[[]ConsoleMenu]
if err := c.getJSON(ctx, endpoint, javaConsoleHeaders(authorization), &resp); err != nil {
return false, err
}
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
return false, errors.New(firstNonEmpty(resp.ErrorMsg, resp.Message, "list console menus failed"))
}
menus := resp.Body
if len(menus) == 0 {
menus = resp.Data
}
var contains func([]ConsoleMenu) bool
contains = func(items []ConsoleMenu) bool {
for _, item := range items {
if strings.TrimSpace(item.Alias) == requiredAlias || contains(item.Childrens) {
return true
}
}
return false
}
return contains(menus), nil
}
func javaConsoleHeaders(authorization string) http.Header {
headers := http.Header{}
headers.Set("Authorization", strings.TrimSpace(authorization))
@ -632,6 +723,22 @@ func javaAppHeadersFromAuthorization(authorization string) http.Header {
return headers
}
// javaInternalAppHeaders 为没有终端 token 的服务端查询注入可信用户上下文,例如榜单隐身能力检查。
func javaInternalAppHeaders(sysOrigin string, userID int64) http.Header {
sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin))
if sysOrigin == "" {
sysOrigin = "LIKEI"
}
headers := http.Header{}
headers.Set("Req-Sys-Origin", "origin="+sysOrigin+";originChild="+sysOrigin)
headers.Set("Req-Client", "Android")
headers.Set("Req-App-Intel", "version=1.0.0;build=100;channel=internal;model=go-service;sysVersion=go")
headers.Set("Req-Zone", "Asia/Shanghai")
headers.Set("Req-Imei", "go-service")
headers.Set("Req-Inner-Internal", fmt.Sprintf("time=%d;uid=%d;", time.Now().UnixMilli(), userID))
return headers
}
func trimBearerToken(authorization string) string {
token := strings.TrimSpace(authorization)
if len(token) >= 7 && strings.EqualFold(token[:7], "Bearer ") {
@ -649,7 +756,40 @@ func (c *Client) GrantProps(ctx context.Context, req GrantPropsRequest) 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 {
@ -974,8 +1114,8 @@ func (c *Client) ChangeFreightBalance(ctx context.Context, req FreightBalanceCha
if err := c.postJSON(ctx, endpoint, req, nil, &resp); err != nil {
return err
}
if resp.Success != nil && !*resp.Success {
message := strings.TrimSpace(resp.Message)
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
message := strings.TrimSpace(firstNonEmpty(resp.ErrorMsg, resp.Message))
if message == "" {
message = "freight balance change failed"
}
@ -1034,6 +1174,60 @@ func (c *Client) GetUserProfile(ctx context.Context, userID int64) (UserProfile,
return resp.Body, nil
}
// MapUserProfiles 批量查询用户资料,避免排行榜逐用户回源造成请求瀑布。
func (c *Client) MapUserProfiles(ctx context.Context, userIDs []int64) (map[int64]UserProfile, error) {
result := make(map[int64]UserProfile, len(userIDs))
if len(userIDs) == 0 {
return result, nil
}
endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/user-profile/client/mapByUserIds"
var resp resultResponse[map[string]UserProfile]
if err := c.postJSON(ctx, endpoint, userIDs, nil, &resp); err != nil {
return nil, err
}
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
message := strings.TrimSpace(firstNonEmpty(resp.Message, resp.ErrorMsg))
if message == "" {
message = "map user profiles failed"
}
return nil, errors.New(message)
}
values := resp.Body
if len(values) == 0 {
values = resp.Data
}
for key, profile := range values {
userID, err := strconv.ParseInt(strings.TrimSpace(key), 10, 64)
if err != nil || userID <= 0 {
userID = int64(profile.ID)
}
if userID > 0 {
result[userID] = profile
}
}
return result, nil
}
// GetUserMysteriousInvisibility 复用 Yumi Java 已有 VIP 能力接口;调用失败由榜单层按隐私优先策略处理。
func (c *Client) GetUserMysteriousInvisibility(ctx context.Context, sysOrigin string, userID int64) (bool, error) {
if userID <= 0 {
return false, fmt.Errorf("userId is required")
}
endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/user/vip/ability"
var resp resultResponse[UserVipAbility]
if err := c.getJSON(ctx, endpoint, javaInternalAppHeaders(sysOrigin, userID), &resp); err != nil {
return false, err
}
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
message := strings.TrimSpace(firstNonEmpty(resp.ErrorMsg, resp.Message))
if message == "" {
message = "get user vip ability failed"
}
return false, errors.New(message)
}
return resp.Body.MysteriousInvisibility, nil
}
// UpdateUserCountry 只提交稳定的用户 ID 和国家 ID国家码、名称以及并发锁统一由 Java other 处理。
func (c *Client) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error {
if userID <= 0 {

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 的原始事件。
type TaskCenterEventArchiveOutbox struct {
ID int64 `gorm:"column:id;primaryKey;index:idx_task_center_archive_claim_create,priority:3;index:idx_task_center_archive_claim_stale,priority:3"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_task_center_archive_event,priority:1"`
ID int64 `gorm:"column:id;primaryKey;index:idx_task_center_archive_claim_create,priority:3;index:idx_task_center_archive_claim_stale,priority:3;index:idx_task_center_game_king_backfill,priority:4"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_task_center_archive_event,priority:1;index:idx_task_center_game_king_backfill,priority:1"`
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"`
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"`
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"`

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 (
"context"
"crypto/subtle"
"net/http"
"strings"
"time"
@ -19,6 +20,14 @@ type authGateway interface {
AuthenticateConsoleToken(ctx context.Context, authorization string) (integration.ConsoleAccount, error)
}
type consolePermissionGateway interface {
ListConsoleButtonAliases(ctx context.Context, authorization string) (map[string]struct{}, error)
}
type consoleMenuGateway interface {
HasConsoleMenuAlias(ctx context.Context, authorization, requiredAlias string) (bool, error)
}
// authMiddleware 校验用户 token并把认证后的用户信息写入上下文。
func authMiddleware(javaClient authGateway) gin.HandlerFunc {
return func(c *gin.Context) {
@ -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 校验内部回调密钥。
func internalSecretMiddleware(secret string) gin.HandlerFunc {
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 上下文读取已经通过鉴权的用户。
func mustAuthUser(c *gin.Context) common.AuthUser {
value, exists := c.Get(authUserContextKey)

View File

@ -11,6 +11,7 @@ import (
"chatapp3-golang/internal/service/binancerecharge"
"chatapp3-golang/internal/service/errorlog"
"chatapp3-golang/internal/service/firstrechargereward"
"chatapp3-golang/internal/service/gameking"
"chatapp3-golang/internal/service/gameopen"
"chatapp3-golang/internal/service/gameprovider"
"chatapp3-golang/internal/service/hostcenter"
@ -34,6 +35,7 @@ import (
"chatapp3-golang/internal/service/voiceroomrocket"
"chatapp3-golang/internal/service/weekstar"
"chatapp3-golang/internal/service/wheel"
"chatapp3-golang/internal/service/yumigiftchallenge"
"github.com/gin-gonic/gin"
)
@ -48,6 +50,7 @@ type Services struct {
FirstRechargeReward *firstrechargereward.Service
Lingxian *lingxian.Service
GameOpen *gameopen.GameOpenService
GameKing *gameking.Service
GameProviders *gameprovider.Registry
GameVisibility *gameprovider.VisibilityPolicy
LuckyGift *luckygift.LuckyGiftService
@ -69,6 +72,7 @@ type Services struct {
VoiceRoomRedPacket *voiceroomredpacket.Service
VoiceRoomRocket *voiceroomrocket.Service
WeekStar *weekstar.WeekStarService
YumiGiftChallenge *yumigiftchallenge.Service
}
// NewRouter 创建 Gin 路由,并按模块注册所有接口。
@ -92,6 +96,7 @@ func NewRouter(
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter)
registerGameKingRoutes(engine, cfg, javaClient, services.GameKing)
registerUserBadgeRoutes(engine, javaClient, services.UserBadge)
registerVipRoutes(engine, javaClient, services.VIP)
registerWheelRoutes(engine, cfg, javaClient, services.Wheel)
@ -100,6 +105,7 @@ func NewRouter(
registerVoiceRoomRedPacketRoutes(engine, javaClient, services.VoiceRoomRedPacket)
registerVoiceRoomRocketRoutes(engine, cfg, javaClient, services.VoiceRoomRocket)
registerWeekStarRoutes(engine, javaClient, services.WeekStar)
registerYumiGiftChallengeRoutes(engine, cfg, javaClient, services.YumiGiftChallenge)
registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward)
registerHostCenterRoutes(engine, services.HostCenter)
registerGameOpenRoutes(engine, cfg, services.GameOpen)

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

View File

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