diff --git a/cmd/api/main.go b/cmd/api/main.go index b495416..83a72d0 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -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, diff --git a/cmd/consumer/main.go b/cmd/consumer/main.go index a3723c9..13e1c94 100644 --- a/cmd/consumer/main.go +++ b/cmd/consumer/main.go @@ -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) } diff --git a/docs/Yumi游戏王活动接口.md b/docs/Yumi游戏王活动接口.md new file mode 100644 index 0000000..233684c --- /dev/null +++ b/docs/Yumi游戏王活动接口.md @@ -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 `;后台沿用 Console 完整 Authorization。 +- 内部任务传 `X-Internal-Token: `。 +- 时间字段均为毫秒时间戳;`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 King;Game King 使用独立账本保证幂等。 + +已接入事件 ID 口径: + +- Java 游戏:`GAME_CONSUME::`,已覆盖 `YOMI`、`LINGXIAN`、`HOTGAME`、`BAISHUN_LEGACY`、`LUDO`、`TURNTABLE`。 +- Go GameOpen:`GAME_OPEN_CONSUME:`。 +- Go HotGame:`GAME_HOTGAME_CONSUME:`。 +- Go Baishun:`BAISHUN_GAME_CONSUME::`。 + +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 策略确认锁影响,并在低峰发布。 diff --git a/docs/yumi_gift_challenge_api.md b/docs/yumi_gift_challenge_api.md new file mode 100644 index 0000000..d85c944 --- /dev/null +++ b/docs/yumi_gift_challenge_api.md @@ -0,0 +1,60 @@ +# Yumi 礼物挑战接口 + +Yumi 的 Java `sysOrigin` 固定为 `LIKEI`。网关外部地址在以下路径前加 `/go`;Go 服务内部路径不加。所有 Snowflake ID 都使用十进制字符串,积分/任务金额使用精确两位小数字符串(如 `"100.50"`)。统一响应业务数据位于 `body`/`data`。 + +## H5 用户接口 + +鉴权:`Authorization: Bearer `。 + +| 方法 | 地址 | 参数 | 主要返回 | +|---|---|---|---| +| 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 接口刷新任务和排行榜。 diff --git a/internal/config/config.go b/internal/config/config.go index 1186828..2b69dcb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"}, diff --git a/internal/integration/gateways.go b/internal/integration/gateways.go index be73d11..d63bfa3 100644 --- a/internal/integration/gateways.go +++ b/internal/integration/gateways.go @@ -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) diff --git a/internal/integration/java.go b/internal/integration/java.go index 6f0bb88..17148bf 100644 --- a/internal/integration/java.go +++ b/internal/integration/java.go @@ -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 { diff --git a/internal/model/decimal24_2.go b/internal/model/decimal24_2.go new file mode 100644 index 0000000..037f739 --- /dev/null +++ b/internal/model/decimal24_2.go @@ -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) +} diff --git a/internal/model/taskcenter_models.go b/internal/model/taskcenter_models.go index 2a3aef1..27bdb1c 100644 --- a/internal/model/taskcenter_models.go +++ b/internal/model/taskcenter_models.go @@ -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"` diff --git a/internal/model/yumi_game_king_models.go b/internal/model/yumi_game_king_models.go new file mode 100644 index 0000000..93665d3 --- /dev/null +++ b/internal/model/yumi_game_king_models.go @@ -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" } diff --git a/internal/model/yumi_gift_challenge_models.go b/internal/model/yumi_gift_challenge_models.go new file mode 100644 index 0000000..778c88e --- /dev/null +++ b/internal/model/yumi_gift_challenge_models.go @@ -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" +} diff --git a/internal/router/game_king_routes.go b/internal/router/game_king_routes.go new file mode 100644 index 0000000..5ccda72 --- /dev/null +++ b/internal/router/game_king_routes.go @@ -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") + } +} diff --git a/internal/router/middleware.go b/internal/router/middleware.go index 5b69555..84d13b0 100644 --- a/internal/router/middleware.go +++ b/internal/router/middleware.go @@ -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) diff --git a/internal/router/router.go b/internal/router/router.go index 7695e11..506ff50 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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) diff --git a/internal/router/yumi_gift_challenge_routes.go b/internal/router/yumi_gift_challenge_routes.go new file mode 100644 index 0000000..4ff2680 --- /dev/null +++ b/internal/router/yumi_gift_challenge_routes.go @@ -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())) +} diff --git a/internal/service/gameking/config.go b/internal/service/gameking/config.go new file mode 100644 index 0000000..37f4d7b --- /dev/null +++ b/internal/service/gameking/config.go @@ -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 +} diff --git a/internal/service/gameking/delivery.go b/internal/service/gameking/delivery.go new file mode 100644 index 0000000..9db6671 --- /dev/null +++ b/internal/service/gameking/delivery.go @@ -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,再复用普通 retry;delivered=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 +} diff --git a/internal/service/gameking/draw.go b/internal/service/gameking/draw.go new file mode 100644 index 0000000..65d1e52 --- /dev/null +++ b/internal/service/gameking/draw.go @@ -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 +} diff --git a/internal/service/gameking/event.go b/internal/service/gameking/event.go new file mode 100644 index 0000000..2f7011e --- /dev/null +++ b/internal/service/gameking/event.go @@ -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", + ) +} diff --git a/internal/service/gameking/normalize.go b/internal/service/gameking/normalize.go new file mode 100644 index 0000000..fa8d895 --- /dev/null +++ b/internal/service/gameking/normalize.go @@ -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 +} diff --git a/internal/service/gameking/page.go b/internal/service/gameking/page.go new file mode 100644 index 0000000..95e67dc --- /dev/null +++ b/internal/service/gameking/page.go @@ -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 兼容维度;当前活动没有胜利事件, +// 因此显式请求时返回空榜而不是 400,supportedRankingTypes 会让新 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), + } +} diff --git a/internal/service/gameking/settlement.go b/internal/service/gameking/settlement.go new file mode 100644 index 0000000..00e883a --- /dev/null +++ b/internal/service/gameking/settlement.go @@ -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 +} diff --git a/internal/service/gameking/types.go b/internal/service/gameking/types.go new file mode 100644 index 0000000..f34b4d0 --- /dev/null +++ b/internal/service/gameking/types.go @@ -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() +} diff --git a/internal/service/taskcenter/event.go b/internal/service/taskcenter/event.go index d5de212..259baa3 100644 --- a/internal/service/taskcenter/event.go +++ b/internal/service/taskcenter/event.go @@ -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) diff --git a/internal/service/taskcenter/types.go b/internal/service/taskcenter/types.go index 573cf25..0c8690c 100644 --- a/internal/service/taskcenter/types.go +++ b/internal/service/taskcenter/types.go @@ -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"` diff --git a/internal/service/yumigiftchallenge/activity.go b/internal/service/yumigiftchallenge/activity.go new file mode 100644 index 0000000..833bf42 --- /dev/null +++ b/internal/service/yumigiftchallenge/activity.go @@ -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 + }) +} diff --git a/internal/service/yumigiftchallenge/config.go b/internal/service/yumigiftchallenge/config.go new file mode 100644 index 0000000..6946349 --- /dev/null +++ b/internal/service/yumigiftchallenge/config.go @@ -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(¤t).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 + } + // 不信任历史/人工写入的数据;即使绕过 Save,Enable 也不能生成总榜早于最终日榜的门闩。 + 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) +} diff --git a/internal/service/yumigiftchallenge/delivery.go b/internal/service/yumigiftchallenge/delivery.go new file mode 100644 index 0000000..ab8ae99 --- /dev/null +++ b/internal/service/yumigiftchallenge/delivery.go @@ -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] +} diff --git a/internal/service/yumigiftchallenge/event.go b/internal/service/yumigiftchallenge/event.go new file mode 100644 index 0000000..1a3a6e1 --- /dev/null +++ b/internal/service/yumigiftchallenge/event.go @@ -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 +} diff --git a/internal/service/yumigiftchallenge/lifecycle.go b/internal/service/yumigiftchallenge/lifecycle.go new file mode 100644 index 0000000..92524e0 --- /dev/null +++ b/internal/service/yumigiftchallenge/lifecycle.go @@ -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 +} diff --git a/internal/service/yumigiftchallenge/ranking.go b/internal/service/yumigiftchallenge/ranking.go new file mode 100644 index 0000000..0497684 --- /dev/null +++ b/internal/service/yumigiftchallenge/ranking.go @@ -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 = © + } + 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 +} diff --git a/internal/service/yumigiftchallenge/runner.go b/internal/service/yumigiftchallenge/runner.go new file mode 100644 index 0000000..91ada66 --- /dev/null +++ b/internal/service/yumigiftchallenge/runner.go @@ -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) +} diff --git a/internal/service/yumigiftchallenge/settlement.go b/internal/service/yumigiftchallenge/settlement.go new file mode 100644 index 0000000..7b5ac87 --- /dev/null +++ b/internal/service/yumigiftchallenge/settlement.go @@ -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 +} diff --git a/internal/service/yumigiftchallenge/task.go b/internal/service/yumigiftchallenge/task.go new file mode 100644 index 0000000..9ced22b --- /dev/null +++ b/internal/service/yumigiftchallenge/task.go @@ -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 +} diff --git a/internal/service/yumigiftchallenge/types.go b/internal/service/yumigiftchallenge/types.go new file mode 100644 index 0000000..7aecd08 --- /dev/null +++ b/internal/service/yumigiftchallenge/types.go @@ -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.LIKEI;YUMI 只是 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() +} diff --git a/migrations/058_yumi_game_king.sql b/migrations/058_yumi_game_king.sql new file mode 100644 index 0000000..d4b4944 --- /dev/null +++ b/migrations/058_yumi_game_king.sql @@ -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; diff --git a/migrations/059_yumi_gift_challenge.sql b/migrations/059_yumi_gift_challenge.sql new file mode 100644 index 0000000..c029898 --- /dev/null +++ b/migrations/059_yumi_gift_challenge.sql @@ -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';