feat: add Yumi activity services
This commit is contained in:
parent
fb43e3e9fa
commit
3c9f10f21a
@ -18,6 +18,7 @@ import (
|
|||||||
"chatapp3-golang/internal/service/cprelationbroadcast"
|
"chatapp3-golang/internal/service/cprelationbroadcast"
|
||||||
"chatapp3-golang/internal/service/errorlog"
|
"chatapp3-golang/internal/service/errorlog"
|
||||||
"chatapp3-golang/internal/service/firstrechargereward"
|
"chatapp3-golang/internal/service/firstrechargereward"
|
||||||
|
"chatapp3-golang/internal/service/gameking"
|
||||||
"chatapp3-golang/internal/service/gameopen"
|
"chatapp3-golang/internal/service/gameopen"
|
||||||
"chatapp3-golang/internal/service/gameprovider"
|
"chatapp3-golang/internal/service/gameprovider"
|
||||||
"chatapp3-golang/internal/service/hostcenter"
|
"chatapp3-golang/internal/service/hostcenter"
|
||||||
@ -41,6 +42,7 @@ import (
|
|||||||
"chatapp3-golang/internal/service/voiceroomrocket"
|
"chatapp3-golang/internal/service/voiceroomrocket"
|
||||||
"chatapp3-golang/internal/service/weekstar"
|
"chatapp3-golang/internal/service/weekstar"
|
||||||
"chatapp3-golang/internal/service/wheel"
|
"chatapp3-golang/internal/service/wheel"
|
||||||
|
"chatapp3-golang/internal/service/yumigiftchallenge"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@ -67,6 +69,7 @@ func main() {
|
|||||||
firstRechargeRewardService := firstrechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
firstRechargeRewardService := firstrechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||||
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
|
gameKingService := gameking.NewService(app.Repository.DB, &app.Gateways)
|
||||||
userBadgeService := userbadge.NewService(app.Repository.DB)
|
userBadgeService := userbadge.NewService(app.Repository.DB)
|
||||||
vipService := vip.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
vipService := vip.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
wheelService := wheel.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
wheelService := wheel.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
@ -80,6 +83,7 @@ func main() {
|
|||||||
voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||||
voiceRoomRocketService.SetRegionBroadcaster(regionIMGroupService)
|
voiceRoomRocketService.SetRegionBroadcaster(regionIMGroupService)
|
||||||
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||||
|
yumiGiftChallengeService := yumigiftchallenge.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
hostCenterService := hostcenter.NewService(app.Config, &app.Gateways)
|
hostCenterService := hostcenter.NewService(app.Config, &app.Gateways)
|
||||||
managerCenterService := managercenter.NewService(app.Repository.DB, &app.Gateways)
|
managerCenterService := managercenter.NewService(app.Repository.DB, &app.Gateways)
|
||||||
@ -88,6 +92,8 @@ func main() {
|
|||||||
baishunService.SetTaskEventReporter(taskCenterService)
|
baishunService.SetTaskEventReporter(taskCenterService)
|
||||||
gameOpenService.SetTaskEventReporter(taskCenterService)
|
gameOpenService.SetTaskEventReporter(taskCenterService)
|
||||||
weekStarService.SetTaskEventReporter(taskCenterService)
|
weekStarService.SetTaskEventReporter(taskCenterService)
|
||||||
|
// GAME_CONSUME_GOLD 在任务中心完成统一校验后扇出;HTTP 与 MQ 重投由活动账本幂等。
|
||||||
|
taskCenterService.SetGameConsumeSink(gameKingService)
|
||||||
|
|
||||||
workerCtx, workerCancel := context.WithCancel(context.Background())
|
workerCtx, workerCancel := context.WithCancel(context.Background())
|
||||||
defer workerCancel()
|
defer workerCancel()
|
||||||
@ -97,6 +103,9 @@ func main() {
|
|||||||
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
|
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
|
||||||
log.Fatalf("start week star message consumer failed: %v", err)
|
log.Fatalf("start week star message consumer failed: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := yumiGiftChallengeService.StartMessageConsumer(workerCtx); err != nil {
|
||||||
|
log.Fatalf("start yumi gift challenge message consumer failed: %v", err)
|
||||||
|
}
|
||||||
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
|
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
|
||||||
log.Fatalf("start task center message consumer failed: %v", err)
|
log.Fatalf("start task center message consumer failed: %v", err)
|
||||||
}
|
}
|
||||||
@ -129,6 +138,7 @@ func main() {
|
|||||||
FirstRechargeReward: firstRechargeRewardService,
|
FirstRechargeReward: firstRechargeRewardService,
|
||||||
Lingxian: lingxianService,
|
Lingxian: lingxianService,
|
||||||
GameOpen: gameOpenService,
|
GameOpen: gameOpenService,
|
||||||
|
GameKing: gameKingService,
|
||||||
GameProviders: gameProviders,
|
GameProviders: gameProviders,
|
||||||
GameVisibility: gameVisibility,
|
GameVisibility: gameVisibility,
|
||||||
LuckyGift: luckyGiftService,
|
LuckyGift: luckyGiftService,
|
||||||
@ -150,6 +160,7 @@ func main() {
|
|||||||
VoiceRoomRedPacket: voiceRoomRedPacketService,
|
VoiceRoomRedPacket: voiceRoomRedPacketService,
|
||||||
VoiceRoomRocket: voiceRoomRocketService,
|
VoiceRoomRocket: voiceRoomRocketService,
|
||||||
WeekStar: weekStarService,
|
WeekStar: weekStarService,
|
||||||
|
YumiGiftChallenge: yumiGiftChallengeService,
|
||||||
})
|
})
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: app.Config.HTTP.ListenAddr,
|
Addr: app.Config.HTTP.ListenAddr,
|
||||||
|
|||||||
@ -4,12 +4,14 @@ import (
|
|||||||
"chatapp3-golang/internal/bootstrap"
|
"chatapp3-golang/internal/bootstrap"
|
||||||
"chatapp3-golang/internal/service/cprelationbroadcast"
|
"chatapp3-golang/internal/service/cprelationbroadcast"
|
||||||
"chatapp3-golang/internal/service/firstrechargereward"
|
"chatapp3-golang/internal/service/firstrechargereward"
|
||||||
|
"chatapp3-golang/internal/service/gameking"
|
||||||
"chatapp3-golang/internal/service/regionimgroup"
|
"chatapp3-golang/internal/service/regionimgroup"
|
||||||
"chatapp3-golang/internal/service/registerreward"
|
"chatapp3-golang/internal/service/registerreward"
|
||||||
"chatapp3-golang/internal/service/taskcenter"
|
"chatapp3-golang/internal/service/taskcenter"
|
||||||
"chatapp3-golang/internal/service/voiceroomredpacket"
|
"chatapp3-golang/internal/service/voiceroomredpacket"
|
||||||
"chatapp3-golang/internal/service/voiceroomrocket"
|
"chatapp3-golang/internal/service/voiceroomrocket"
|
||||||
"chatapp3-golang/internal/service/weekstar"
|
"chatapp3-golang/internal/service/weekstar"
|
||||||
|
"chatapp3-golang/internal/service/yumigiftchallenge"
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@ -28,7 +30,11 @@ func main() {
|
|||||||
|
|
||||||
registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||||
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||||
|
yumiGiftChallengeService := yumigiftchallenge.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
|
gameKingService := gameking.NewService(app.Repository.DB, &app.Gateways)
|
||||||
|
// consumer 是生产 MQ 的主要入口,必须与 API 直报共用同一个活动幂等账本。
|
||||||
|
taskCenterService.SetGameConsumeSink(gameKingService)
|
||||||
firstRechargeRewardService := firstrechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
firstRechargeRewardService := firstrechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
cpRelationBroadcastService := cprelationbroadcast.NewService(app.Config, app.Repository.DB, regionIMGroupService)
|
cpRelationBroadcastService := cprelationbroadcast.NewService(app.Config, app.Repository.DB, regionIMGroupService)
|
||||||
@ -46,6 +52,9 @@ func main() {
|
|||||||
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
|
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
|
||||||
log.Fatalf("start week star message consumer failed: %v", err)
|
log.Fatalf("start week star message consumer failed: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := yumiGiftChallengeService.StartMessageConsumer(workerCtx); err != nil {
|
||||||
|
log.Fatalf("start yumi gift challenge message consumer failed: %v", err)
|
||||||
|
}
|
||||||
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
|
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
|
||||||
log.Fatalf("start task center message consumer failed: %v", err)
|
log.Fatalf("start task center message consumer failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
303
docs/Yumi游戏王活动接口.md
Normal file
303
docs/Yumi游戏王活动接口.md
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
# Yumi 游戏王活动接口
|
||||||
|
|
||||||
|
本文对应 `chatapp3-golang` 的 Yumi Game King 实现。业务只接受 `LIKEI` 租户的权威游戏金币消耗事件,不提供事件白名单输入框,也不会统计通用钱包支出。
|
||||||
|
|
||||||
|
## 1. 通用约定
|
||||||
|
|
||||||
|
- H5 外部网关地址:`/go/app/h5/game-king/yumi/*`;Go 服务内路由不带 `/go`。
|
||||||
|
- 后台外部网关地址:`/go/resident-activity/game-king/*`;Go 服务内路由不带 `/go`。
|
||||||
|
- H5 和后台均传 `Authorization`。H5 支持 `Bearer <token>`;后台沿用 Console 完整 Authorization。
|
||||||
|
- 内部任务传 `X-Internal-Token: <HTTP_INTERNAL_CALLBACK_SECRET>`。
|
||||||
|
- 时间字段均为毫秒时间戳;`timeZone` 使用 IANA 时区,默认 `Asia/Riyadh`。
|
||||||
|
- 所有业务 ID 在 JSON 响应中均为字符串,避免 JavaScript 丢失 Snowflake 精度。请求中的 ID 可传字符串或数字,建议统一传字符串。
|
||||||
|
- 成功响应的数据同时位于 `data` 和 `body`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": true,
|
||||||
|
"errorCode": 0,
|
||||||
|
"errorMsg": "success",
|
||||||
|
"code": "success",
|
||||||
|
"message": "success",
|
||||||
|
"data": {},
|
||||||
|
"body": {},
|
||||||
|
"time": 1784300000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 错误时 HTTP 状态码与 `errorCode` 一致,稳定业务码位于 `code` / `errorCodeName`。
|
||||||
|
|
||||||
|
## 2. H5 接口
|
||||||
|
|
||||||
|
### 2.1 活动详情
|
||||||
|
|
||||||
|
`GET /go/app/h5/game-king/yumi/detail?activityId={activityId}`
|
||||||
|
|
||||||
|
`activityId` 可不传;不传时按登录用户 `sysOrigin=LIKEI` 解析当前可见活动。返回活动、固定七个奖项、六个排名奖励档、服务器时间和活动状态。
|
||||||
|
|
||||||
|
关键返回字段:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"activity": {
|
||||||
|
"id": "190000000000000001",
|
||||||
|
"activityCode": "YUMI_GAME_KING_S1",
|
||||||
|
"sysOrigin": "LIKEI",
|
||||||
|
"startTime": 1784300000000,
|
||||||
|
"endTime": 1784900000000,
|
||||||
|
"coinPerDraw": 1000,
|
||||||
|
"eventTypes": ["GAME_CONSUME_GOLD"],
|
||||||
|
"rankingType": "TYCOON",
|
||||||
|
"rankingPeriod": "OVERALL",
|
||||||
|
"settlementStatus": "NOT_STARTED"
|
||||||
|
},
|
||||||
|
"prizes": [],
|
||||||
|
"rankRewards": [],
|
||||||
|
"supportedRankingTypes": ["TYCOON"],
|
||||||
|
"supportedRankingPeriods": ["DAILY", "OVERALL"],
|
||||||
|
"activityStatus": "UPCOMING",
|
||||||
|
"serverTime": 1784299900000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`activityStatus`:`UPCOMING`、`ACTIVE`、`ENDED`、`DISABLED`。
|
||||||
|
|
||||||
|
### 2.2 我的进度
|
||||||
|
|
||||||
|
`GET /go/app/h5/game-king/yumi/me?activityId={activityId}`
|
||||||
|
|
||||||
|
返回:
|
||||||
|
|
||||||
|
- `totalConsumed`:活动时间窗内累计游戏消耗金币。
|
||||||
|
- `earnedChances`:`floor(totalConsumed / coinPerDraw)`。
|
||||||
|
- `usedChances` / `availableChances`:已用和可用抽奖次数。
|
||||||
|
- `nextChanceRemaining`:距离下一次机会仍需消耗的金币。
|
||||||
|
- `rank`:总榜名次;无消费时为 `null`。
|
||||||
|
|
||||||
|
### 2.3 抽奖
|
||||||
|
|
||||||
|
`POST /go/app/h5/game-king/yumi/draw`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"activityId": "190000000000000001",
|
||||||
|
"requestId": "9af73ee9-65ca-4cf5-b23c-f4c222c3ee6e"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `activityId` 和 `requestId` 均必填;`requestId` 最长 64 字符。客户端一次点击生成一个稳定值;超时重试必须复用原值。
|
||||||
|
- 幂等范围是 `activityId + userId + requestId`。已经提交的结果即使活动随后结束,也会原样返回,且不会再次扣次数或发奖。
|
||||||
|
- 权重仅表示相对概率;库存 `-1` 为无限,`0` 为耗尽,正数为剩余库存。有限库存使用条件更新防止超发。
|
||||||
|
- 返回 `record`、`prize`、`remainingChances`、`idempotentReplay`。
|
||||||
|
|
||||||
|
### 2.4 排行榜
|
||||||
|
|
||||||
|
`GET /go/app/h5/game-king/yumi/ranking?activityId={activityId}&rankingType=TYCOON&period=OVERALL&limit=30`
|
||||||
|
|
||||||
|
- `rankingType`:正式口径仅 `TYCOON`。旧 H5 请求 `VICTORIOUS` 时返回空榜,避免兼容页面报错。
|
||||||
|
- `period`:`DAILY` 或 `OVERALL`;配置页固定保存 `OVERALL`,H5 仍可切日榜。
|
||||||
|
- `limit` 默认 30,最大 100。
|
||||||
|
- 排序固定为:消费金币降序、达到当前消费值的事件时间升序、用户 ID 升序。
|
||||||
|
- `DAILY` 按活动时区自然日统计;活动结束结算只使用 `OVERALL` Top30。
|
||||||
|
- 用户资料通过 Java 批量接口补齐;资料服务异常时榜单仍返回 `userId` 和分数。
|
||||||
|
|
||||||
|
### 2.5 我的抽奖记录
|
||||||
|
|
||||||
|
`GET /go/app/h5/game-king/yumi/draw-records?activityId={activityId}&limit=20`
|
||||||
|
|
||||||
|
`limit` 最大 100。返回奖品快照及 `PENDING / PROCESSING / SUCCESS / FAILED / UNKNOWN` 发放状态。
|
||||||
|
|
||||||
|
## 3. 后台管理接口
|
||||||
|
|
||||||
|
所有接口要求菜单别名 `ResidentGameKingActivity`。写接口还要求对应按钮权限:
|
||||||
|
|
||||||
|
| 权限别名 | 接口用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `resident-activity:yumi-game-king:edit` | 保存活动、奖项、排名奖励 |
|
||||||
|
| `resident-activity:yumi-game-king:enable` | 启停活动 |
|
||||||
|
| `resident-activity:yumi-game-king:settle` | 人工触发到期结算 |
|
||||||
|
| `resident-activity:yumi-game-king:reconcile` | FAILED 重试、UNKNOWN 核账 |
|
||||||
|
| `resident-activity:yumi-game-king:backfill` | 权威事件补数 |
|
||||||
|
|
||||||
|
### 3.1 活动配置
|
||||||
|
|
||||||
|
| 方法 | 地址 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/go/resident-activity/game-king/list` | 活动列表 |
|
||||||
|
| GET | `/go/resident-activity/game-king/detail/{id}` | 完整详情 |
|
||||||
|
| POST | `/go/resident-activity/game-king/save` | 新增或修改 |
|
||||||
|
| PUT | `/go/resident-activity/game-king/enable/{id}?enabled=true` | 启停;布尔值只接受 `true/false/1/0` |
|
||||||
|
| GET | `/go/resident-activity/game-king/prizes/{id}` | 七个奖项 |
|
||||||
|
| PUT | `/go/resident-activity/game-king/prizes/{id}` | 保存七个奖项 |
|
||||||
|
| GET | `/go/resident-activity/game-king/rank-rewards/{id}` | 六个排名奖励档 |
|
||||||
|
| PUT | `/go/resident-activity/game-king/rank-rewards/{id}` | 保存六个排名奖励档 |
|
||||||
|
|
||||||
|
`POST /save` 示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "0",
|
||||||
|
"activityCode": "YUMI_GAME_KING_S1",
|
||||||
|
"activityName": "Yumi Game King",
|
||||||
|
"activityDesc": "",
|
||||||
|
"sysOrigin": "LIKEI",
|
||||||
|
"timeZone": "Asia/Riyadh",
|
||||||
|
"startTime": 1784300000000,
|
||||||
|
"endTime": 1784900000000,
|
||||||
|
"settlementDelayMinutes": 30,
|
||||||
|
"coinPerDraw": 1000,
|
||||||
|
"rankingType": "TYCOON",
|
||||||
|
"rankingPeriod": "OVERALL",
|
||||||
|
"enabled": false,
|
||||||
|
"prizes": [
|
||||||
|
{
|
||||||
|
"id": "0",
|
||||||
|
"prizeName": "Prize 1",
|
||||||
|
"prizeImage": "https://example.com/prize-1.png",
|
||||||
|
"weight": 100,
|
||||||
|
"stock": -1,
|
||||||
|
"resourceGroupId": "190000000000000101",
|
||||||
|
"enabled": true,
|
||||||
|
"sortOrder": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rankRewards": [
|
||||||
|
{
|
||||||
|
"id": "0",
|
||||||
|
"startRank": 1,
|
||||||
|
"endRank": 1,
|
||||||
|
"resourceGroupId": "190000000000000201",
|
||||||
|
"rewardName": "Top 1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
示例数组为节省篇幅只展示一项,真实启用配置必须满足:
|
||||||
|
|
||||||
|
- 恰好 7 个奖项,全部启用,`sortOrder` 完整覆盖 1..7 且不重复;权重必须大于 0。
|
||||||
|
- 恰好 6 个排名档:`1`、`2`、`3`、`4-7`、`8-10`、`11-30`。
|
||||||
|
- `sysOrigin` 固定 `LIKEI`;`rankingType` 固定 `TYCOON`;配置 `rankingPeriod` 固定 `OVERALL`。
|
||||||
|
- 首次启用时 `startTime` 必须严格晚于当前时间;同租户启用活动时间窗不能重叠。
|
||||||
|
- 计数窗口严格为 `[startTime, endTime)`。活动开始后锁定时间、口径、奖项、排名奖励和启停状态,只允许修改名称、说明等展示信息。
|
||||||
|
- `settlementDelayMinutes` 默认 30,可配置 0..1440。
|
||||||
|
- 每次保存/启用及每次真实发奖前都会重新校验奖励资源组:必须存在、已上架、明确属于 `LIKEI` 且非空,并比对冻结的完整奖励项快照。
|
||||||
|
|
||||||
|
单独保存奖项请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"activityId": "190000000000000001",
|
||||||
|
"prizes": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
单独保存排名奖励请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"activityId": "190000000000000001",
|
||||||
|
"rankRewards": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
路径 ID 与 body `activityId` 必须一致。
|
||||||
|
|
||||||
|
### 3.2 抽奖发放管理
|
||||||
|
|
||||||
|
| 方法 | 地址 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/go/resident-activity/game-king/draw-records/{id}?deliveryStatus=&limit=100` | 查看抽奖和发放明细 |
|
||||||
|
| POST | `/go/resident-activity/game-king/draw/retry/{recordId}` | 只重试 `FAILED` |
|
||||||
|
| POST | `/go/resident-activity/game-king/delivery-item/resolve/{itemId}?delivered=true` | 人工核销 `UNKNOWN` |
|
||||||
|
|
||||||
|
`delivered` 只接受 `true/false/1/0`:
|
||||||
|
|
||||||
|
- `true`:已通过 `trackId/businessNo` 确认下游到账,只记为成功,不再发送。
|
||||||
|
- `false`:已确认下游未到账,先转为 `FAILED`,再执行一次显式重试。
|
||||||
|
- 非法或缺失值返回 400,绝不默认为 `false`。
|
||||||
|
|
||||||
|
### 3.3 排名结算
|
||||||
|
|
||||||
|
| 方法 | 地址 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/go/resident-activity/game-king/settlement-records/{id}` | 查看冻结名次与发放状态 |
|
||||||
|
| POST | `/go/resident-activity/game-king/settlement/{id}` | 到结算时间后人工冻结并派发一小批 |
|
||||||
|
| POST | `/go/resident-activity/game-king/settlement/retry/{recordId}` | 只重试 `FAILED` |
|
||||||
|
|
||||||
|
结算只冻结一次 `OVERALL` Top30。记录和发奖任务在同一事务生成;人工接口每次最多派发该活动 4 个 `PENDING`,其余由内部定时任务继续。单项硬超时 20 秒,因此最坏外部调用约 80 秒,并为数据库处理留出余量。
|
||||||
|
|
||||||
|
### 3.4 事件补数
|
||||||
|
|
||||||
|
`POST /go/resident-activity/game-king/backfill/{id}?lastId=0&limit=200`
|
||||||
|
|
||||||
|
- 只读取 `task_center_event_archive_outbox` 的 `LIKEI + GAME_CONSUME_GOLD + 活动时间窗` 数据。
|
||||||
|
- 在任何归档扫描前都会检查迁移 058 的四列在线索引;索引未完成、不可见或列序不符时返回 `503 backfill_index_required`,不会退化为全表扫描。
|
||||||
|
- 仅允许对已启用且结算尚未冻结的活动补数;活动结束后的结算等待期仍可补,进入结算后返回 409,游标不会静默推进。
|
||||||
|
- 调用方只能传不透明游标 `lastId` 和 `limit`,不能传 eventType 或钱包口径;`limit` 最大 500。
|
||||||
|
- 返回 `eventType`、`scanned`、`accepted`、`nextLastId`、`finished`。未完成时用返回的 `nextLastId` 继续。
|
||||||
|
- 若结算恰好在一页补数中途冻结,接口返回 409 且不返回新的游标;调用方保留原 `lastId`,已成功写入的前序事件会由活动账本幂等跳过。
|
||||||
|
- 活动账本以 `activityId + eventId` 幂等,实时事件和补数重复不会重复累计。
|
||||||
|
|
||||||
|
## 4. 游戏消耗事件接入
|
||||||
|
|
||||||
|
唯一统计事件是任务中心的 `GAME_CONSUME_GOLD`。事件经过任务中心统一校验后、在任务中心自身业务去重之前同步扇出给 Game King;Game King 使用独立账本保证幂等。
|
||||||
|
|
||||||
|
已接入事件 ID 口径:
|
||||||
|
|
||||||
|
- Java 游戏:`GAME_CONSUME:<provider>:<orderId>`,已覆盖 `YOMI`、`LINGXIAN`、`HOTGAME`、`BAISHUN_LEGACY`、`LUDO`、`TURNTABLE`。
|
||||||
|
- Go GameOpen:`GAME_OPEN_CONSUME:<orderId>`。
|
||||||
|
- Go HotGame:`GAME_HOTGAME_CONSUME:<orderId>`。
|
||||||
|
- Go Baishun:`BAISHUN_GAME_CONSUME:<sysOrigin>:<orderId>`。
|
||||||
|
|
||||||
|
RocketMQ 默认配置:
|
||||||
|
|
||||||
|
- topic:`RC_DEFAULT_APP_ORDINARY`
|
||||||
|
- tag:`task_center_event`
|
||||||
|
- consumer group:`task-center-progress`
|
||||||
|
- 开关:`CHATAPP_TASK_CENTER_MQ_ENABLED`(兼容 `TASK_CENTER_EVENT_MQ_ENABLED`)
|
||||||
|
|
||||||
|
活动累计使用事件的 `occurredAt` 判断 `[startTime,endTime)`,因此活动开始前的历史游戏消耗不会计入。只接受正数 `deltaValue`;退款、胜利收入和非游戏钱包消耗不计入。
|
||||||
|
|
||||||
|
## 5. 发奖和异常状态
|
||||||
|
|
||||||
|
发奖以“奖励资源组”为一个下游调用单元,使用 Java `SendActivityReward`,`origin=GAME_KING_AWARD`,`trackId=deliveryItem.id`。后台明细同时返回稳定 `trackId`、`businessNo`、用户 ID、资源组 ID 和冻结奖励项。
|
||||||
|
|
||||||
|
- `PENDING`:本地事务已提交,尚未领取。
|
||||||
|
- `PROCESSING`:已领取,正在校验/调用下游。
|
||||||
|
- `SUCCESS`:Java 明确成功。
|
||||||
|
- `FAILED`:在发奖请求发出前就已明确失败,例如资源组不存在、下架、跨租户或与冻结快照不一致;只允许运营显式重试。
|
||||||
|
- `UNKNOWN`:发奖请求已尝试但结果不能证明,或 `PROCESSING` 超过 10 分钟;禁止自动重试,必须先核账再 resolve。
|
||||||
|
|
||||||
|
一旦活动启用,奖励组内的实际奖励项内容必须保持与冻结快照一致。若运营修改了组内容,发放会在调用 Java 前进入 `FAILED`;应先恢复原快照或完成明确处置,再重试。
|
||||||
|
|
||||||
|
## 6. 内部结算任务
|
||||||
|
|
||||||
|
`POST /internal/game-king/settle-due`
|
||||||
|
|
||||||
|
Header:`X-Internal-Token`。
|
||||||
|
|
||||||
|
本 Go 服务不启动 ticker。由 `chatapp-cron` 周期调用:
|
||||||
|
|
||||||
|
- 每次最多修复 20 个过期 `PROCESSING`,并扫描、冻结 10 个到期活动。
|
||||||
|
- 每次最多派发全局 4 个 `PENDING`;单项硬超时 20 秒,调用方超时应配置为至少 120 秒。
|
||||||
|
- 只自动恢复 `PENDING`。`FAILED` / `UNKNOWN` 永不自动重试;超过 10 分钟的 `PROCESSING` 只转为 `UNKNOWN`。
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scanned": 2,
|
||||||
|
"settled": 2,
|
||||||
|
"failedIds": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 数据库迁移与性能
|
||||||
|
|
||||||
|
迁移文件:`migrations/058_yumi_game_king.sql`。
|
||||||
|
|
||||||
|
- 新建活动、七奖项、六档奖励、幂等消费账本、总榜/日榜聚合、抽奖、结算和发放表。
|
||||||
|
- 排行读取物化聚合并使用复合排序索引,不对消费流水做运行时 `GROUP BY`。
|
||||||
|
- 补数给 `task_center_event_archive_outbox` 增加 `(sys_origin,event_type,occurred_at,id)` 复合索引,以范围 + keyset 扫描,避免全表排序。
|
||||||
|
- 迁移不执行历史 `UPDATE/DELETE/backfill`。新增复合索引仍属于线上 DDL,执行前应按目标 MySQL 版本、表数据量和 DDL 策略确认锁影响,并在低峰发布。
|
||||||
60
docs/yumi_gift_challenge_api.md
Normal file
60
docs/yumi_gift_challenge_api.md
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# Yumi 礼物挑战接口
|
||||||
|
|
||||||
|
Yumi 的 Java `sysOrigin` 固定为 `LIKEI`。网关外部地址在以下路径前加 `/go`;Go 服务内部路径不加。所有 Snowflake ID 都使用十进制字符串,积分/任务金额使用精确两位小数字符串(如 `"100.50"`)。统一响应业务数据位于 `body`/`data`。
|
||||||
|
|
||||||
|
## H5 用户接口
|
||||||
|
|
||||||
|
鉴权:`Authorization: Bearer <app token>`。
|
||||||
|
|
||||||
|
| 方法 | 地址 | 参数 | 主要返回 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/app/h5/gift-challenge/yumi/detail` | query `activityId` 可选 | `activity`、固定三任务 `tasks`、日/总榜奖励 `rankRewards` |
|
||||||
|
| POST | `/app/h5/gift-challenge/yumi/enter` | JSON `{ "activityId": "..." }` | 当日 `totalScore`、`dailyScore`、名次和任务状态;幂等完成进入页面任务 |
|
||||||
|
| GET | `/app/h5/gift-challenge/yumi/ranking` | query `activityId` 可选、`periodType=DAILY\|OVERALL`、DAILY 可传 `statDate=yyyy-MM-dd`、`limit`(默认/最大 30) | 原始 Top N 过滤神秘人后的 `entries`、本人真实 `my`、`settled` |
|
||||||
|
| POST | `/app/h5/gift-challenge/yumi/tasks/:taskCode/claim` | JSON `{ "activityId": "...", "requestId": "..." }` | 更新后的当日用户状态;`requestId` 最长 128 |
|
||||||
|
|
||||||
|
只有 `GOLD`、非背包、非 `LUCKY_GIFT`/`MAGIC` 的普通礼物计分。积分按 `giftCandy × quantity × 去重有效收礼人数` 精确计算,支持最多两位小数。
|
||||||
|
|
||||||
|
## Webconsole 接口
|
||||||
|
|
||||||
|
鉴权:Console `Authorization`。所有 GET 还要求页面菜单 `ResidentYumiGiftChallenge`;写操作分别校验按钮权限。
|
||||||
|
|
||||||
|
基础地址:`/resident-activity/yumi-gift-challenge`。
|
||||||
|
|
||||||
|
| 方法 | 地址 | 参数/返回 | 权限 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/list` | query `sysOrigin=LIKEI&page&size`,返回 `{records,total,current,size}` | 页面菜单 |
|
||||||
|
| GET | `/detail/:id` | 完整活动、任务、奖励 | 页面菜单 |
|
||||||
|
| GET | `/tasks/:id` | 返回任务数组 | 页面菜单 |
|
||||||
|
| GET | `/rank-rewards/:id` | 返回日榜/总榜奖励数组 | 页面菜单 |
|
||||||
|
| GET | `/ranking/:id` | query `periodType,statDate,limit` | 页面菜单 |
|
||||||
|
| GET | `/settlement-records/:id` | query `periodType,statDate,deliveryStatus,page,size` | 页面菜单 |
|
||||||
|
| POST | `/save` | 完整活动 JSON;新活动固定 3 个启用任务,榜奖区间可配置 | `resident-activity:yumi-gift-challenge:edit` |
|
||||||
|
| PUT | `/enable/:id` | query `enabled=true\|false` | `resident-activity:yumi-gift-challenge:enable` |
|
||||||
|
| PUT | `/tasks/:id` | JSON `{ "activityId":"...", "tasks":[...] }` | `...:edit` |
|
||||||
|
| PUT | `/rank-rewards/:id` | JSON `{ "activityId":"...", "rankRewards":[...] }` | `...:edit` |
|
||||||
|
| POST | `/settlement/:id` | JSON `{ "periodType":"DAILY\|OVERALL", "statDate":"yyyy-MM-dd" }`;OVERALL 不传日期 | `...:settle` |
|
||||||
|
| POST | `/delivery-item/resolve/:itemId` | JSON `{ "delivered": true\|false }`;仅人工核账 UNKNOWN | `...:reconcile` |
|
||||||
|
|
||||||
|
活动启用时会校验奖励组已上架且属于 LIKEI,并冻结完整奖励项。活动开始后仅允许通过 `/save` 修改名称和描述,其他统计/奖励口径只读。
|
||||||
|
日榜、总榜结算延迟均允许 `0-1440` 分钟,且 `overallSettlementDelayMinutes` 必须大于或等于 `dailySettlementDelayMinutes`,保证最终日榜不会晚于总榜关门。
|
||||||
|
|
||||||
|
## chatapp-cron 触发
|
||||||
|
|
||||||
|
`POST /internal/yumi-gift-challenge/settle-due?batch=20`
|
||||||
|
|
||||||
|
- 鉴权头:`X-Internal-Token`,值与 `CHATAPP_HTTP_INTERNAL_CALLBACK_SECRET` 一致。
|
||||||
|
- Go 服务或 cron 任一侧未配置该密钥时均 fail-closed,不发送/不执行结算请求。
|
||||||
|
- 单轮最多处理 `batch` 个到期 `NOT_STARTED/PROCESSING` 日榜或总榜,并各恢复最多 `batch` 个任务/榜奖 owner;`batch` 默认 20、最大 100。
|
||||||
|
- 返回 `periodsScanned`、`periodsFrozen`、`taskOwnersScanned`、`settlementOwnersScanned`、`ownersProcessed`、`failedKeys`。
|
||||||
|
- HTTP 200 仍可能表示部分 owner 已成功、部分待恢复;cron 会把非空 `failedKeys` 提升为失败告警,下轮依靠数据库状态幂等续跑。
|
||||||
|
- 该端点可重复、可多实例并发调用;周期门闩、业务唯一键和逐奖励项 trackId 保证幂等。过租期 `PROCESSING` 会保守转为 `UNKNOWN`,不会自动盲目补发。
|
||||||
|
|
||||||
|
建议 chatapp-cron 每 10 秒至 1 分钟触发一次,单次 HTTP 超时至少覆盖每项 30 秒的下游上限和配置的 batch。
|
||||||
|
|
||||||
|
## RocketMQ 与 IM
|
||||||
|
|
||||||
|
- 独立消费组:`YUMI_GIFT_CHALLENGE_GIFT`。
|
||||||
|
- 默认 topic/tag:`RC_DEFAULT_APP_ORDINARY` / `give_gift_v3`。
|
||||||
|
- 环境变量前缀:`CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_*`;缺少 endpoint/密钥时消费者不会伪造凭证启动。
|
||||||
|
- 本活动没有新增 IM 消息、房间广播或客户端 IM 协议;H5 通过上述 HTTP 接口刷新任务和排行榜。
|
||||||
@ -25,6 +25,7 @@ type Config struct {
|
|||||||
FirstRechargeReward FirstRechargeRewardConfig
|
FirstRechargeReward FirstRechargeRewardConfig
|
||||||
TaskCenter TaskCenterConfig
|
TaskCenter TaskCenterConfig
|
||||||
WeekStar WeekStarConfig
|
WeekStar WeekStarConfig
|
||||||
|
YumiGiftChallenge YumiGiftChallengeConfig
|
||||||
RoomTurnoverReward RoomTurnoverRewardConfig
|
RoomTurnoverReward RoomTurnoverRewardConfig
|
||||||
VoiceRoomRedPacket VoiceRoomRedPacketConfig
|
VoiceRoomRedPacket VoiceRoomRedPacketConfig
|
||||||
VoiceRoomRocket VoiceRoomRocketConfig
|
VoiceRoomRocket VoiceRoomRocketConfig
|
||||||
@ -172,6 +173,13 @@ type WeekStarRocketMQConfig struct {
|
|||||||
Tag string
|
Tag string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// YumiGiftChallengeConfig 保存 Yumi 礼物挑战的独立送礼消费者和发奖租期配置。
|
||||||
|
type YumiGiftChallengeConfig struct {
|
||||||
|
DefaultSysOrigin string
|
||||||
|
DeliveryProcessingLeaseSeconds int
|
||||||
|
RocketMQ WeekStarRocketMQConfig
|
||||||
|
}
|
||||||
|
|
||||||
// RoomTurnoverRewardConfig 保存房间流水奖励模块配置。
|
// RoomTurnoverRewardConfig 保存房间流水奖励模块配置。
|
||||||
type RoomTurnoverRewardConfig struct {
|
type RoomTurnoverRewardConfig struct {
|
||||||
DefaultSysOrigin string
|
DefaultSysOrigin string
|
||||||
@ -315,6 +323,12 @@ func Load() Config {
|
|||||||
weekStarRocketMQEnabledDefault := strings.TrimSpace(weekStarRocketMQEndpoint) != "" &&
|
weekStarRocketMQEnabledDefault := strings.TrimSpace(weekStarRocketMQEndpoint) != "" &&
|
||||||
strings.TrimSpace(weekStarRocketMQAccessKey) != "" &&
|
strings.TrimSpace(weekStarRocketMQAccessKey) != "" &&
|
||||||
strings.TrimSpace(weekStarRocketMQAccessSecret) != ""
|
strings.TrimSpace(weekStarRocketMQAccessSecret) != ""
|
||||||
|
yumiGiftRocketMQEndpoint := getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT", "CHATAPP_WEEK_STAR_ROCKETMQ_ENDPOINT"}, "")
|
||||||
|
yumiGiftRocketMQAccessKey := getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY", "CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_KEY"}, "")
|
||||||
|
yumiGiftRocketMQAccessSecret := getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_ACCESS_SECRET", "LIKEI_ROCKETMQ_SECRET_KEY", "CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_SECRET"}, "")
|
||||||
|
yumiGiftRocketMQEnabledDefault := strings.TrimSpace(yumiGiftRocketMQEndpoint) != "" &&
|
||||||
|
strings.TrimSpace(yumiGiftRocketMQAccessKey) != "" &&
|
||||||
|
strings.TrimSpace(yumiGiftRocketMQAccessSecret) != ""
|
||||||
taskCenterMQEndpoint := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ENDPOINT", "TASK_CENTER_EVENT_MQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT"}, "")
|
taskCenterMQEndpoint := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ENDPOINT", "TASK_CENTER_EVENT_MQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT"}, "")
|
||||||
taskCenterMQAccessKey := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ACCESS_KEY", "TASK_CENTER_EVENT_MQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY"}, "")
|
taskCenterMQAccessKey := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ACCESS_KEY", "TASK_CENTER_EVENT_MQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY"}, "")
|
||||||
taskCenterMQAccessSecret := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ACCESS_SECRET", "TASK_CENTER_EVENT_MQ_SECRET_KEY", "LIKEI_ROCKETMQ_SECRET_KEY"}, "")
|
taskCenterMQAccessSecret := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ACCESS_SECRET", "TASK_CENTER_EVENT_MQ_SECRET_KEY", "LIKEI_ROCKETMQ_SECRET_KEY"}, "")
|
||||||
@ -460,6 +474,22 @@ func Load() Config {
|
|||||||
Tag: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_TAG", "WEEK_STAR_ROCKETMQ_TAG"}, "give_gift_v3"),
|
Tag: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_TAG", "WEEK_STAR_ROCKETMQ_TAG"}, "give_gift_v3"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
YumiGiftChallenge: YumiGiftChallengeConfig{
|
||||||
|
// Java 的 Yumi 业务来源枚举是 LIKEI;此默认值不能使用仅供品牌识别的 YUMI。
|
||||||
|
DefaultSysOrigin: strings.ToUpper(getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_DEFAULT_SYS_ORIGIN"}, "LIKEI")),
|
||||||
|
DeliveryProcessingLeaseSeconds: getEnvIntAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_DELIVERY_LEASE_SECONDS"}, 300),
|
||||||
|
RocketMQ: WeekStarRocketMQConfig{
|
||||||
|
Enabled: getEnvBoolAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_ENABLED"}, yumiGiftRocketMQEnabledDefault),
|
||||||
|
Endpoint: yumiGiftRocketMQEndpoint,
|
||||||
|
Namespace: getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_NAMESPACE", "LIKEI_ROCKETMQ_NAMESPACE"}, ""),
|
||||||
|
AccessKey: yumiGiftRocketMQAccessKey,
|
||||||
|
AccessSecret: yumiGiftRocketMQAccessSecret,
|
||||||
|
SecurityToken: getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_SECURITY_TOKEN"}, ""),
|
||||||
|
ConsumerGroup: getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_CONSUMER_GROUP"}, "YUMI_GIFT_CHALLENGE_GIFT"),
|
||||||
|
Topic: getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_TOPIC", "CHATAPP_WEEK_STAR_ROCKETMQ_TOPIC"}, "RC_DEFAULT_APP_ORDINARY"),
|
||||||
|
Tag: getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_TAG", "CHATAPP_WEEK_STAR_ROCKETMQ_TAG"}, "give_gift_v3"),
|
||||||
|
},
|
||||||
|
},
|
||||||
RoomTurnoverReward: RoomTurnoverRewardConfig{
|
RoomTurnoverReward: RoomTurnoverRewardConfig{
|
||||||
DefaultSysOrigin: strings.ToUpper(getEnvAny(
|
DefaultSysOrigin: strings.ToUpper(getEnvAny(
|
||||||
[]string{"CHATAPP_ROOM_TURNOVER_REWARD_DEFAULT_SYS_ORIGIN", "ROOM_TURNOVER_REWARD_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"},
|
[]string{"CHATAPP_ROOM_TURNOVER_REWARD_DEFAULT_SYS_ORIGIN", "ROOM_TURNOVER_REWARD_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"},
|
||||||
|
|||||||
@ -56,6 +56,16 @@ func (g *Gateways) AuthenticateConsoleToken(ctx context.Context, authorization s
|
|||||||
return g.Auth.AuthenticateConsoleToken(ctx, authorization)
|
return g.Auth.AuthenticateConsoleToken(ctx, authorization)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListConsoleButtonAliases 透传管理员按钮权限查询。
|
||||||
|
func (g *Gateways) ListConsoleButtonAliases(ctx context.Context, authorization string) (map[string]struct{}, error) {
|
||||||
|
return g.Auth.ListConsoleButtonAliases(ctx, authorization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasConsoleMenuAlias 查询管理员是否拥有指定页面菜单。
|
||||||
|
func (g *Gateways) HasConsoleMenuAlias(ctx context.Context, authorization, requiredAlias string) (bool, error) {
|
||||||
|
return g.Auth.HasConsoleMenuAlias(ctx, authorization, requiredAlias)
|
||||||
|
}
|
||||||
|
|
||||||
// GetDeviceFingerprint 透传到设备网关。
|
// GetDeviceFingerprint 透传到设备网关。
|
||||||
func (g *Gateways) GetDeviceFingerprint(ctx context.Context, userID int64) (string, error) {
|
func (g *Gateways) GetDeviceFingerprint(ctx context.Context, userID int64) (string, error) {
|
||||||
return g.Device.GetDeviceFingerprint(ctx, userID)
|
return g.Device.GetDeviceFingerprint(ctx, userID)
|
||||||
@ -126,6 +136,11 @@ func (g *Gateways) SendActivityReward(ctx context.Context, req SendActivityRewar
|
|||||||
return g.RewardDispatch.SendActivityReward(ctx, req)
|
return g.RewardDispatch.SendActivityReward(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendFrozenActivityReward 透传活动冻结奖励项发放网关。
|
||||||
|
func (g *Gateways) SendFrozenActivityReward(ctx context.Context, req SendFrozenActivityRewardRequest) error {
|
||||||
|
return g.RewardDispatch.SendFrozenActivityReward(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
// GetRewardGroupDetail 透传到奖励查询网关。
|
// GetRewardGroupDetail 透传到奖励查询网关。
|
||||||
func (g *Gateways) GetRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) {
|
func (g *Gateways) GetRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) {
|
||||||
return g.RewardQuery.GetRewardGroupDetail(ctx, groupID)
|
return g.RewardQuery.GetRewardGroupDetail(ctx, groupID)
|
||||||
@ -141,6 +156,16 @@ func (g *Gateways) GetUserProfile(ctx context.Context, userID int64) (UserProfil
|
|||||||
return g.Profile.GetUserProfile(ctx, userID)
|
return g.Profile.GetUserProfile(ctx, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MapUserProfiles 透传批量用户资料查询。
|
||||||
|
func (g *Gateways) MapUserProfiles(ctx context.Context, userIDs []int64) (map[int64]UserProfile, error) {
|
||||||
|
return g.Profile.MapUserProfiles(ctx, userIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserMysteriousInvisibility 查询用户是否开启排行榜隐身能力。
|
||||||
|
func (g *Gateways) GetUserMysteriousInvisibility(ctx context.Context, sysOrigin string, userID int64) (bool, error) {
|
||||||
|
return g.Profile.GetUserMysteriousInvisibility(ctx, sysOrigin, userID)
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateUserCountry 透传到 Java 用户国家更新接口。
|
// UpdateUserCountry 透传到 Java 用户国家更新接口。
|
||||||
func (g *Gateways) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error {
|
func (g *Gateways) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error {
|
||||||
return g.Profile.UpdateUserCountry(ctx, userID, countryID)
|
return g.Profile.UpdateUserCountry(ctx, userID, countryID)
|
||||||
@ -281,6 +306,16 @@ func (g *AuthGateway) AuthenticateConsoleToken(ctx context.Context, authorizatio
|
|||||||
return g.client.AuthenticateConsoleToken(ctx, authorization)
|
return g.client.AuthenticateConsoleToken(ctx, authorization)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListConsoleButtonAliases 查询管理员按钮权限码。
|
||||||
|
func (g *AuthGateway) ListConsoleButtonAliases(ctx context.Context, authorization string) (map[string]struct{}, error) {
|
||||||
|
return g.client.ListConsoleButtonAliases(ctx, authorization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasConsoleMenuAlias 查询管理员页面菜单权限。
|
||||||
|
func (g *AuthGateway) HasConsoleMenuAlias(ctx context.Context, authorization, requiredAlias string) (bool, error) {
|
||||||
|
return g.client.HasConsoleMenuAlias(ctx, authorization, requiredAlias)
|
||||||
|
}
|
||||||
|
|
||||||
// DeviceGateway 负责设备指纹相关接口。
|
// DeviceGateway 负责设备指纹相关接口。
|
||||||
type DeviceGateway struct {
|
type DeviceGateway struct {
|
||||||
client *Client
|
client *Client
|
||||||
@ -370,6 +405,11 @@ func (g *RewardDispatchGateway) SendActivityReward(ctx context.Context, req Send
|
|||||||
return g.client.SendActivityReward(ctx, req)
|
return g.client.SendActivityReward(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendFrozenActivityReward 发放一个已经冻结的活动奖励项。
|
||||||
|
func (g *RewardDispatchGateway) SendFrozenActivityReward(ctx context.Context, req SendFrozenActivityRewardRequest) error {
|
||||||
|
return g.client.SendFrozenActivityReward(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
// RewardQueryGateway 负责奖励配置查询接口。
|
// RewardQueryGateway 负责奖励配置查询接口。
|
||||||
type RewardQueryGateway struct {
|
type RewardQueryGateway struct {
|
||||||
client *Client
|
client *Client
|
||||||
@ -405,6 +445,16 @@ func (g *ProfileGateway) GetUserProfile(ctx context.Context, userID int64) (User
|
|||||||
return g.client.GetUserProfile(ctx, userID)
|
return g.client.GetUserProfile(ctx, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MapUserProfiles 批量查询用户资料。
|
||||||
|
func (g *ProfileGateway) MapUserProfiles(ctx context.Context, userIDs []int64) (map[int64]UserProfile, error) {
|
||||||
|
return g.client.MapUserProfiles(ctx, userIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserMysteriousInvisibility 查询单个用户的排行榜隐身能力。
|
||||||
|
func (g *ProfileGateway) GetUserMysteriousInvisibility(ctx context.Context, sysOrigin string, userID int64) (bool, error) {
|
||||||
|
return g.client.GetUserMysteriousInvisibility(ctx, sysOrigin, userID)
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateUserCountry 通过 Java other 更新用户国家。
|
// UpdateUserCountry 通过 Java other 更新用户国家。
|
||||||
func (g *ProfileGateway) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error {
|
func (g *ProfileGateway) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error {
|
||||||
return g.client.UpdateUserCountry(ctx, userID, countryID)
|
return g.client.UpdateUserCountry(ctx, userID, countryID)
|
||||||
|
|||||||
@ -41,6 +41,11 @@ type ConsoleAccount struct {
|
|||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConsoleMenu struct {
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
Childrens []ConsoleMenu `json:"childrens"`
|
||||||
|
}
|
||||||
|
|
||||||
type UserProfile struct {
|
type UserProfile struct {
|
||||||
ID Int64Value `json:"id"`
|
ID Int64Value `json:"id"`
|
||||||
Account string `json:"account"`
|
Account string `json:"account"`
|
||||||
@ -199,6 +204,30 @@ type SendActivityRewardRequest struct {
|
|||||||
AcceptUserID int64 `json:"acceptUserId"`
|
AcceptUserID int64 `json:"acceptUserId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendFrozenActivityRewardRequest 直接携带启用时冻结的单个奖励项,避免活动发奖时重新读取已变化的共享奖励组。
|
||||||
|
type SendFrozenActivityRewardRequest struct {
|
||||||
|
TrackID int64 `json:"trackId"`
|
||||||
|
AcceptUserID int64 `json:"acceptUserId"`
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
SysOrigin string `json:"sysOrigin"`
|
||||||
|
Prizes []FrozenActivityReward `json:"prizes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FrozenActivityReward 对齐 Java PrizeDescribe;内层 TrackID 是资源组商品 ID,外层请求 TrackID 才是 delivery item 幂等号。
|
||||||
|
type FrozenActivityReward struct {
|
||||||
|
TrackID int64 `json:"trackId"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
DetailType string `json:"detailType,omitempty"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
Quantity int64 `json:"quantity"`
|
||||||
|
Remark string `json:"remark,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserVipAbility struct {
|
||||||
|
UserID Int64Value `json:"userId"`
|
||||||
|
MysteriousInvisibility bool `json:"mysteriousInvisibility"`
|
||||||
|
}
|
||||||
|
|
||||||
type TeamPolicyReleaseQuery struct {
|
type TeamPolicyReleaseQuery struct {
|
||||||
SysOrigin string
|
SysOrigin string
|
||||||
Region string
|
Region string
|
||||||
@ -245,17 +274,20 @@ type RewardGroupItem struct {
|
|||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
DetailType string `json:"detailType"`
|
DetailType string `json:"detailType"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
BadgeName string `json:"badgeName"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Quantity Int64Value `json:"quantity"`
|
Quantity Int64Value `json:"quantity"`
|
||||||
Cover string `json:"cover"`
|
Cover string `json:"cover"`
|
||||||
SourceURL string `json:"sourceUrl"`
|
SourceURL string `json:"sourceUrl"`
|
||||||
Remark string `json:"remark"`
|
Remark string `json:"remark"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RewardGroupDetail struct {
|
type RewardGroupDetail struct {
|
||||||
ID Int64Value `json:"id"`
|
ID Int64Value `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
SysOrigin string `json:"sysOrigin"`
|
SysOrigin string `json:"sysOrigin"`
|
||||||
|
ShelfStatus *bool `json:"shelfStatus,omitempty"`
|
||||||
RewardConfigList []RewardGroupItem `json:"rewardConfigList"`
|
RewardConfigList []RewardGroupItem `json:"rewardConfigList"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -346,11 +378,13 @@ type freightSellerPageResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type resultResponse[T any] struct {
|
type resultResponse[T any] struct {
|
||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Body T `json:"body"`
|
ErrorMsg string `json:"errorMsg"`
|
||||||
Data T `json:"data"`
|
Body T `json:"body"`
|
||||||
Success *bool `json:"success"`
|
Data T `json:"data"`
|
||||||
|
Success *bool `json:"success"`
|
||||||
|
Status *bool `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Int64Value int64
|
type Int64Value int64
|
||||||
@ -513,6 +547,63 @@ func (c *Client) AuthenticateConsoleToken(ctx context.Context, authorization str
|
|||||||
return resp.Body, nil
|
return resp.Body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListConsoleButtonAliases 返回当前管理员真实拥有的按钮权限码,供 Go 写接口做服务端授权。
|
||||||
|
func (c *Client) ListConsoleButtonAliases(ctx context.Context, authorization string) (map[string]struct{}, error) {
|
||||||
|
authorization = strings.TrimSpace(authorization)
|
||||||
|
if authorization == "" {
|
||||||
|
return nil, fmt.Errorf("authorization is blank")
|
||||||
|
}
|
||||||
|
endpoint := strings.TrimRight(c.cfg.Java.ConsoleBaseURL, "/") + "/account/buttons/aliases"
|
||||||
|
var resp resultResponse[[]string]
|
||||||
|
if err := c.getJSON(ctx, endpoint, javaConsoleHeaders(authorization), &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
|
||||||
|
return nil, errors.New(firstNonEmpty(resp.ErrorMsg, resp.Message, "list console button aliases failed"))
|
||||||
|
}
|
||||||
|
aliases := resp.Body
|
||||||
|
if len(aliases) == 0 {
|
||||||
|
aliases = resp.Data
|
||||||
|
}
|
||||||
|
result := make(map[string]struct{}, len(aliases))
|
||||||
|
for _, alias := range aliases {
|
||||||
|
if alias = strings.TrimSpace(alias); alias != "" {
|
||||||
|
result[alias] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasConsoleMenuAlias 递归检查当前管理员菜单树,作为 Go 管理端读接口的服务端页面授权。
|
||||||
|
func (c *Client) HasConsoleMenuAlias(ctx context.Context, authorization, requiredAlias string) (bool, error) {
|
||||||
|
authorization = strings.TrimSpace(authorization)
|
||||||
|
if authorization == "" {
|
||||||
|
return false, fmt.Errorf("authorization is blank")
|
||||||
|
}
|
||||||
|
endpoint := strings.TrimRight(c.cfg.Java.ConsoleBaseURL, "/") + "/account/menus"
|
||||||
|
var resp resultResponse[[]ConsoleMenu]
|
||||||
|
if err := c.getJSON(ctx, endpoint, javaConsoleHeaders(authorization), &resp); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
|
||||||
|
return false, errors.New(firstNonEmpty(resp.ErrorMsg, resp.Message, "list console menus failed"))
|
||||||
|
}
|
||||||
|
menus := resp.Body
|
||||||
|
if len(menus) == 0 {
|
||||||
|
menus = resp.Data
|
||||||
|
}
|
||||||
|
var contains func([]ConsoleMenu) bool
|
||||||
|
contains = func(items []ConsoleMenu) bool {
|
||||||
|
for _, item := range items {
|
||||||
|
if strings.TrimSpace(item.Alias) == requiredAlias || contains(item.Childrens) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return contains(menus), nil
|
||||||
|
}
|
||||||
|
|
||||||
func javaConsoleHeaders(authorization string) http.Header {
|
func javaConsoleHeaders(authorization string) http.Header {
|
||||||
headers := http.Header{}
|
headers := http.Header{}
|
||||||
headers.Set("Authorization", strings.TrimSpace(authorization))
|
headers.Set("Authorization", strings.TrimSpace(authorization))
|
||||||
@ -632,6 +723,22 @@ func javaAppHeadersFromAuthorization(authorization string) http.Header {
|
|||||||
return headers
|
return headers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// javaInternalAppHeaders 为没有终端 token 的服务端查询注入可信用户上下文,例如榜单隐身能力检查。
|
||||||
|
func javaInternalAppHeaders(sysOrigin string, userID int64) http.Header {
|
||||||
|
sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin))
|
||||||
|
if sysOrigin == "" {
|
||||||
|
sysOrigin = "LIKEI"
|
||||||
|
}
|
||||||
|
headers := http.Header{}
|
||||||
|
headers.Set("Req-Sys-Origin", "origin="+sysOrigin+";originChild="+sysOrigin)
|
||||||
|
headers.Set("Req-Client", "Android")
|
||||||
|
headers.Set("Req-App-Intel", "version=1.0.0;build=100;channel=internal;model=go-service;sysVersion=go")
|
||||||
|
headers.Set("Req-Zone", "Asia/Shanghai")
|
||||||
|
headers.Set("Req-Imei", "go-service")
|
||||||
|
headers.Set("Req-Inner-Internal", fmt.Sprintf("time=%d;uid=%d;", time.Now().UnixMilli(), userID))
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
func trimBearerToken(authorization string) string {
|
func trimBearerToken(authorization string) string {
|
||||||
token := strings.TrimSpace(authorization)
|
token := strings.TrimSpace(authorization)
|
||||||
if len(token) >= 7 && strings.EqualFold(token[:7], "Bearer ") {
|
if len(token) >= 7 && strings.EqualFold(token[:7], "Bearer ") {
|
||||||
@ -649,7 +756,40 @@ func (c *Client) GrantProps(ctx context.Context, req GrantPropsRequest) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) SendActivityReward(ctx context.Context, req SendActivityRewardRequest) error {
|
func (c *Client) SendActivityReward(ctx context.Context, req SendActivityRewardRequest) error {
|
||||||
return c.postJSON(ctx, c.cfg.Java.OtherBaseURL+"/props-activity-cnf/client/sendActivityReward", req, nil, nil)
|
var resp resultResponse[any]
|
||||||
|
if err := c.postJSON(ctx, c.cfg.Java.OtherBaseURL+"/props-activity-cnf/client/sendActivityReward", req, nil, &resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
|
||||||
|
message := strings.TrimSpace(resp.Message)
|
||||||
|
if message == "" {
|
||||||
|
message = "send activity reward failed"
|
||||||
|
}
|
||||||
|
return errors.New(message)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendFrozenActivityReward 通过 Java 已有的 PrizeDescribe 接口发放一个冻结奖励项。
|
||||||
|
func (c *Client) SendFrozenActivityReward(ctx context.Context, req SendFrozenActivityRewardRequest) error {
|
||||||
|
if req.TrackID <= 0 || req.AcceptUserID <= 0 || len(req.Prizes) != 1 {
|
||||||
|
return fmt.Errorf("invalid frozen activity reward request")
|
||||||
|
}
|
||||||
|
var resp resultResponse[any]
|
||||||
|
if err := c.postJSON(ctx, strings.TrimRight(c.cfg.Java.OtherBaseURL, "/")+"/props/tool/client/sendProps", req, nil, &resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
|
||||||
|
message := strings.TrimSpace(resp.ErrorMsg)
|
||||||
|
if message == "" {
|
||||||
|
message = strings.TrimSpace(resp.Message)
|
||||||
|
}
|
||||||
|
if message == "" {
|
||||||
|
message = "send frozen activity reward failed"
|
||||||
|
}
|
||||||
|
return errors.New(message)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GivePropsBackpack(ctx context.Context, req GivePropsBackpackRequest) error {
|
func (c *Client) GivePropsBackpack(ctx context.Context, req GivePropsBackpackRequest) error {
|
||||||
@ -974,8 +1114,8 @@ func (c *Client) ChangeFreightBalance(ctx context.Context, req FreightBalanceCha
|
|||||||
if err := c.postJSON(ctx, endpoint, req, nil, &resp); err != nil {
|
if err := c.postJSON(ctx, endpoint, req, nil, &resp); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if resp.Success != nil && !*resp.Success {
|
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
|
||||||
message := strings.TrimSpace(resp.Message)
|
message := strings.TrimSpace(firstNonEmpty(resp.ErrorMsg, resp.Message))
|
||||||
if message == "" {
|
if message == "" {
|
||||||
message = "freight balance change failed"
|
message = "freight balance change failed"
|
||||||
}
|
}
|
||||||
@ -1034,6 +1174,60 @@ func (c *Client) GetUserProfile(ctx context.Context, userID int64) (UserProfile,
|
|||||||
return resp.Body, nil
|
return resp.Body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MapUserProfiles 批量查询用户资料,避免排行榜逐用户回源造成请求瀑布。
|
||||||
|
func (c *Client) MapUserProfiles(ctx context.Context, userIDs []int64) (map[int64]UserProfile, error) {
|
||||||
|
result := make(map[int64]UserProfile, len(userIDs))
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/user-profile/client/mapByUserIds"
|
||||||
|
var resp resultResponse[map[string]UserProfile]
|
||||||
|
if err := c.postJSON(ctx, endpoint, userIDs, nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
|
||||||
|
message := strings.TrimSpace(firstNonEmpty(resp.Message, resp.ErrorMsg))
|
||||||
|
if message == "" {
|
||||||
|
message = "map user profiles failed"
|
||||||
|
}
|
||||||
|
return nil, errors.New(message)
|
||||||
|
}
|
||||||
|
values := resp.Body
|
||||||
|
if len(values) == 0 {
|
||||||
|
values = resp.Data
|
||||||
|
}
|
||||||
|
for key, profile := range values {
|
||||||
|
userID, err := strconv.ParseInt(strings.TrimSpace(key), 10, 64)
|
||||||
|
if err != nil || userID <= 0 {
|
||||||
|
userID = int64(profile.ID)
|
||||||
|
}
|
||||||
|
if userID > 0 {
|
||||||
|
result[userID] = profile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserMysteriousInvisibility 复用 Yumi Java 已有 VIP 能力接口;调用失败由榜单层按隐私优先策略处理。
|
||||||
|
func (c *Client) GetUserMysteriousInvisibility(ctx context.Context, sysOrigin string, userID int64) (bool, error) {
|
||||||
|
if userID <= 0 {
|
||||||
|
return false, fmt.Errorf("userId is required")
|
||||||
|
}
|
||||||
|
endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/user/vip/ability"
|
||||||
|
var resp resultResponse[UserVipAbility]
|
||||||
|
if err := c.getJSON(ctx, endpoint, javaInternalAppHeaders(sysOrigin, userID), &resp); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
|
||||||
|
message := strings.TrimSpace(firstNonEmpty(resp.ErrorMsg, resp.Message))
|
||||||
|
if message == "" {
|
||||||
|
message = "get user vip ability failed"
|
||||||
|
}
|
||||||
|
return false, errors.New(message)
|
||||||
|
}
|
||||||
|
return resp.Body.MysteriousInvisibility, nil
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateUserCountry 只提交稳定的用户 ID 和国家 ID,国家码、名称以及并发锁统一由 Java other 处理。
|
// UpdateUserCountry 只提交稳定的用户 ID 和国家 ID,国家码、名称以及并发锁统一由 Java other 处理。
|
||||||
func (c *Client) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error {
|
func (c *Client) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error {
|
||||||
if userID <= 0 {
|
if userID <= 0 {
|
||||||
|
|||||||
169
internal/model/decimal24_2.go
Normal file
169
internal/model/decimal24_2.go
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Decimal24_2 用规范十进制文本承载 MySQL DECIMAL(24,2),避免金额经过 float64 丢精度。
|
||||||
|
// JSON 输出字符串以保护 22 位整数精度;数据库读写通过 Scanner/Valuer 完成。
|
||||||
|
type Decimal24_2 string
|
||||||
|
|
||||||
|
func ParseDecimal24_2(value string) (Decimal24_2, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return "", fmt.Errorf("decimal is blank")
|
||||||
|
}
|
||||||
|
negative := false
|
||||||
|
if value[0] == '-' {
|
||||||
|
negative, value = true, value[1:]
|
||||||
|
} else if value[0] == '+' {
|
||||||
|
return "", fmt.Errorf("decimal plus sign is not supported")
|
||||||
|
}
|
||||||
|
if value == "" || strings.ContainsAny(value, "eE") {
|
||||||
|
return "", fmt.Errorf("invalid decimal")
|
||||||
|
}
|
||||||
|
parts := strings.Split(value, ".")
|
||||||
|
if len(parts) > 2 || parts[0] == "" {
|
||||||
|
return "", fmt.Errorf("invalid decimal")
|
||||||
|
}
|
||||||
|
for _, char := range parts[0] {
|
||||||
|
if char < '0' || char > '9' {
|
||||||
|
return "", fmt.Errorf("invalid decimal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
integer := strings.TrimLeft(parts[0], "0")
|
||||||
|
if integer == "" {
|
||||||
|
integer = "0"
|
||||||
|
}
|
||||||
|
fraction := ""
|
||||||
|
if len(parts) == 2 {
|
||||||
|
fraction = parts[1]
|
||||||
|
for _, char := range fraction {
|
||||||
|
if char < '0' || char > '9' {
|
||||||
|
return "", fmt.Errorf("invalid decimal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// BigDecimal.stripTrailingZeros 后 scale<=2 的值均可接受,例如 1.2300。
|
||||||
|
if len(fraction) > 2 && strings.Trim(fraction[2:], "0") != "" {
|
||||||
|
return "", fmt.Errorf("decimal scale exceeds 2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(integer) > 22 {
|
||||||
|
return "", fmt.Errorf("decimal integer digits exceed 22")
|
||||||
|
}
|
||||||
|
if len(fraction) > 2 {
|
||||||
|
fraction = fraction[:2]
|
||||||
|
}
|
||||||
|
fraction += strings.Repeat("0", 2-len(fraction))
|
||||||
|
if integer == "0" && fraction == "00" {
|
||||||
|
negative = false
|
||||||
|
}
|
||||||
|
prefix := ""
|
||||||
|
if negative {
|
||||||
|
prefix = "-"
|
||||||
|
}
|
||||||
|
return Decimal24_2(prefix + integer + "." + fraction), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Decimal24_2) UnmarshalJSON(data []byte) error {
|
||||||
|
raw := strings.TrimSpace(string(data))
|
||||||
|
if raw == "" || raw == "null" || raw == `""` {
|
||||||
|
*d = DecimalZero()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) {
|
||||||
|
var value string
|
||||||
|
if err := json.Unmarshal(data, &value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw = value
|
||||||
|
}
|
||||||
|
parsed, err := ParseDecimal24_2(raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*d = parsed
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Decimal24_2) MarshalJSON() ([]byte, error) { return json.Marshal(d.String()) }
|
||||||
|
|
||||||
|
func (d *Decimal24_2) Scan(value any) error {
|
||||||
|
var raw string
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case nil:
|
||||||
|
raw = "0"
|
||||||
|
case []byte:
|
||||||
|
raw = string(typed)
|
||||||
|
case string:
|
||||||
|
raw = typed
|
||||||
|
case int64:
|
||||||
|
raw = fmt.Sprintf("%d", typed)
|
||||||
|
default:
|
||||||
|
raw = fmt.Sprint(typed)
|
||||||
|
}
|
||||||
|
parsed, err := ParseDecimal24_2(raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*d = parsed
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Decimal24_2) Value() (driver.Value, error) { return d.String(), nil }
|
||||||
|
|
||||||
|
func (d Decimal24_2) String() string {
|
||||||
|
if strings.TrimSpace(string(d)) == "" {
|
||||||
|
return "0.00"
|
||||||
|
}
|
||||||
|
return string(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecimalZero() Decimal24_2 { return Decimal24_2("0.00") }
|
||||||
|
|
||||||
|
func (d Decimal24_2) Positive() bool { return d.cents().Sign() > 0 }
|
||||||
|
|
||||||
|
func (d Decimal24_2) Compare(other Decimal24_2) int { return d.cents().Cmp(other.cents()) }
|
||||||
|
|
||||||
|
func (d Decimal24_2) MultiplyInt(multiplier int64) (Decimal24_2, error) {
|
||||||
|
if multiplier < 0 {
|
||||||
|
return "", fmt.Errorf("decimal multiplier must not be negative")
|
||||||
|
}
|
||||||
|
value := new(big.Int).Mul(d.cents(), big.NewInt(multiplier))
|
||||||
|
if len(new(big.Int).Abs(new(big.Int).Set(value)).String()) > 24 {
|
||||||
|
return "", fmt.Errorf("decimal exceeds DECIMAL(24,2)")
|
||||||
|
}
|
||||||
|
return decimalFromCents(value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Decimal24_2) cents() *big.Int {
|
||||||
|
raw := d.String()
|
||||||
|
negative := strings.HasPrefix(raw, "-")
|
||||||
|
raw = strings.TrimPrefix(raw, "-")
|
||||||
|
digits := strings.ReplaceAll(raw, ".", "")
|
||||||
|
value, ok := new(big.Int).SetString(digits, 10)
|
||||||
|
if !ok {
|
||||||
|
return new(big.Int)
|
||||||
|
}
|
||||||
|
if negative {
|
||||||
|
value.Neg(value)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func decimalFromCents(value *big.Int) Decimal24_2 {
|
||||||
|
negative := value.Sign() < 0
|
||||||
|
abs := new(big.Int).Abs(new(big.Int).Set(value)).String()
|
||||||
|
if len(abs) < 3 {
|
||||||
|
abs = strings.Repeat("0", 3-len(abs)) + abs
|
||||||
|
}
|
||||||
|
result := abs[:len(abs)-2] + "." + abs[len(abs)-2:]
|
||||||
|
if negative && result != "0.00" {
|
||||||
|
result = "-" + result
|
||||||
|
}
|
||||||
|
return Decimal24_2(result)
|
||||||
|
}
|
||||||
@ -116,12 +116,12 @@ func (TaskCenterEventDedup) TableName() string { return "task_center_event_dedup
|
|||||||
|
|
||||||
// TaskCenterEventArchiveOutbox 保存待归档到 COS 的原始事件。
|
// TaskCenterEventArchiveOutbox 保存待归档到 COS 的原始事件。
|
||||||
type TaskCenterEventArchiveOutbox struct {
|
type TaskCenterEventArchiveOutbox struct {
|
||||||
ID int64 `gorm:"column:id;primaryKey;index:idx_task_center_archive_claim_create,priority:3;index:idx_task_center_archive_claim_stale,priority:3"`
|
ID int64 `gorm:"column:id;primaryKey;index:idx_task_center_archive_claim_create,priority:3;index:idx_task_center_archive_claim_stale,priority:3;index:idx_task_center_game_king_backfill,priority:4"`
|
||||||
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_task_center_archive_event,priority:1"`
|
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_task_center_archive_event,priority:1;index:idx_task_center_game_king_backfill,priority:1"`
|
||||||
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_task_center_archive_event,priority:2"`
|
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_task_center_archive_event,priority:2"`
|
||||||
EventType string `gorm:"column:event_type;size:64"`
|
EventType string `gorm:"column:event_type;size:64;index:idx_task_center_game_king_backfill,priority:2"`
|
||||||
UserID int64 `gorm:"column:user_id"`
|
UserID int64 `gorm:"column:user_id"`
|
||||||
OccurredAt time.Time `gorm:"column:occurred_at"`
|
OccurredAt time.Time `gorm:"column:occurred_at;index:idx_task_center_game_king_backfill,priority:3"`
|
||||||
RawJSON string `gorm:"column:raw_json;type:text"`
|
RawJSON string `gorm:"column:raw_json;type:text"`
|
||||||
Status string `gorm:"column:status;size:32;index:idx_task_center_archive_status_time,priority:1;index:idx_task_center_archive_claim_create,priority:1;index:idx_task_center_archive_claim_stale,priority:1"`
|
Status string `gorm:"column:status;size:32;index:idx_task_center_archive_status_time,priority:1;index:idx_task_center_archive_claim_create,priority:1;index:idx_task_center_archive_claim_stale,priority:1"`
|
||||||
RetryCount int `gorm:"column:retry_count"`
|
RetryCount int `gorm:"column:retry_count"`
|
||||||
|
|||||||
170
internal/model/yumi_game_king_models.go
Normal file
170
internal/model/yumi_game_king_models.go
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// YumiGameKingActivity 保存一次游戏王活动及其不可变统计口径。
|
||||||
|
type YumiGameKingActivity struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey"`
|
||||||
|
ActivityCode string `gorm:"column:activity_code;size:64;uniqueIndex:uk_yumi_gk_activity_code"`
|
||||||
|
ActivityName string `gorm:"column:activity_name;size:128"`
|
||||||
|
ActivityDesc string `gorm:"column:activity_desc;size:1000"`
|
||||||
|
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_yumi_gk_activity_window,priority:1"`
|
||||||
|
Timezone string `gorm:"column:time_zone;size:64"`
|
||||||
|
StartTime time.Time `gorm:"column:start_time;index:idx_yumi_gk_activity_window,priority:3"`
|
||||||
|
EndTime time.Time `gorm:"column:end_time;index:idx_yumi_gk_activity_window,priority:4;index:idx_yumi_gk_activity_settle,priority:3"`
|
||||||
|
SettlementDelayMinutes int `gorm:"column:settlement_delay_minutes"`
|
||||||
|
SettlementTime time.Time `gorm:"column:settlement_time;index:idx_yumi_gk_activity_settle,priority:2"`
|
||||||
|
CoinPerDraw int64 `gorm:"column:coin_per_draw"`
|
||||||
|
RankingType string `gorm:"column:ranking_type;size:32"`
|
||||||
|
RankingPeriod string `gorm:"column:ranking_period;size:32"`
|
||||||
|
Enabled bool `gorm:"column:enabled;index:idx_yumi_gk_activity_window,priority:2"`
|
||||||
|
SettlementStatus string `gorm:"column:settlement_status;size:32;index:idx_yumi_gk_activity_settle,priority:1"`
|
||||||
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (YumiGameKingActivity) TableName() string { return "yumi_game_king_activity" }
|
||||||
|
|
||||||
|
// YumiGameKingScopeLock 串行同租户启用窗口检查,避免并发创建不同活动时同时通过 overlap COUNT。
|
||||||
|
type YumiGameKingScopeLock struct {
|
||||||
|
SysOrigin string `gorm:"column:sys_origin;size:32;primaryKey"`
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (YumiGameKingScopeLock) TableName() string { return "yumi_game_king_scope_lock" }
|
||||||
|
|
||||||
|
// YumiGameKingPrize 保存固定七个抽奖槽位;RewardItemsJSON 是启用前校验得到的展示快照。
|
||||||
|
type YumiGameKingPrize struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey"`
|
||||||
|
ActivityID int64 `gorm:"column:activity_id;uniqueIndex:uk_yumi_gk_prize_position,priority:1;index:idx_yumi_gk_prize_enabled,priority:1"`
|
||||||
|
PrizeName string `gorm:"column:prize_name;size:128"`
|
||||||
|
PrizeImage string `gorm:"column:prize_image;size:500"`
|
||||||
|
Weight int64 `gorm:"column:weight"`
|
||||||
|
Stock int64 `gorm:"column:stock"`
|
||||||
|
ResourceGroupID int64 `gorm:"column:resource_group_id"`
|
||||||
|
RewardGroupName string `gorm:"column:reward_group_name;size:128"`
|
||||||
|
RewardItemsJSON string `gorm:"column:reward_items_json;type:text"`
|
||||||
|
Enabled bool `gorm:"column:enabled;index:idx_yumi_gk_prize_enabled,priority:2"`
|
||||||
|
SortOrder int `gorm:"column:sort_order;uniqueIndex:uk_yumi_gk_prize_position,priority:2"`
|
||||||
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (YumiGameKingPrize) TableName() string { return "yumi_game_king_prize" }
|
||||||
|
|
||||||
|
// YumiGameKingRankReward 保存活动结束时固定六个连续名次奖励档。
|
||||||
|
type YumiGameKingRankReward struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey"`
|
||||||
|
ActivityID int64 `gorm:"column:activity_id;uniqueIndex:uk_yumi_gk_rank_range,priority:1;index:idx_yumi_gk_rank_start,priority:1"`
|
||||||
|
StartRank int `gorm:"column:start_rank;uniqueIndex:uk_yumi_gk_rank_range,priority:2;index:idx_yumi_gk_rank_start,priority:2"`
|
||||||
|
EndRank int `gorm:"column:end_rank;uniqueIndex:uk_yumi_gk_rank_range,priority:3"`
|
||||||
|
ResourceGroupID int64 `gorm:"column:resource_group_id"`
|
||||||
|
RewardName string `gorm:"column:reward_name;size:128"`
|
||||||
|
RewardItemsJSON string `gorm:"column:reward_items_json;type:text"`
|
||||||
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (YumiGameKingRankReward) TableName() string { return "yumi_game_king_rank_reward" }
|
||||||
|
|
||||||
|
// YumiGameKingConsumeLedger 是活动维度的幂等消费账本,实时事件和后台补数共用此边界。
|
||||||
|
type YumiGameKingConsumeLedger struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey"`
|
||||||
|
ActivityID int64 `gorm:"column:activity_id;uniqueIndex:uk_yumi_gk_ledger_event,priority:1;index:idx_yumi_gk_ledger_user,priority:1"`
|
||||||
|
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_yumi_gk_ledger_event,priority:2"`
|
||||||
|
UserID int64 `gorm:"column:user_id;index:idx_yumi_gk_ledger_user,priority:2"`
|
||||||
|
SysOrigin string `gorm:"column:sys_origin;size:32"`
|
||||||
|
Amount int64 `gorm:"column:amount"`
|
||||||
|
EventTime time.Time `gorm:"column:event_time;index:idx_yumi_gk_ledger_user,priority:3"`
|
||||||
|
PayloadJSON string `gorm:"column:payload_json;type:text"`
|
||||||
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (YumiGameKingConsumeLedger) TableName() string { return "yumi_game_king_consume_ledger" }
|
||||||
|
|
||||||
|
// YumiGameKingUser 保存榜单聚合和已使用抽奖次数,避免排行榜扫描消费账本。
|
||||||
|
type YumiGameKingUser struct {
|
||||||
|
ActivityID int64 `gorm:"column:activity_id;primaryKey;index:idx_yumi_gk_user_rank,priority:1"`
|
||||||
|
UserID int64 `gorm:"column:user_id;primaryKey;index:idx_yumi_gk_user_rank,priority:4,sort:asc"`
|
||||||
|
TotalConsumed int64 `gorm:"column:total_consumed;index:idx_yumi_gk_user_rank,priority:2,sort:desc"`
|
||||||
|
UsedChances int64 `gorm:"column:used_chances"`
|
||||||
|
ConsumeReachedTime time.Time `gorm:"column:consume_reached_time;index:idx_yumi_gk_user_rank,priority:3,sort:asc"`
|
||||||
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (YumiGameKingUser) TableName() string { return "yumi_game_king_user" }
|
||||||
|
|
||||||
|
// YumiGameKingUserDaily 保存活动时区自然日聚合,DAILY 排行不扫描消费明细。
|
||||||
|
type YumiGameKingUserDaily struct {
|
||||||
|
ActivityID int64 `gorm:"column:activity_id;primaryKey;index:idx_yumi_gk_daily_rank,priority:1"`
|
||||||
|
StatDate time.Time `gorm:"column:stat_date;type:date;primaryKey;index:idx_yumi_gk_daily_rank,priority:2"`
|
||||||
|
UserID int64 `gorm:"column:user_id;primaryKey;index:idx_yumi_gk_daily_rank,priority:5,sort:asc"`
|
||||||
|
TotalConsumed int64 `gorm:"column:total_consumed;index:idx_yumi_gk_daily_rank,priority:3,sort:desc"`
|
||||||
|
ConsumeReachedTime time.Time `gorm:"column:consume_reached_time;index:idx_yumi_gk_daily_rank,priority:4,sort:asc"`
|
||||||
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (YumiGameKingUserDaily) TableName() string { return "yumi_game_king_user_daily" }
|
||||||
|
|
||||||
|
// YumiGameKingDrawRecord 保存一次请求幂等的抽奖结果和整组奖励发放状态。
|
||||||
|
type YumiGameKingDrawRecord struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey"`
|
||||||
|
ActivityID int64 `gorm:"column:activity_id;uniqueIndex:uk_yumi_gk_draw_request,priority:1;index:idx_yumi_gk_draw_user,priority:1;index:idx_yumi_gk_draw_manage,priority:1"`
|
||||||
|
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_yumi_gk_draw_request,priority:2;index:idx_yumi_gk_draw_user,priority:2"`
|
||||||
|
RequestID string `gorm:"column:request_id;size:64;uniqueIndex:uk_yumi_gk_draw_request,priority:3"`
|
||||||
|
PrizeID int64 `gorm:"column:prize_id"`
|
||||||
|
PrizeName string `gorm:"column:prize_name;size:128"`
|
||||||
|
PrizeImage string `gorm:"column:prize_image;size:500"`
|
||||||
|
ResourceGroupID int64 `gorm:"column:resource_group_id"`
|
||||||
|
DeliveryStatus string `gorm:"column:delivery_status;size:32;index:idx_yumi_gk_draw_manage,priority:2"`
|
||||||
|
RetryCount int `gorm:"column:retry_count"`
|
||||||
|
FailureReason string `gorm:"column:failure_reason;size:500"`
|
||||||
|
DrawTime time.Time `gorm:"column:draw_time"`
|
||||||
|
DeliverTime *time.Time `gorm:"column:deliver_time"`
|
||||||
|
CreateTime time.Time `gorm:"column:create_time;index:idx_yumi_gk_draw_user,priority:3"`
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (YumiGameKingDrawRecord) TableName() string { return "yumi_game_king_draw_record" }
|
||||||
|
|
||||||
|
// YumiGameKingSettlementRecord 是活动结算时冻结的 Top30 排名和发奖状态。
|
||||||
|
type YumiGameKingSettlementRecord struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey"`
|
||||||
|
ActivityID int64 `gorm:"column:activity_id;uniqueIndex:uk_yumi_gk_settle_user,priority:1;uniqueIndex:uk_yumi_gk_settle_rank,priority:1;index:idx_yumi_gk_settle_status,priority:1"`
|
||||||
|
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_yumi_gk_settle_user,priority:2"`
|
||||||
|
RankNo int `gorm:"column:rank_no;uniqueIndex:uk_yumi_gk_settle_rank,priority:2"`
|
||||||
|
TotalConsumed int64 `gorm:"column:total_consumed"`
|
||||||
|
RankRewardID int64 `gorm:"column:rank_reward_id"`
|
||||||
|
ResourceGroupID int64 `gorm:"column:resource_group_id"`
|
||||||
|
BusinessNo string `gorm:"column:business_no;size:128;uniqueIndex:uk_yumi_gk_settle_business"`
|
||||||
|
DeliveryStatus string `gorm:"column:delivery_status;size:32;index:idx_yumi_gk_settle_status,priority:2"`
|
||||||
|
RetryCount int `gorm:"column:retry_count"`
|
||||||
|
FailureReason string `gorm:"column:failure_reason;size:500"`
|
||||||
|
DeliverTime *time.Time `gorm:"column:deliver_time"`
|
||||||
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (YumiGameKingSettlementRecord) TableName() string {
|
||||||
|
return "yumi_game_king_settlement_record"
|
||||||
|
}
|
||||||
|
|
||||||
|
// YumiGameKingDeliveryItem 把一次奖励组调用作为最小核账单元;UNKNOWN 不允许自动重试。
|
||||||
|
type YumiGameKingDeliveryItem struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey;index:idx_yumi_gk_delivery_status,priority:3"`
|
||||||
|
OwnerType string `gorm:"column:owner_type;size:20;uniqueIndex:uk_yumi_gk_delivery_owner,priority:1"`
|
||||||
|
OwnerID int64 `gorm:"column:owner_id;uniqueIndex:uk_yumi_gk_delivery_owner,priority:2"`
|
||||||
|
ActivityID int64 `gorm:"column:activity_id;index:idx_yumi_gk_delivery_activity,priority:1"`
|
||||||
|
UserID int64 `gorm:"column:user_id"`
|
||||||
|
ResourceGroupID int64 `gorm:"column:resource_group_id"`
|
||||||
|
DeliveryStatus string `gorm:"column:delivery_status;size:32;index:idx_yumi_gk_delivery_status,priority:1;index:idx_yumi_gk_delivery_activity,priority:2"`
|
||||||
|
RetryCount int `gorm:"column:retry_count"`
|
||||||
|
FailureReason string `gorm:"column:failure_reason;size:500"`
|
||||||
|
DeliverTime *time.Time `gorm:"column:deliver_time"`
|
||||||
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time;index:idx_yumi_gk_delivery_status,priority:2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (YumiGameKingDeliveryItem) TableName() string { return "yumi_game_king_delivery_item" }
|
||||||
221
internal/model/yumi_gift_challenge_models.go
Normal file
221
internal/model/yumi_gift_challenge_models.go
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// YumiGiftChallengeActivity 保存一次独立活动周期;活动开始后核心时间和奖励配置只读。
|
||||||
|
type YumiGiftChallengeActivity struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey"`
|
||||||
|
ActivityCode string `gorm:"column:activity_code"`
|
||||||
|
ActivityName string `gorm:"column:activity_name"`
|
||||||
|
ActivityDesc string `gorm:"column:activity_desc"`
|
||||||
|
SysOrigin string `gorm:"column:sys_origin"`
|
||||||
|
Timezone string `gorm:"column:time_zone"`
|
||||||
|
StartTime time.Time `gorm:"column:start_time"`
|
||||||
|
EndTime time.Time `gorm:"column:end_time"`
|
||||||
|
DailySettlementDelayMinutes int `gorm:"column:daily_settlement_delay_minutes"`
|
||||||
|
OverallSettlementDelayMinutes int `gorm:"column:overall_settlement_delay_minutes"`
|
||||||
|
OverallSettlementTime time.Time `gorm:"column:overall_settlement_time"`
|
||||||
|
DisplayTopN int `gorm:"column:display_top_n"`
|
||||||
|
Enabled bool `gorm:"column:enabled"`
|
||||||
|
OverallSettlementStatus string `gorm:"column:overall_settlement_status"`
|
||||||
|
Version int `gorm:"column:version"`
|
||||||
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (YumiGiftChallengeActivity) TableName() string { return "yumi_gift_challenge_activity" }
|
||||||
|
|
||||||
|
// YumiGiftChallengeTaskConfig 是活动固定三个每日任务槽位的定义。
|
||||||
|
type YumiGiftChallengeTaskConfig struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey"`
|
||||||
|
ActivityID int64 `gorm:"column:activity_id"`
|
||||||
|
TaskCode string `gorm:"column:task_code"`
|
||||||
|
TaskType string `gorm:"column:task_type"`
|
||||||
|
TaskTitle string `gorm:"column:task_title"`
|
||||||
|
TaskDesc string `gorm:"column:task_desc"`
|
||||||
|
TargetValue Decimal24_2 `gorm:"column:target_value;type:decimal(24,2)"`
|
||||||
|
ResourceGroupID *int64 `gorm:"column:resource_group_id"`
|
||||||
|
Enabled bool `gorm:"column:enabled"`
|
||||||
|
SortOrder int `gorm:"column:sort_order"`
|
||||||
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (YumiGiftChallengeTaskConfig) TableName() string { return "yumi_gift_challenge_task_config" }
|
||||||
|
|
||||||
|
// YumiGiftChallengeRankReward 把一个连续名次区间绑定到一个奖励组。
|
||||||
|
type YumiGiftChallengeRankReward struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey"`
|
||||||
|
ActivityID int64 `gorm:"column:activity_id"`
|
||||||
|
PeriodType string `gorm:"column:period_type"`
|
||||||
|
StartRank int `gorm:"column:start_rank"`
|
||||||
|
EndRank int `gorm:"column:end_rank"`
|
||||||
|
ResourceGroupID int64 `gorm:"column:resource_group_id"`
|
||||||
|
RewardName string `gorm:"column:reward_name"`
|
||||||
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (YumiGiftChallengeRankReward) TableName() string { return "yumi_gift_challenge_rank_reward" }
|
||||||
|
|
||||||
|
// YumiGiftChallengeRewardSnapshot 是启用时冻结的完整奖励项,展示和实际发放均读取此表。
|
||||||
|
type YumiGiftChallengeRewardSnapshot struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey"`
|
||||||
|
ActivityID int64 `gorm:"column:activity_id"`
|
||||||
|
ResourceGroupID int64 `gorm:"column:resource_group_id"`
|
||||||
|
RewardConfigID int64 `gorm:"column:reward_config_id"`
|
||||||
|
RewardType string `gorm:"column:reward_type"`
|
||||||
|
DetailType string `gorm:"column:detail_type"`
|
||||||
|
Content string `gorm:"column:content"`
|
||||||
|
Quantity int64 `gorm:"column:quantity"`
|
||||||
|
SortOrder int `gorm:"column:sort_order"`
|
||||||
|
Remark string `gorm:"column:remark"`
|
||||||
|
Cover string `gorm:"column:cover"`
|
||||||
|
SourceURL string `gorm:"column:source_url"`
|
||||||
|
DisplayName string `gorm:"column:display_name"`
|
||||||
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (YumiGiftChallengeRewardSnapshot) TableName() string {
|
||||||
|
return "yumi_gift_challenge_reward_snapshot"
|
||||||
|
}
|
||||||
|
|
||||||
|
// YumiGiftChallengeGiftLedger 是原始送礼事件的活动账本,也是消费幂等边界。
|
||||||
|
type YumiGiftChallengeGiftLedger struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey"`
|
||||||
|
ActivityID int64 `gorm:"column:activity_id"`
|
||||||
|
SourceEventTrackID string `gorm:"column:source_event_track_id"`
|
||||||
|
UserID int64 `gorm:"column:user_id"`
|
||||||
|
GiftID int64 `gorm:"column:gift_id"`
|
||||||
|
GiftTab string `gorm:"column:gift_tab"`
|
||||||
|
Amount Decimal24_2 `gorm:"column:amount;type:decimal(24,2)"`
|
||||||
|
EventTime time.Time `gorm:"column:event_time"`
|
||||||
|
StatDate string `gorm:"column:stat_date;type:date"`
|
||||||
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (YumiGiftChallengeGiftLedger) TableName() string { return "yumi_gift_challenge_gift_ledger" }
|
||||||
|
|
||||||
|
// YumiGiftChallengeUserScore 保存活动总榜聚合,避免榜单查询扫描明细账本。
|
||||||
|
type YumiGiftChallengeUserScore struct {
|
||||||
|
ActivityID int64 `gorm:"column:activity_id;primaryKey"`
|
||||||
|
UserID int64 `gorm:"column:user_id;primaryKey"`
|
||||||
|
Score Decimal24_2 `gorm:"column:score;type:decimal(24,2)"`
|
||||||
|
ScoreReachedTime time.Time `gorm:"column:score_reached_time"`
|
||||||
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (YumiGiftChallengeUserScore) TableName() string { return "yumi_gift_challenge_user_score" }
|
||||||
|
|
||||||
|
// YumiGiftChallengeUserDailyScore 保存活动时区自然日榜聚合。
|
||||||
|
type YumiGiftChallengeUserDailyScore struct {
|
||||||
|
ActivityID int64 `gorm:"column:activity_id;primaryKey"`
|
||||||
|
StatDate string `gorm:"column:stat_date;type:date;primaryKey"`
|
||||||
|
UserID int64 `gorm:"column:user_id;primaryKey"`
|
||||||
|
Score Decimal24_2 `gorm:"column:score;type:decimal(24,2)"`
|
||||||
|
ScoreReachedTime time.Time `gorm:"column:score_reached_time"`
|
||||||
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (YumiGiftChallengeUserDailyScore) TableName() string {
|
||||||
|
return "yumi_gift_challenge_user_daily_score"
|
||||||
|
}
|
||||||
|
|
||||||
|
// YumiGiftChallengeUserTaskDaily 冻结用户当天任务目标和奖励归属,并记录首次领取 requestId。
|
||||||
|
type YumiGiftChallengeUserTaskDaily struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey"`
|
||||||
|
ActivityID int64 `gorm:"column:activity_id"`
|
||||||
|
StatDate string `gorm:"column:stat_date;type:date"`
|
||||||
|
UserID int64 `gorm:"column:user_id"`
|
||||||
|
TaskConfigID int64 `gorm:"column:task_config_id"`
|
||||||
|
TaskCode string `gorm:"column:task_code"`
|
||||||
|
TaskType string `gorm:"column:task_type"`
|
||||||
|
TaskTitle string `gorm:"column:task_title"`
|
||||||
|
SortOrder int `gorm:"column:sort_order"`
|
||||||
|
TargetValue Decimal24_2 `gorm:"column:target_value;type:decimal(24,2)"`
|
||||||
|
ProgressValue Decimal24_2 `gorm:"column:progress_value;type:decimal(24,2)"`
|
||||||
|
CompletedTime *time.Time `gorm:"column:completed_time"`
|
||||||
|
ResourceGroupID *int64 `gorm:"column:resource_group_id"`
|
||||||
|
BusinessNo string `gorm:"column:business_no"`
|
||||||
|
ClaimRequestID string `gorm:"column:claim_request_id"`
|
||||||
|
DeliveryStatus string `gorm:"column:delivery_status"`
|
||||||
|
RetryCount int `gorm:"column:retry_count"`
|
||||||
|
FailureReason string `gorm:"column:failure_reason"`
|
||||||
|
ClaimedTime *time.Time `gorm:"column:claimed_time"`
|
||||||
|
DeliverTime *time.Time `gorm:"column:deliver_time"`
|
||||||
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (YumiGiftChallengeUserTaskDaily) TableName() string {
|
||||||
|
return "yumi_gift_challenge_user_task_daily"
|
||||||
|
}
|
||||||
|
|
||||||
|
// YumiGiftChallengePeriodSettlement 是包括空榜在内的周期门闩;PROCESSING 后禁止迟到写入。
|
||||||
|
type YumiGiftChallengePeriodSettlement struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey"`
|
||||||
|
ActivityID int64 `gorm:"column:activity_id"`
|
||||||
|
PeriodType string `gorm:"column:period_type"`
|
||||||
|
PeriodKey string `gorm:"column:period_key"`
|
||||||
|
StatDate *string `gorm:"column:stat_date;type:date"`
|
||||||
|
SnapshotDueTime time.Time `gorm:"column:snapshot_due_time"`
|
||||||
|
Status string `gorm:"column:status"`
|
||||||
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (YumiGiftChallengePeriodSettlement) TableName() string {
|
||||||
|
return "yumi_gift_challenge_period_settlement"
|
||||||
|
}
|
||||||
|
|
||||||
|
// YumiGiftChallengeSettlement 是已经冻结的获奖 parent,先提交后再物化和发送奖励项。
|
||||||
|
type YumiGiftChallengeSettlement struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey"`
|
||||||
|
ActivityID int64 `gorm:"column:activity_id"`
|
||||||
|
PeriodType string `gorm:"column:period_type"`
|
||||||
|
PeriodKey string `gorm:"column:period_key"`
|
||||||
|
StatDate *string `gorm:"column:stat_date;type:date"`
|
||||||
|
UserID int64 `gorm:"column:user_id"`
|
||||||
|
RankNo int `gorm:"column:rank_no"`
|
||||||
|
Score Decimal24_2 `gorm:"column:score;type:decimal(24,2)"`
|
||||||
|
RankRewardID int64 `gorm:"column:rank_reward_id"`
|
||||||
|
ResourceGroupID int64 `gorm:"column:resource_group_id"`
|
||||||
|
BusinessNo string `gorm:"column:business_no"`
|
||||||
|
DeliveryStatus string `gorm:"column:delivery_status"`
|
||||||
|
RetryCount int `gorm:"column:retry_count"`
|
||||||
|
FailureReason string `gorm:"column:failure_reason"`
|
||||||
|
DeliverTime *time.Time `gorm:"column:deliver_time"`
|
||||||
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (YumiGiftChallengeSettlement) TableName() string { return "yumi_gift_challenge_settlement" }
|
||||||
|
|
||||||
|
// YumiGiftChallengeDeliveryItem 是实际发奖最小幂等单元,UNKNOWN 只允许人工核账处理。
|
||||||
|
type YumiGiftChallengeDeliveryItem struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey"`
|
||||||
|
OwnerType string `gorm:"column:owner_type"`
|
||||||
|
OwnerID int64 `gorm:"column:owner_id"`
|
||||||
|
ActivityID int64 `gorm:"column:activity_id"`
|
||||||
|
UserID int64 `gorm:"column:user_id"`
|
||||||
|
RewardConfigID int64 `gorm:"column:reward_config_id"`
|
||||||
|
ResourceGroupID int64 `gorm:"column:resource_group_id"`
|
||||||
|
RewardType string `gorm:"column:reward_type"`
|
||||||
|
DetailType string `gorm:"column:detail_type"`
|
||||||
|
Content string `gorm:"column:content"`
|
||||||
|
Quantity int64 `gorm:"column:quantity"`
|
||||||
|
SortOrder int `gorm:"column:sort_order"`
|
||||||
|
Remark string `gorm:"column:remark"`
|
||||||
|
DeliveryStatus string `gorm:"column:delivery_status"`
|
||||||
|
RetryCount int `gorm:"column:retry_count"`
|
||||||
|
FailureReason string `gorm:"column:failure_reason"`
|
||||||
|
DeliverTime *time.Time `gorm:"column:deliver_time"`
|
||||||
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (YumiGiftChallengeDeliveryItem) TableName() string {
|
||||||
|
return "yumi_gift_challenge_delivery_item"
|
||||||
|
}
|
||||||
253
internal/router/game_king_routes.go
Normal file
253
internal/router/game_king_routes.go
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/config"
|
||||||
|
"chatapp3-golang/internal/service/gameking"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerGameKingRoutes 注册 Yumi H5、运营后台和外部 cron 内部触发接口。
|
||||||
|
func registerGameKingRoutes(engine *gin.Engine, cfg config.Config, javaClient authGateway, service *gameking.Service) {
|
||||||
|
if service == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h5 := engine.Group("/app/h5/game-king/yumi")
|
||||||
|
h5.Use(authMiddleware(javaClient))
|
||||||
|
h5.GET("/detail", func(c *gin.Context) {
|
||||||
|
resp, err := service.Detail(c.Request.Context(), mustAuthUser(c), parseGameKingID(c.Query("activityId")))
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, resp)
|
||||||
|
})
|
||||||
|
h5.GET("/me", func(c *gin.Context) {
|
||||||
|
resp, err := service.Me(c.Request.Context(), mustAuthUser(c), parseGameKingID(c.Query("activityId")))
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, resp)
|
||||||
|
})
|
||||||
|
h5.POST("/draw", func(c *gin.Context) {
|
||||||
|
var req gameking.DrawRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeError(c, gameking.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := service.Draw(c.Request.Context(), mustAuthUser(c), req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, resp)
|
||||||
|
})
|
||||||
|
h5.GET("/ranking", func(c *gin.Context) {
|
||||||
|
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "30")))
|
||||||
|
resp, err := service.Ranking(c.Request.Context(), mustAuthUser(c), parseGameKingID(c.Query("activityId")), c.Query("rankingType"), c.Query("period"), limit)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, resp)
|
||||||
|
})
|
||||||
|
h5.GET("/draw-records", func(c *gin.Context) {
|
||||||
|
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
|
||||||
|
resp, err := service.DrawRecords(c.Request.Context(), mustAuthUser(c), parseGameKingID(c.Query("activityId")), limit)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, resp)
|
||||||
|
})
|
||||||
|
|
||||||
|
admin := engine.Group("/resident-activity/game-king")
|
||||||
|
admin.Use(consoleAuthMiddleware(javaClient), consoleMenuMiddleware(javaClient, "ResidentGameKingActivity"))
|
||||||
|
admin.GET("/list", func(c *gin.Context) {
|
||||||
|
resp, err := service.ListActivities(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, resp)
|
||||||
|
})
|
||||||
|
admin.GET("/detail/:id", func(c *gin.Context) {
|
||||||
|
resp, err := service.ManageDetail(c.Request.Context(), parseGameKingID(c.Param("id")))
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, resp)
|
||||||
|
})
|
||||||
|
admin.POST("/save", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:edit"), func(c *gin.Context) {
|
||||||
|
var req gameking.SaveActivityRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeError(c, gameking.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := service.SaveActivity(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, resp)
|
||||||
|
})
|
||||||
|
admin.PUT("/enable/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:enable"), func(c *gin.Context) {
|
||||||
|
raw, exists := c.GetQuery("enabled")
|
||||||
|
if !exists {
|
||||||
|
writeError(c, gameking.NewAppError(http.StatusBadRequest, "enabled_required", "enabled query is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enabled, err := parseGameKingBool(raw, "enabled")
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := service.EnableActivity(c.Request.Context(), parseGameKingID(c.Param("id")), enabled); err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, gin.H{"success": true})
|
||||||
|
})
|
||||||
|
admin.GET("/prizes/:id", func(c *gin.Context) {
|
||||||
|
resp, err := service.ListPrizes(c.Request.Context(), parseGameKingID(c.Param("id")))
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, resp)
|
||||||
|
})
|
||||||
|
admin.PUT("/prizes/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:edit"), func(c *gin.Context) {
|
||||||
|
var req gameking.SavePrizesRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeError(c, gameking.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := service.SavePrizes(c.Request.Context(), parseGameKingID(c.Param("id")), req); err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, gin.H{"success": true})
|
||||||
|
})
|
||||||
|
admin.GET("/rank-rewards/:id", func(c *gin.Context) {
|
||||||
|
resp, err := service.ListRankRewards(c.Request.Context(), parseGameKingID(c.Param("id")))
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, resp)
|
||||||
|
})
|
||||||
|
admin.PUT("/rank-rewards/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:edit"), func(c *gin.Context) {
|
||||||
|
var req gameking.SaveRankRewardsRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeError(c, gameking.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := service.SaveRankRewards(c.Request.Context(), parseGameKingID(c.Param("id")), req); err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, gin.H{"success": true})
|
||||||
|
})
|
||||||
|
admin.GET("/draw-records/:id", func(c *gin.Context) {
|
||||||
|
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "100")))
|
||||||
|
resp, err := service.ManageDrawRecords(c.Request.Context(), parseGameKingID(c.Param("id")), c.Query("deliveryStatus"), limit)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, resp)
|
||||||
|
})
|
||||||
|
admin.POST("/draw/retry/:recordId", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:reconcile"), func(c *gin.Context) {
|
||||||
|
if err := service.RetryDraw(c.Request.Context(), parseGameKingID(c.Param("recordId"))); err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, gin.H{"success": true})
|
||||||
|
})
|
||||||
|
admin.GET("/settlement-records/:id", func(c *gin.Context) {
|
||||||
|
resp, err := service.SettlementRecords(c.Request.Context(), parseGameKingID(c.Param("id")))
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, resp)
|
||||||
|
})
|
||||||
|
admin.POST("/settlement/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:settle"), func(c *gin.Context) {
|
||||||
|
if err := service.Settle(c.Request.Context(), parseGameKingID(c.Param("id"))); err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, gin.H{"success": true})
|
||||||
|
})
|
||||||
|
admin.POST("/settlement/retry/:recordId", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:reconcile"), func(c *gin.Context) {
|
||||||
|
if err := service.RetrySettlement(c.Request.Context(), parseGameKingID(c.Param("recordId"))); err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, gin.H{"success": true})
|
||||||
|
})
|
||||||
|
admin.POST("/delivery-item/resolve/:itemId", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:reconcile"), func(c *gin.Context) {
|
||||||
|
raw, exists := c.GetQuery("delivered")
|
||||||
|
if !exists {
|
||||||
|
writeError(c, gameking.NewAppError(http.StatusBadRequest, "delivered_required", "delivered query is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delivered, err := parseGameKingBool(raw, "delivered")
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := service.ResolveUnknownDeliveryItem(c.Request.Context(), parseGameKingID(c.Param("itemId")), delivered); err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, gin.H{"success": true})
|
||||||
|
})
|
||||||
|
admin.POST("/backfill/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:backfill"), func(c *gin.Context) {
|
||||||
|
lastID := parseGameKingID(c.DefaultQuery("lastId", "0"))
|
||||||
|
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "200")))
|
||||||
|
resp, err := service.Backfill(c.Request.Context(), parseGameKingID(c.Param("id")), lastID, limit)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, resp)
|
||||||
|
})
|
||||||
|
|
||||||
|
internal := engine.Group("/internal/game-king")
|
||||||
|
// 结算入口会冻结榜单并产生真实奖励副作用,必须与 Yumi 礼物挑战保持相同的
|
||||||
|
// fail-closed 边界:服务端未配置密钥时直接拒绝,不能沿用历史内部接口的空密钥兼容行为。
|
||||||
|
internal.Use(requiredInternalSecretMiddleware(cfg.HTTP.InternalCallbackSecret))
|
||||||
|
internal.POST("/settle-due", func(c *gin.Context) {
|
||||||
|
resp, err := service.SettleDue(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, resp)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGameKingID(value string) int64 {
|
||||||
|
parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGameKingBool(value, field string) (bool, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "true", "1":
|
||||||
|
return true, nil
|
||||||
|
case "false", "0":
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
// resolve delivered=false 会立即重发,因此不能把拼写错误或空值默认为 false。
|
||||||
|
return false, gameking.NewAppError(http.StatusBadRequest, field+"_invalid", field+" must be true, false, 1 or 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -19,6 +20,14 @@ type authGateway interface {
|
|||||||
AuthenticateConsoleToken(ctx context.Context, authorization string) (integration.ConsoleAccount, error)
|
AuthenticateConsoleToken(ctx context.Context, authorization string) (integration.ConsoleAccount, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type consolePermissionGateway interface {
|
||||||
|
ListConsoleButtonAliases(ctx context.Context, authorization string) (map[string]struct{}, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type consoleMenuGateway interface {
|
||||||
|
HasConsoleMenuAlias(ctx context.Context, authorization, requiredAlias string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
// authMiddleware 校验用户 token,并把认证后的用户信息写入上下文。
|
// authMiddleware 校验用户 token,并把认证后的用户信息写入上下文。
|
||||||
func authMiddleware(javaClient authGateway) gin.HandlerFunc {
|
func authMiddleware(javaClient authGateway) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
@ -73,6 +82,58 @@ func consoleAuthMiddleware(javaClient authGateway) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// consolePermissionMiddleware 在写接口执行前读取 Java Console 的真实按钮权限,禁止只依赖前端隐藏按钮。
|
||||||
|
func consolePermissionMiddleware(javaClient authGateway, requiredAlias string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
gateway, ok := javaClient.(consolePermissionGateway)
|
||||||
|
if !ok {
|
||||||
|
writeError(c, common.NewAppError(http.StatusServiceUnavailable, "permission_gateway_unavailable", "console permission gateway is unavailable"))
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
aliases, err := gateway.ListConsoleButtonAliases(ctx, strings.TrimSpace(c.GetHeader("Authorization")))
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, common.NewAppError(http.StatusForbidden, "permission_check_failed", err.Error()))
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, allowed := aliases[requiredAlias]; !allowed {
|
||||||
|
writeError(c, common.NewAppError(http.StatusForbidden, "permission_denied", "required permission: "+requiredAlias))
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleMenuMiddleware 校验页面菜单权限,所有管理端 GET 都不能只依赖前端路由可见性。
|
||||||
|
func consoleMenuMiddleware(javaClient authGateway, requiredAlias string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
gateway, ok := javaClient.(consoleMenuGateway)
|
||||||
|
if !ok {
|
||||||
|
writeError(c, common.NewAppError(http.StatusServiceUnavailable, "menu_gateway_unavailable", "console menu gateway is unavailable"))
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
allowed, err := gateway.HasConsoleMenuAlias(ctx, strings.TrimSpace(c.GetHeader("Authorization")), requiredAlias)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, common.NewAppError(http.StatusForbidden, "menu_permission_check_failed", err.Error()))
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
writeError(c, common.NewAppError(http.StatusForbidden, "permission_denied", "required menu: "+requiredAlias))
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// internalSecretMiddleware 校验内部回调密钥。
|
// internalSecretMiddleware 校验内部回调密钥。
|
||||||
func internalSecretMiddleware(secret string) gin.HandlerFunc {
|
func internalSecretMiddleware(secret string) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
@ -92,6 +153,29 @@ func internalSecretMiddleware(secret string) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requiredInternalSecretMiddleware 用于会触发资金/奖励副作用的内部入口。
|
||||||
|
// 与兼容历史调用的 internalSecretMiddleware 不同,服务端未配置密钥时必须 fail-closed,
|
||||||
|
// 否则错误的部署配置会把无鉴权请求直接放进结算状态机。
|
||||||
|
func requiredInternalSecretMiddleware(secret string) gin.HandlerFunc {
|
||||||
|
configuredSecret := strings.TrimSpace(secret)
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if configuredSecret == "" {
|
||||||
|
writeError(c, common.NewAppError(http.StatusServiceUnavailable, "internal_secret_not_configured", "internal callback secret is not configured"))
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimSpace(c.GetHeader("X-Internal-Token"))
|
||||||
|
if subtle.ConstantTimeCompare([]byte(token), []byte(configuredSecret)) != 1 {
|
||||||
|
writeError(c, common.NewAppError(http.StatusUnauthorized, "invalid_internal_token", "internal token is invalid"))
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// mustAuthUser 从 Gin 上下文读取已经通过鉴权的用户。
|
// mustAuthUser 从 Gin 上下文读取已经通过鉴权的用户。
|
||||||
func mustAuthUser(c *gin.Context) common.AuthUser {
|
func mustAuthUser(c *gin.Context) common.AuthUser {
|
||||||
value, exists := c.Get(authUserContextKey)
|
value, exists := c.Get(authUserContextKey)
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import (
|
|||||||
"chatapp3-golang/internal/service/binancerecharge"
|
"chatapp3-golang/internal/service/binancerecharge"
|
||||||
"chatapp3-golang/internal/service/errorlog"
|
"chatapp3-golang/internal/service/errorlog"
|
||||||
"chatapp3-golang/internal/service/firstrechargereward"
|
"chatapp3-golang/internal/service/firstrechargereward"
|
||||||
|
"chatapp3-golang/internal/service/gameking"
|
||||||
"chatapp3-golang/internal/service/gameopen"
|
"chatapp3-golang/internal/service/gameopen"
|
||||||
"chatapp3-golang/internal/service/gameprovider"
|
"chatapp3-golang/internal/service/gameprovider"
|
||||||
"chatapp3-golang/internal/service/hostcenter"
|
"chatapp3-golang/internal/service/hostcenter"
|
||||||
@ -34,6 +35,7 @@ import (
|
|||||||
"chatapp3-golang/internal/service/voiceroomrocket"
|
"chatapp3-golang/internal/service/voiceroomrocket"
|
||||||
"chatapp3-golang/internal/service/weekstar"
|
"chatapp3-golang/internal/service/weekstar"
|
||||||
"chatapp3-golang/internal/service/wheel"
|
"chatapp3-golang/internal/service/wheel"
|
||||||
|
"chatapp3-golang/internal/service/yumigiftchallenge"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -48,6 +50,7 @@ type Services struct {
|
|||||||
FirstRechargeReward *firstrechargereward.Service
|
FirstRechargeReward *firstrechargereward.Service
|
||||||
Lingxian *lingxian.Service
|
Lingxian *lingxian.Service
|
||||||
GameOpen *gameopen.GameOpenService
|
GameOpen *gameopen.GameOpenService
|
||||||
|
GameKing *gameking.Service
|
||||||
GameProviders *gameprovider.Registry
|
GameProviders *gameprovider.Registry
|
||||||
GameVisibility *gameprovider.VisibilityPolicy
|
GameVisibility *gameprovider.VisibilityPolicy
|
||||||
LuckyGift *luckygift.LuckyGiftService
|
LuckyGift *luckygift.LuckyGiftService
|
||||||
@ -69,6 +72,7 @@ type Services struct {
|
|||||||
VoiceRoomRedPacket *voiceroomredpacket.Service
|
VoiceRoomRedPacket *voiceroomredpacket.Service
|
||||||
VoiceRoomRocket *voiceroomrocket.Service
|
VoiceRoomRocket *voiceroomrocket.Service
|
||||||
WeekStar *weekstar.WeekStarService
|
WeekStar *weekstar.WeekStarService
|
||||||
|
YumiGiftChallenge *yumigiftchallenge.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRouter 创建 Gin 路由,并按模块注册所有接口。
|
// NewRouter 创建 Gin 路由,并按模块注册所有接口。
|
||||||
@ -92,6 +96,7 @@ func NewRouter(
|
|||||||
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
|
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
|
||||||
registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
|
registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
|
||||||
registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter)
|
registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter)
|
||||||
|
registerGameKingRoutes(engine, cfg, javaClient, services.GameKing)
|
||||||
registerUserBadgeRoutes(engine, javaClient, services.UserBadge)
|
registerUserBadgeRoutes(engine, javaClient, services.UserBadge)
|
||||||
registerVipRoutes(engine, javaClient, services.VIP)
|
registerVipRoutes(engine, javaClient, services.VIP)
|
||||||
registerWheelRoutes(engine, cfg, javaClient, services.Wheel)
|
registerWheelRoutes(engine, cfg, javaClient, services.Wheel)
|
||||||
@ -100,6 +105,7 @@ func NewRouter(
|
|||||||
registerVoiceRoomRedPacketRoutes(engine, javaClient, services.VoiceRoomRedPacket)
|
registerVoiceRoomRedPacketRoutes(engine, javaClient, services.VoiceRoomRedPacket)
|
||||||
registerVoiceRoomRocketRoutes(engine, cfg, javaClient, services.VoiceRoomRocket)
|
registerVoiceRoomRocketRoutes(engine, cfg, javaClient, services.VoiceRoomRocket)
|
||||||
registerWeekStarRoutes(engine, javaClient, services.WeekStar)
|
registerWeekStarRoutes(engine, javaClient, services.WeekStar)
|
||||||
|
registerYumiGiftChallengeRoutes(engine, cfg, javaClient, services.YumiGiftChallenge)
|
||||||
registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward)
|
registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward)
|
||||||
registerHostCenterRoutes(engine, services.HostCenter)
|
registerHostCenterRoutes(engine, services.HostCenter)
|
||||||
registerGameOpenRoutes(engine, cfg, services.GameOpen)
|
registerGameOpenRoutes(engine, cfg, services.GameOpen)
|
||||||
|
|||||||
335
internal/router/yumi_gift_challenge_routes.go
Normal file
335
internal/router/yumi_gift_challenge_routes.go
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/config"
|
||||||
|
"chatapp3-golang/internal/service/yumigiftchallenge"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const yumiGiftChallengeMenuAlias = "ResidentYumiGiftChallenge"
|
||||||
|
|
||||||
|
// registerYumiGiftChallengeRoutes 注册 Yumi H5 用户接口与 Console 配置、结算、核账接口。
|
||||||
|
func registerYumiGiftChallengeRoutes(engine *gin.Engine, cfg config.Config, javaClient authGateway, service *yumigiftchallenge.Service) {
|
||||||
|
if service == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
internal := engine.Group("/internal/yumi-gift-challenge")
|
||||||
|
// 结算会产生真实奖励副作用;密钥缺失也必须拒绝,不能沿用历史内部接口的空密钥兼容行为。
|
||||||
|
internal.Use(requiredInternalSecretMiddleware(cfg.HTTP.InternalCallbackSecret))
|
||||||
|
internal.POST("/settle-due", func(c *gin.Context) {
|
||||||
|
batch, err := parseOptionalInt(c.Query("batch"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response, err := service.SettleDue(c.Request.Context(), batch)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, response)
|
||||||
|
})
|
||||||
|
h5 := engine.Group("/app/h5/gift-challenge/yumi")
|
||||||
|
h5.Use(authMiddleware(javaClient))
|
||||||
|
h5.GET("/detail", func(c *gin.Context) {
|
||||||
|
activityID, err := parseOptionalInt64(c.Query("activityId"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response, err := service.GetDetail(c.Request.Context(), mustAuthUser(c), activityID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, response)
|
||||||
|
})
|
||||||
|
h5.POST("/enter", func(c *gin.Context) {
|
||||||
|
var request yumigiftchallenge.EnterRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
writeBadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response, err := service.Enter(c.Request.Context(), mustAuthUser(c), request)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, response)
|
||||||
|
})
|
||||||
|
h5.GET("/ranking", func(c *gin.Context) {
|
||||||
|
activityID, err := parseOptionalInt64(c.Query("activityId"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit, err := parseOptionalInt(c.Query("limit"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
period := firstNonEmptyString(c.Query("periodType"), c.Query("period"))
|
||||||
|
response, err := service.GetRanking(c.Request.Context(), mustAuthUser(c), activityID, period, c.Query("statDate"), limit)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, response)
|
||||||
|
})
|
||||||
|
h5.POST("/tasks/:taskCode/claim", func(c *gin.Context) {
|
||||||
|
var request yumigiftchallenge.ClaimRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
writeBadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response, err := service.ClaimTask(c.Request.Context(), mustAuthUser(c), c.Param("taskCode"), request)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, response)
|
||||||
|
})
|
||||||
|
|
||||||
|
admin := engine.Group("/resident-activity/yumi-gift-challenge")
|
||||||
|
admin.Use(consoleAuthMiddleware(javaClient))
|
||||||
|
read := admin.Group("")
|
||||||
|
read.Use(consoleMenuMiddleware(javaClient, yumiGiftChallengeMenuAlias))
|
||||||
|
read.GET("/list", func(c *gin.Context) {
|
||||||
|
page, pageErr := parseOptionalInt(c.Query("page"))
|
||||||
|
size, sizeErr := parseOptionalInt(c.Query("size"))
|
||||||
|
if pageErr != nil || sizeErr != nil {
|
||||||
|
writeError(c, firstParseError(pageErr, sizeErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response, err := service.ListActivities(c.Request.Context(), c.Query("sysOrigin"), page, size)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, response)
|
||||||
|
})
|
||||||
|
read.GET("/detail/:id", func(c *gin.Context) {
|
||||||
|
activityID, ok := requirePathInt64(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response, err := service.GetAdminDetail(c.Request.Context(), activityID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, response)
|
||||||
|
})
|
||||||
|
read.GET("/tasks/:id", func(c *gin.Context) {
|
||||||
|
activityID, ok := requirePathInt64(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response, err := service.GetAdminDetail(c.Request.Context(), activityID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, response.Tasks)
|
||||||
|
})
|
||||||
|
read.GET("/rank-rewards/:id", func(c *gin.Context) {
|
||||||
|
activityID, ok := requirePathInt64(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response, err := service.GetAdminDetail(c.Request.Context(), activityID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, response.RankRewards)
|
||||||
|
})
|
||||||
|
read.GET("/ranking/:id", func(c *gin.Context) {
|
||||||
|
activityID, ok := requirePathInt64(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit, err := parseOptionalInt(c.Query("limit"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
period := firstNonEmptyString(c.Query("periodType"), c.Query("period"))
|
||||||
|
response, err := service.GetAdminRanking(c.Request.Context(), activityID, period, c.Query("statDate"), limit)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, response)
|
||||||
|
})
|
||||||
|
read.GET("/settlement-records/:id", func(c *gin.Context) {
|
||||||
|
activityID, ok := requirePathInt64(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page, pageErr := parseOptionalInt(c.Query("page"))
|
||||||
|
size, sizeErr := parseOptionalInt(c.Query("size"))
|
||||||
|
if pageErr != nil || sizeErr != nil {
|
||||||
|
writeError(c, firstParseError(pageErr, sizeErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response, err := service.PageSettlementRecords(c.Request.Context(), activityID, c.Query("periodType"), c.Query("statDate"), c.Query("deliveryStatus"), page, size)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, response)
|
||||||
|
})
|
||||||
|
|
||||||
|
admin.POST("/save", consolePermissionMiddleware(javaClient, "resident-activity:yumi-gift-challenge:edit"), func(c *gin.Context) {
|
||||||
|
var request yumigiftchallenge.SaveRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
writeBadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response, err := service.SaveActivity(c.Request.Context(), request)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, response)
|
||||||
|
})
|
||||||
|
admin.PUT("/enable/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-gift-challenge:enable"), func(c *gin.Context) {
|
||||||
|
activityID, ok := requirePathInt64(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enabled, err := strconv.ParseBool(strings.TrimSpace(c.Query("enabled")))
|
||||||
|
if err != nil {
|
||||||
|
writeBadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := service.SetEnabled(c.Request.Context(), activityID, enabled); err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, gin.H{"id": strconv.FormatInt(activityID, 10), "enabled": enabled})
|
||||||
|
})
|
||||||
|
admin.PUT("/tasks/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-gift-challenge:edit"), func(c *gin.Context) {
|
||||||
|
activityID, ok := requirePathInt64(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request yumigiftchallenge.TaskSaveRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
writeBadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.ActivityID = yumigiftchallenge.FlexibleInt64(activityID)
|
||||||
|
response, err := service.SaveTasks(c.Request.Context(), activityID, request)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, response)
|
||||||
|
})
|
||||||
|
admin.PUT("/rank-rewards/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-gift-challenge:edit"), func(c *gin.Context) {
|
||||||
|
activityID, ok := requirePathInt64(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request yumigiftchallenge.RankRewardSaveRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
writeBadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.ActivityID = yumigiftchallenge.FlexibleInt64(activityID)
|
||||||
|
response, err := service.SaveRankRewards(c.Request.Context(), activityID, request)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, response)
|
||||||
|
})
|
||||||
|
admin.POST("/settlement/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-gift-challenge:settle"), func(c *gin.Context) {
|
||||||
|
activityID, ok := requirePathInt64(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request yumigiftchallenge.SettlementRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
writeBadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := service.Settle(c.Request.Context(), activityID, request); err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, gin.H{"settled": true})
|
||||||
|
})
|
||||||
|
admin.POST("/delivery-item/resolve/:itemId", consolePermissionMiddleware(javaClient, "resident-activity:yumi-gift-challenge:reconcile"), func(c *gin.Context) {
|
||||||
|
itemID, ok := requirePathInt64(c, "itemId")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request yumigiftchallenge.ResolveDeliveryRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
writeBadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := service.ResolveUnknownDeliveryItem(c.Request.Context(), itemID, request.Delivered); err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, gin.H{"id": strconv.FormatInt(itemID, 10), "delivered": request.Delivered})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOptionalInt64(value string) (int64, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
return 0, yumigiftchallenge.NewAppError(http.StatusBadRequest, "invalid_id", "id must be a positive int64 string")
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOptionalInt(value string) (int, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
parsed, err := strconv.Atoi(value)
|
||||||
|
if err != nil || parsed < 0 {
|
||||||
|
return 0, yumigiftchallenge.NewAppError(http.StatusBadRequest, "invalid_number", "query number must be a non-negative integer")
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requirePathInt64(c *gin.Context, name string) (int64, bool) {
|
||||||
|
value, err := parseOptionalInt64(c.Param(name))
|
||||||
|
if err != nil || value <= 0 {
|
||||||
|
if err == nil {
|
||||||
|
err = yumigiftchallenge.NewAppError(http.StatusBadRequest, "invalid_id", name+" must be a positive int64 string")
|
||||||
|
}
|
||||||
|
writeError(c, err)
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstParseError(values ...error) error {
|
||||||
|
for _, err := range values {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return yumigiftchallenge.NewAppError(http.StatusBadRequest, "bad_request", "invalid query")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeBadRequest(c *gin.Context, err error) {
|
||||||
|
writeError(c, yumigiftchallenge.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||||
|
}
|
||||||
534
internal/service/gameking/config.go
Normal file
534
internal/service/gameking/config.go
Normal file
@ -0,0 +1,534 @@
|
|||||||
|
package gameking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
"chatapp3-golang/internal/utils"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListActivities 返回后台活动列表,活动数量很小,避免引入无意义分页状态。
|
||||||
|
func (s *Service) ListActivities(ctx context.Context) ([]ActivityView, error) {
|
||||||
|
var rows []model.YumiGameKingActivity
|
||||||
|
if err := s.db.WithContext(ctx).Order("id DESC").Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]ActivityView, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
result = append(result, activityView(row))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManageDetail 返回后台活动及其七奖项、六排名档完整配置。
|
||||||
|
func (s *Service) ManageDetail(ctx context.Context, activityID int64) (*DetailResponse, error) {
|
||||||
|
activity, err := s.loadActivity(ctx, activityID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.buildDetail(ctx, activity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detail 返回当前登录用户所属平台可见的活动详情。
|
||||||
|
func (s *Service) Detail(ctx context.Context, user AuthUser, activityID int64) (*DetailResponse, error) {
|
||||||
|
activity, err := s.resolveAppActivity(ctx, normalizeSysOrigin(user.SysOrigin), activityID, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.buildDetail(ctx, activity)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) buildDetail(ctx context.Context, activity model.YumiGameKingActivity) (*DetailResponse, error) {
|
||||||
|
prizes, err := s.loadPrizes(ctx, activity.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rankRewards, err := s.loadRankRewards(ctx, activity.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
prizeViews := make([]PrizeView, 0, len(prizes))
|
||||||
|
for _, row := range prizes {
|
||||||
|
prizeViews = append(prizeViews, prizeView(row))
|
||||||
|
}
|
||||||
|
rankViews := make([]RankRewardView, 0, len(rankRewards))
|
||||||
|
for _, row := range rankRewards {
|
||||||
|
rankViews = append(rankViews, rankRewardView(row))
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
return &DetailResponse{
|
||||||
|
Activity: activityView(activity),
|
||||||
|
Prizes: prizeViews,
|
||||||
|
RankRewards: rankViews,
|
||||||
|
SupportedRankingTypes: []string{rankingTypeTycoon},
|
||||||
|
SupportedRankingPeriods: []string{"DAILY", rankingPeriodOverall},
|
||||||
|
ActivityStatus: activityStatus(activity, now),
|
||||||
|
ServerTime: now.UnixMilli(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveActivity 新增或修改活动。首次启用前会重新读取全部奖励组并冻结展示快照。
|
||||||
|
func (s *Service) SaveActivity(ctx context.Context, req SaveActivityRequest) (*ActivityView, error) {
|
||||||
|
normalized, err := normalizeActivityInput(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing *model.YumiGameKingActivity
|
||||||
|
if normalized.ID.Int64() > 0 {
|
||||||
|
row, loadErr := s.loadActivity(ctx, normalized.ID.Int64())
|
||||||
|
if loadErr != nil {
|
||||||
|
return nil, loadErr
|
||||||
|
}
|
||||||
|
existing = &row
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedPrizes []normalizedPrize
|
||||||
|
if normalized.Prizes != nil {
|
||||||
|
normalizedPrizes, err = s.normalizePrizes(ctx, normalized.SysOrigin, normalized.Prizes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var normalizedRanks []normalizedRankReward
|
||||||
|
if normalized.RankRewards != nil {
|
||||||
|
normalizedRanks, err = s.normalizeRankRewards(ctx, normalized.SysOrigin, normalized.RankRewards)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只在“首次启用/重新启用”的开始前状态补齐并刷新子配置。已经开始且仍为 enabled
|
||||||
|
// 的元数据编辑绝不能走这条路径,否则会重建奖品 ID、刷新快照并把已消耗库存写回旧值。
|
||||||
|
needsEnableValidation := normalized.Enabled && (existing == nil || !existing.Enabled)
|
||||||
|
if needsEnableValidation {
|
||||||
|
if normalizedPrizes == nil {
|
||||||
|
if existing == nil {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "prizes_required", "seven prizes are required before enabling")
|
||||||
|
}
|
||||||
|
inputs, loadErr := s.currentPrizeInputs(ctx, existing.ID)
|
||||||
|
if loadErr != nil {
|
||||||
|
return nil, loadErr
|
||||||
|
}
|
||||||
|
normalizedPrizes, err = s.normalizePrizes(ctx, normalized.SysOrigin, inputs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if normalizedRanks == nil {
|
||||||
|
if existing == nil {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "rank_rewards_required", "six rank reward tiers are required before enabling")
|
||||||
|
}
|
||||||
|
inputs, loadErr := s.currentRankInputs(ctx, existing.ID)
|
||||||
|
if loadErr != nil {
|
||||||
|
return nil, loadErr
|
||||||
|
}
|
||||||
|
normalizedRanks, err = s.normalizeRankRewards(ctx, normalized.SysOrigin, inputs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activityID := normalized.ID.Int64()
|
||||||
|
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var row model.YumiGameKingActivity
|
||||||
|
created := activityID <= 0
|
||||||
|
writeTime := time.Now()
|
||||||
|
if !created {
|
||||||
|
if err := withWriteLock(tx).Where("id = ?", activityID).First(&row).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return NewAppError(http.StatusNotFound, "activity_not_found", "game king activity was not found")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 奖励组远程校验和数据库锁等待都可能跨过 startTime,必须在拿到排他锁后
|
||||||
|
// 重新取时钟,不能用事务外的旧 now 绕过开始锁。
|
||||||
|
writeTime = time.Now()
|
||||||
|
if row.Enabled && !writeTime.Before(row.StartTime) {
|
||||||
|
if err := validateStartedActivityUpdate(row, normalized); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if normalizedPrizes != nil || normalizedRanks != nil {
|
||||||
|
return NewAppError(http.StatusConflict, "activity_config_locked", "prizes and rank rewards cannot change after activity start")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
activityID, err = utils.NextID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
row = model.YumiGameKingActivity{ID: activityID, SettlementStatus: SettlementNotStarted, CreateTime: writeTime}
|
||||||
|
}
|
||||||
|
// 事务外读取时可能仍为 enabled,但在等待锁期间被另一个管理员关闭。此时本请求
|
||||||
|
// 没有执行重新启用所需的奖励组校验,必须失败并让调用方重试,不能用旧快照启用。
|
||||||
|
if normalized.Enabled && !row.Enabled && (normalizedPrizes == nil || normalizedRanks == nil) {
|
||||||
|
return NewAppError(http.StatusConflict, "activity_state_changed", "activity state changed; reload and retry")
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalized.Enabled && !row.Enabled && !time.UnixMilli(normalized.StartTime).After(writeTime) {
|
||||||
|
return NewAppError(http.StatusConflict, "activity_start_must_be_future", "the first enabled startTime must be in the future")
|
||||||
|
}
|
||||||
|
if normalized.Enabled {
|
||||||
|
if err := lockActivityScopeTx(tx, normalized.SysOrigin, writeTime); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ensureNoEnabledOverlap(tx, activityID, normalized.SysOrigin, time.UnixMilli(normalized.StartTime), time.UnixMilli(normalized.EndTime)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var codeCount int64
|
||||||
|
if err := tx.Model(&model.YumiGameKingActivity{}).
|
||||||
|
Where("activity_code = ? AND id <> ?", normalized.ActivityCode, activityID).
|
||||||
|
Count(&codeCount).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if codeCount > 0 {
|
||||||
|
return NewAppError(http.StatusConflict, "activity_code_exists", "activityCode already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
row.ActivityCode = normalized.ActivityCode
|
||||||
|
row.ActivityName = normalized.ActivityName
|
||||||
|
row.ActivityDesc = normalized.ActivityDesc
|
||||||
|
row.SysOrigin = normalized.SysOrigin
|
||||||
|
row.Timezone = normalized.Timezone
|
||||||
|
row.StartTime = time.UnixMilli(normalized.StartTime)
|
||||||
|
row.EndTime = time.UnixMilli(normalized.EndTime)
|
||||||
|
row.SettlementDelayMinutes = *normalized.SettlementDelayMinutes
|
||||||
|
row.SettlementTime = row.EndTime.Add(time.Duration(row.SettlementDelayMinutes) * time.Minute)
|
||||||
|
row.CoinPerDraw = normalized.CoinPerDraw
|
||||||
|
row.RankingType = normalized.RankingType
|
||||||
|
row.RankingPeriod = normalized.RankingPeriod
|
||||||
|
row.Enabled = normalized.Enabled
|
||||||
|
row.UpdateTime = writeTime
|
||||||
|
if row.SettlementStatus == "" {
|
||||||
|
row.SettlementStatus = SettlementNotStarted
|
||||||
|
}
|
||||||
|
if err := tx.Save(&row).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if normalizedPrizes != nil {
|
||||||
|
if err := replacePrizesTx(tx, activityID, normalizedPrizes, writeTime); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if normalizedRanks != nil {
|
||||||
|
if err := replaceRankRewardsTx(tx, activityID, normalizedRanks, writeTime); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
row, err := s.loadActivity(ctx, activityID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
view := activityView(row)
|
||||||
|
return &view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableActivity 在活动开始前启停活动;启用会重新验证并刷新全部奖励快照。
|
||||||
|
func (s *Service) EnableActivity(ctx context.Context, activityID int64, enabled bool) error {
|
||||||
|
activity, err := s.loadActivity(ctx, activityID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if activity.Enabled && !time.Now().Before(activity.StartTime) {
|
||||||
|
return NewAppError(http.StatusConflict, "activity_config_locked", "activity enable state cannot change after activity start")
|
||||||
|
}
|
||||||
|
var prizes []normalizedPrize
|
||||||
|
var ranks []normalizedRankReward
|
||||||
|
if enabled {
|
||||||
|
prizeInputs, loadErr := s.currentPrizeInputs(ctx, activityID)
|
||||||
|
if loadErr != nil {
|
||||||
|
return loadErr
|
||||||
|
}
|
||||||
|
prizes, err = s.normalizePrizes(ctx, activity.SysOrigin, prizeInputs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rankInputs, loadErr := s.currentRankInputs(ctx, activityID)
|
||||||
|
if loadErr != nil {
|
||||||
|
return loadErr
|
||||||
|
}
|
||||||
|
ranks, err = s.normalizeRankRewards(ctx, activity.SysOrigin, rankInputs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var locked model.YumiGameKingActivity
|
||||||
|
if err := withWriteLock(tx).Where("id = ?", activityID).First(&locked).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
lockedNow := time.Now()
|
||||||
|
if locked.Enabled && !lockedNow.Before(locked.StartTime) {
|
||||||
|
return NewAppError(http.StatusConflict, "activity_config_locked", "activity enable state cannot change after activity start")
|
||||||
|
}
|
||||||
|
if enabled {
|
||||||
|
if !locked.StartTime.After(lockedNow) {
|
||||||
|
return NewAppError(http.StatusConflict, "activity_start_must_be_future", "the first enabled startTime must be in the future")
|
||||||
|
}
|
||||||
|
if err := lockActivityScopeTx(tx, locked.SysOrigin, lockedNow); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ensureNoEnabledOverlap(tx, activityID, locked.SysOrigin, locked.StartTime, locked.EndTime); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := replacePrizesTx(tx, activityID, prizes, lockedNow); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := replaceRankRewardsTx(tx, activityID, ranks, lockedNow); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Model(&model.YumiGameKingActivity{}).Where("id = ?", activityID).
|
||||||
|
Updates(map[string]any{"enabled": enabled, "update_time": lockedNow}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListPrizes(ctx context.Context, activityID int64) ([]PrizeView, error) {
|
||||||
|
if _, err := s.loadActivity(ctx, activityID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := s.loadPrizes(ctx, activityID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]PrizeView, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
result = append(result, prizeView(row))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SavePrizes(ctx context.Context, activityID int64, req SavePrizesRequest) error {
|
||||||
|
if req.ActivityID.Int64() != activityID {
|
||||||
|
return NewAppError(http.StatusBadRequest, "activity_id_mismatch", "body activityId must match path id")
|
||||||
|
}
|
||||||
|
activity, err := s.loadActivity(ctx, activityID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if activity.Enabled && !time.Now().Before(activity.StartTime) {
|
||||||
|
return NewAppError(http.StatusConflict, "activity_config_locked", "prizes cannot change after activity start")
|
||||||
|
}
|
||||||
|
values, err := s.normalizePrizes(ctx, activity.SysOrigin, req.Prizes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var locked model.YumiGameKingActivity
|
||||||
|
if err := withWriteLock(tx).Where("id = ?", activityID).First(&locked).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
lockedNow := time.Now()
|
||||||
|
if locked.Enabled && !lockedNow.Before(locked.StartTime) {
|
||||||
|
return NewAppError(http.StatusConflict, "activity_config_locked", "prizes cannot change after activity start")
|
||||||
|
}
|
||||||
|
return replacePrizesTx(tx, activityID, values, lockedNow)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListRankRewards(ctx context.Context, activityID int64) ([]RankRewardView, error) {
|
||||||
|
if _, err := s.loadActivity(ctx, activityID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := s.loadRankRewards(ctx, activityID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]RankRewardView, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
result = append(result, rankRewardView(row))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SaveRankRewards(ctx context.Context, activityID int64, req SaveRankRewardsRequest) error {
|
||||||
|
if req.ActivityID.Int64() != activityID {
|
||||||
|
return NewAppError(http.StatusBadRequest, "activity_id_mismatch", "body activityId must match path id")
|
||||||
|
}
|
||||||
|
activity, err := s.loadActivity(ctx, activityID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if activity.Enabled && !time.Now().Before(activity.StartTime) {
|
||||||
|
return NewAppError(http.StatusConflict, "activity_config_locked", "rank rewards cannot change after activity start")
|
||||||
|
}
|
||||||
|
values, err := s.normalizeRankRewards(ctx, activity.SysOrigin, req.RankRewards)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var locked model.YumiGameKingActivity
|
||||||
|
if err := withWriteLock(tx).Where("id = ?", activityID).First(&locked).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
lockedNow := time.Now()
|
||||||
|
if locked.Enabled && !lockedNow.Before(locked.StartTime) {
|
||||||
|
return NewAppError(http.StatusConflict, "activity_config_locked", "rank rewards cannot change after activity start")
|
||||||
|
}
|
||||||
|
return replaceRankRewardsTx(tx, activityID, values, lockedNow)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadActivity(ctx context.Context, activityID int64) (model.YumiGameKingActivity, error) {
|
||||||
|
if activityID <= 0 {
|
||||||
|
return model.YumiGameKingActivity{}, NewAppError(http.StatusBadRequest, "invalid_activity_id", "activityId is required")
|
||||||
|
}
|
||||||
|
var row model.YumiGameKingActivity
|
||||||
|
if err := s.db.WithContext(ctx).Where("id = ?", activityID).First(&row).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return row, NewAppError(http.StatusNotFound, "activity_not_found", "game king activity was not found")
|
||||||
|
}
|
||||||
|
return row, err
|
||||||
|
}
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolveAppActivity(ctx context.Context, sysOrigin string, activityID int64, now time.Time) (model.YumiGameKingActivity, error) {
|
||||||
|
var row model.YumiGameKingActivity
|
||||||
|
base := s.db.WithContext(ctx).Where("sys_origin = ? AND enabled = ?", sysOrigin, true)
|
||||||
|
if activityID > 0 {
|
||||||
|
if err := base.Where("id = ?", activityID).First(&row).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return row, NewAppError(http.StatusNotFound, "activity_not_found", "enabled game king activity was not found")
|
||||||
|
}
|
||||||
|
return row, err
|
||||||
|
}
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
for _, query := range []*gorm.DB{
|
||||||
|
base.Session(&gorm.Session{}).Where("start_time <= ? AND end_time > ?", now, now).Order("start_time DESC"),
|
||||||
|
base.Session(&gorm.Session{}).Where("start_time > ?", now).Order("start_time ASC"),
|
||||||
|
base.Session(&gorm.Session{}).Where("end_time <= ?", now).Order("end_time DESC"),
|
||||||
|
} {
|
||||||
|
if err := query.First(&row).Error; err == nil {
|
||||||
|
return row, nil
|
||||||
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return row, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return row, NewAppError(http.StatusNotFound, "activity_not_found", "enabled game king activity was not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadPrizes(ctx context.Context, activityID int64) ([]model.YumiGameKingPrize, error) {
|
||||||
|
var rows []model.YumiGameKingPrize
|
||||||
|
err := s.db.WithContext(ctx).Where("activity_id = ?", activityID).Order("sort_order ASC").Find(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadRankRewards(ctx context.Context, activityID int64) ([]model.YumiGameKingRankReward, error) {
|
||||||
|
var rows []model.YumiGameKingRankReward
|
||||||
|
err := s.db.WithContext(ctx).Where("activity_id = ?", activityID).Order("start_rank ASC").Find(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) currentPrizeInputs(ctx context.Context, activityID int64) ([]PrizeInput, error) {
|
||||||
|
rows, err := s.loadPrizes(ctx, activityID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
values := make([]PrizeInput, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
values = append(values, PrizeInput{ID: FlexibleInt64(row.ID), PrizeName: row.PrizeName, PrizeImage: row.PrizeImage, Weight: row.Weight, Stock: row.Stock, ResourceGroupID: FlexibleInt64(row.ResourceGroupID), Enabled: row.Enabled, SortOrder: row.SortOrder})
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) currentRankInputs(ctx context.Context, activityID int64) ([]RankRewardInput, error) {
|
||||||
|
rows, err := s.loadRankRewards(ctx, activityID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
values := make([]RankRewardInput, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
values = append(values, RankRewardInput{ID: FlexibleInt64(row.ID), StartRank: row.StartRank, EndRank: row.EndRank, ResourceGroupID: FlexibleInt64(row.ResourceGroupID), RewardName: row.RewardName})
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func replacePrizesTx(tx *gorm.DB, activityID int64, values []normalizedPrize, now time.Time) error {
|
||||||
|
if err := tx.Where("activity_id = ?", activityID).Delete(&model.YumiGameKingPrize{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([]model.YumiGameKingPrize, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
id, err := utils.NextID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows = append(rows, model.YumiGameKingPrize{
|
||||||
|
ID: id, ActivityID: activityID, PrizeName: value.Input.PrizeName, PrizeImage: value.Input.PrizeImage,
|
||||||
|
Weight: value.Input.Weight, Stock: value.Input.Stock, ResourceGroupID: value.Snapshot.GroupID,
|
||||||
|
RewardGroupName: value.Snapshot.GroupName, RewardItemsJSON: value.Snapshot.ItemsJSON,
|
||||||
|
Enabled: true, SortOrder: value.Input.SortOrder, CreateTime: now, UpdateTime: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return tx.Create(&rows).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceRankRewardsTx(tx *gorm.DB, activityID int64, values []normalizedRankReward, now time.Time) error {
|
||||||
|
if err := tx.Where("activity_id = ?", activityID).Delete(&model.YumiGameKingRankReward{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([]model.YumiGameKingRankReward, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
id, err := utils.NextID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows = append(rows, model.YumiGameKingRankReward{
|
||||||
|
ID: id, ActivityID: activityID, StartRank: value.Input.StartRank, EndRank: value.Input.EndRank,
|
||||||
|
ResourceGroupID: value.Snapshot.GroupID, RewardName: value.Input.RewardName,
|
||||||
|
RewardItemsJSON: value.Snapshot.ItemsJSON, CreateTime: now, UpdateTime: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return tx.Create(&rows).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureNoEnabledOverlap(tx *gorm.DB, activityID int64, sysOrigin string, start, end time.Time) error {
|
||||||
|
var count int64
|
||||||
|
if err := tx.Model(&model.YumiGameKingActivity{}).
|
||||||
|
Where("sys_origin = ? AND enabled = ? AND id <> ? AND start_time < ? AND end_time > ?", sysOrigin, true, activityID, end, start).
|
||||||
|
Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return NewAppError(http.StatusConflict, "activity_time_overlap", "enabled activities for the same sysOrigin cannot overlap")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lockActivityScopeTx(tx *gorm.DB, sysOrigin string, now time.Time) error {
|
||||||
|
row := model.YumiGameKingScopeLock{SysOrigin: sysOrigin, UpdateTime: now}
|
||||||
|
if err := tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "sys_origin"}},
|
||||||
|
DoNothing: true,
|
||||||
|
}).Create(&row).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 插入竞争失败后,显式 FOR UPDATE 等待持锁事务提交;锁一直持有到本次 overlap
|
||||||
|
// 检查和 activity 保存一起提交,两个不同 activity 行也无法并发穿透。
|
||||||
|
return withWriteLock(tx).Where("sys_origin = ?", sysOrigin).First(&row).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateStartedActivityUpdate(row model.YumiGameKingActivity, req SaveActivityRequest) error {
|
||||||
|
if row.ActivityCode != req.ActivityCode || row.SysOrigin != req.SysOrigin || row.Timezone != req.Timezone ||
|
||||||
|
row.StartTime.UnixMilli() != req.StartTime || row.EndTime.UnixMilli() != req.EndTime ||
|
||||||
|
row.SettlementDelayMinutes != *req.SettlementDelayMinutes || row.CoinPerDraw != req.CoinPerDraw ||
|
||||||
|
row.RankingType != req.RankingType || row.RankingPeriod != req.RankingPeriod || row.Enabled != req.Enabled {
|
||||||
|
return NewAppError(http.StatusConflict, "activity_config_locked", "activity scope, time, chance rule and enable state cannot change after activity start")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
392
internal/service/gameking/delivery.go
Normal file
392
internal/service/gameking/delivery.go
Normal file
@ -0,0 +1,392 @@
|
|||||||
|
package gameking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/integration"
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const deliveryProcessingLease = 10 * time.Minute
|
||||||
|
|
||||||
|
type frozenDeliveryContext struct {
|
||||||
|
SysOrigin string
|
||||||
|
BusinessNo string
|
||||||
|
RewardItemsJSON string
|
||||||
|
RewardItems []RewardItemView
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatchDeliveryItem 原子领取一个 PENDING/人工确认后的 FAILED 奖励组并调用 Java。
|
||||||
|
// 一旦 HTTP 调用被尝试,任何错误都进入 UNKNOWN;只有调用前校验失败才是可重试 FAILED。
|
||||||
|
func (s *Service) dispatchDeliveryItem(ctx context.Context, itemID int64, retry bool) error {
|
||||||
|
// 事务已提交后发奖不能继承 H5/Admin/cron 连接取消,否则会在真正调用 Java 前留下
|
||||||
|
// PROCESSING 并被误判 UNKNOWN。脱离上游取消但保留硬超时,避免无界后台工作。
|
||||||
|
dispatchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
ctx = dispatchCtx
|
||||||
|
var item model.YumiGameKingDeliveryItem
|
||||||
|
claimed := false
|
||||||
|
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := withWriteLock(tx).Where("id = ?", itemID).First(&item).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return NewAppError(http.StatusNotFound, "delivery_item_not_found", "delivery item was not found")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch item.DeliveryStatus {
|
||||||
|
case DeliverySuccess:
|
||||||
|
return nil
|
||||||
|
case DeliveryUnknown:
|
||||||
|
return NewAppError(http.StatusConflict, "delivery_unknown_requires_resolution", "UNKNOWN delivery must be reconciled before retry")
|
||||||
|
case DeliveryProcessing:
|
||||||
|
return NewAppError(http.StatusConflict, "delivery_processing", "delivery is already processing")
|
||||||
|
case DeliveryPending:
|
||||||
|
if retry {
|
||||||
|
return NewAppError(http.StatusConflict, "delivery_not_retryable", "only FAILED delivery can be retried")
|
||||||
|
}
|
||||||
|
case DeliveryFailed:
|
||||||
|
if !retry {
|
||||||
|
return NewAppError(http.StatusConflict, "delivery_not_pending", "FAILED delivery requires an explicit retry")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return NewAppError(http.StatusConflict, "delivery_status_invalid", "delivery status is invalid")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
updates := map[string]any{"delivery_status": DeliveryProcessing, "failure_reason": "", "update_time": now}
|
||||||
|
if retry {
|
||||||
|
updates["retry_count"] = gorm.Expr("retry_count + 1")
|
||||||
|
item.RetryCount++
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.YumiGameKingDeliveryItem{}).Where("id = ?", item.ID).Updates(updates).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := updateDeliveryParentTx(tx, item, DeliveryProcessing, "", nil, retry); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
item.DeliveryStatus = DeliveryProcessing
|
||||||
|
item.UpdateTime = now
|
||||||
|
claimed = true
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !claimed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
frozen, err := s.loadFrozenDeliveryContext(ctx, item)
|
||||||
|
if err != nil {
|
||||||
|
_ = s.finishDeliveryItem(ctx, item.ID, DeliveryFailed, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
current, err := s.loadRewardGroupSnapshot(ctx, frozen.SysOrigin, item.ResourceGroupID)
|
||||||
|
if err != nil {
|
||||||
|
// 奖励组查询发生在真正发奖之前,因此网络失败、下架、跨租户或不存在都能明确
|
||||||
|
// 判定“未调用发奖”,记录 FAILED,待配置恢复后由运营显式 retry。
|
||||||
|
_ = s.finishDeliveryItem(ctx, item.ID, DeliveryFailed, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if current.ItemsJSON != frozen.RewardItemsJSON {
|
||||||
|
err = NewAppError(http.StatusConflict, "reward_group_snapshot_changed", "reward group content changed after activity enable; restore it before retry")
|
||||||
|
_ = s.finishDeliveryItem(ctx, item.ID, DeliveryFailed, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.gateway == nil {
|
||||||
|
err = NewAppError(http.StatusServiceUnavailable, "reward_gateway_unavailable", "reward gateway is unavailable")
|
||||||
|
_ = s.finishDeliveryItem(ctx, item.ID, DeliveryFailed, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.gateway.SendActivityReward(ctx, integration.SendActivityRewardRequest{
|
||||||
|
TrackID: item.ID, Origin: rewardOriginGameKing, SysOrigin: frozen.SysOrigin,
|
||||||
|
SourceGroupID: item.ResourceGroupID, AcceptUserID: item.UserID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// HTTP 已经发出后无法证明 Java 是否落账;包括 4xx/409 在内全部 UNKNOWN,
|
||||||
|
// 禁止自动或普通 retry,必须用 trackId/businessNo 核账后人工 resolve。
|
||||||
|
_ = s.finishDeliveryItem(ctx, item.ID, DeliveryUnknown, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.finishDeliveryItem(ctx, item.ID, DeliverySuccess, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) finishDeliveryItem(ctx context.Context, itemID int64, status, reason string) error {
|
||||||
|
// Java 调用和前置检查共用 20 秒操作超时,但最终状态必须使用独立短租约落库;
|
||||||
|
// 否则操作刚好超时时,已取消的 ctx 会让 FAILED/UNKNOWN/SUCCESS 都留在 PROCESSING。
|
||||||
|
finalizeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return s.db.WithContext(finalizeCtx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var item model.YumiGameKingDeliveryItem
|
||||||
|
if err := withWriteLock(tx).Where("id = ?", itemID).First(&item).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if item.DeliveryStatus != DeliveryProcessing {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
var deliverTime *time.Time
|
||||||
|
if status == DeliverySuccess {
|
||||||
|
deliverTime = &now
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.YumiGameKingDeliveryItem{}).Where("id = ?", item.ID).Updates(map[string]any{
|
||||||
|
"delivery_status": status, "failure_reason": truncateFailure(reason), "deliver_time": deliverTime, "update_time": now,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := updateDeliveryParentTx(tx, item, status, reason, deliverTime, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if item.OwnerType == OwnerSettlement {
|
||||||
|
return refreshSettlementStatusTx(tx, item.ActivityID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateDeliveryParentTx(tx *gorm.DB, item model.YumiGameKingDeliveryItem, status, reason string, deliverTime *time.Time, incrementRetry bool) error {
|
||||||
|
updates := map[string]any{
|
||||||
|
"delivery_status": status, "failure_reason": truncateFailure(reason), "deliver_time": deliverTime, "update_time": time.Now(),
|
||||||
|
}
|
||||||
|
if incrementRetry {
|
||||||
|
updates["retry_count"] = gorm.Expr("retry_count + 1")
|
||||||
|
}
|
||||||
|
switch item.OwnerType {
|
||||||
|
case OwnerDraw:
|
||||||
|
return tx.Model(&model.YumiGameKingDrawRecord{}).Where("id = ?", item.OwnerID).Updates(updates).Error
|
||||||
|
case OwnerSettlement:
|
||||||
|
return tx.Model(&model.YumiGameKingSettlementRecord{}).Where("id = ?", item.OwnerID).Updates(updates).Error
|
||||||
|
default:
|
||||||
|
return NewAppError(http.StatusConflict, "delivery_owner_invalid", "delivery owner type is invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshSettlementStatusTx(tx *gorm.DB, activityID int64) error {
|
||||||
|
var total, succeeded, unfinished, failed int64
|
||||||
|
base := tx.Model(&model.YumiGameKingSettlementRecord{}).Where("activity_id = ?", activityID)
|
||||||
|
if err := base.Count(&total).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.YumiGameKingSettlementRecord{}).Where("activity_id = ? AND delivery_status = ?", activityID, DeliverySuccess).Count(&succeeded).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.YumiGameKingSettlementRecord{}).Where("activity_id = ? AND delivery_status IN ?", activityID, []string{DeliveryPending, DeliveryProcessing}).Count(&unfinished).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.YumiGameKingSettlementRecord{}).Where("activity_id = ? AND delivery_status IN ?", activityID, []string{DeliveryFailed, DeliveryUnknown}).Count(&failed).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
status := SettlementProcessing
|
||||||
|
if succeeded == total {
|
||||||
|
status = SettlementCompleted
|
||||||
|
} else if failed > 0 {
|
||||||
|
status = SettlementPartialFailed
|
||||||
|
} else if unfinished == 0 {
|
||||||
|
status = SettlementPartialFailed
|
||||||
|
}
|
||||||
|
return tx.Model(&model.YumiGameKingActivity{}).Where("id = ?", activityID).
|
||||||
|
Updates(map[string]any{"settlement_status": status, "update_time": time.Now()}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadFrozenDeliveryContext(ctx context.Context, item model.YumiGameKingDeliveryItem) (frozenDeliveryContext, error) {
|
||||||
|
var activity model.YumiGameKingActivity
|
||||||
|
if err := s.db.WithContext(ctx).Where("id = ?", item.ActivityID).First(&activity).Error; err != nil {
|
||||||
|
return frozenDeliveryContext{}, err
|
||||||
|
}
|
||||||
|
result := frozenDeliveryContext{SysOrigin: activity.SysOrigin}
|
||||||
|
switch item.OwnerType {
|
||||||
|
case OwnerDraw:
|
||||||
|
var record model.YumiGameKingDrawRecord
|
||||||
|
if err := s.db.WithContext(ctx).Where("id = ?", item.OwnerID).First(&record).Error; err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
var prize model.YumiGameKingPrize
|
||||||
|
if err := s.db.WithContext(ctx).Where("id = ? AND activity_id = ?", record.PrizeID, item.ActivityID).First(&prize).Error; err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if prize.ResourceGroupID != item.ResourceGroupID {
|
||||||
|
return result, NewAppError(http.StatusConflict, "delivery_snapshot_mismatch", "draw reward group does not match frozen prize")
|
||||||
|
}
|
||||||
|
result.BusinessNo = fmt.Sprintf("YUMI_GAME_KING_DRAW:%d", record.ID)
|
||||||
|
result.RewardItemsJSON = prize.RewardItemsJSON
|
||||||
|
result.RewardItems = rewardItems(prize.RewardItemsJSON)
|
||||||
|
case OwnerSettlement:
|
||||||
|
var record model.YumiGameKingSettlementRecord
|
||||||
|
if err := s.db.WithContext(ctx).Where("id = ?", item.OwnerID).First(&record).Error; err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
var rankReward model.YumiGameKingRankReward
|
||||||
|
if err := s.db.WithContext(ctx).Where("id = ? AND activity_id = ?", record.RankRewardID, item.ActivityID).First(&rankReward).Error; err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if rankReward.ResourceGroupID != item.ResourceGroupID {
|
||||||
|
return result, NewAppError(http.StatusConflict, "delivery_snapshot_mismatch", "settlement reward group does not match frozen rank tier")
|
||||||
|
}
|
||||||
|
result.BusinessNo = record.BusinessNo
|
||||||
|
result.RewardItemsJSON = rankReward.RewardItemsJSON
|
||||||
|
result.RewardItems = rewardItems(rankReward.RewardItemsJSON)
|
||||||
|
default:
|
||||||
|
return result, NewAppError(http.StatusConflict, "delivery_owner_invalid", "delivery owner type is invalid")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(result.RewardItemsJSON) == "" || len(result.RewardItems) == 0 {
|
||||||
|
return result, NewAppError(http.StatusConflict, "delivery_snapshot_empty", "frozen reward snapshot is empty")
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) deliveryItems(ctx context.Context, ownerType string, ownerID int64) ([]DeliveryItemView, error) {
|
||||||
|
var rows []model.YumiGameKingDeliveryItem
|
||||||
|
if err := s.db.WithContext(ctx).Where("owner_type = ? AND owner_id = ?", ownerType, ownerID).Order("id ASC").Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]DeliveryItemView, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
frozen, err := s.loadFrozenDeliveryContext(ctx, row)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, DeliveryItemView{
|
||||||
|
ID: row.ID, TrackID: row.ID, OwnerType: row.OwnerType, OwnerID: row.OwnerID,
|
||||||
|
BusinessNo: frozen.BusinessNo, UserID: row.UserID, ResourceGroupID: row.ResourceGroupID,
|
||||||
|
DeliveryStatus: row.DeliveryStatus, RetryCount: row.RetryCount, FailureReason: row.FailureReason,
|
||||||
|
DeliverTime: unixMilliPtr(row.DeliverTime), UpdateTime: unixMilli(row.UpdateTime), RewardItems: frozen.RewardItems,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) RetryDraw(ctx context.Context, recordID int64) error {
|
||||||
|
var record model.YumiGameKingDrawRecord
|
||||||
|
if err := s.db.WithContext(ctx).Where("id = ?", recordID).First(&record).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if record.DeliveryStatus != DeliveryFailed {
|
||||||
|
return NewAppError(http.StatusConflict, "draw_not_retryable", "only FAILED draw rewards can be retried")
|
||||||
|
}
|
||||||
|
var item model.YumiGameKingDeliveryItem
|
||||||
|
if err := s.db.WithContext(ctx).Where("owner_type = ? AND owner_id = ?", OwnerDraw, record.ID).First(&item).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.dispatchDeliveryItem(ctx, item.ID, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) RetrySettlement(ctx context.Context, recordID int64) error {
|
||||||
|
var record model.YumiGameKingSettlementRecord
|
||||||
|
if err := s.db.WithContext(ctx).Where("id = ?", recordID).First(&record).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if record.DeliveryStatus != DeliveryFailed {
|
||||||
|
return NewAppError(http.StatusConflict, "settlement_not_retryable", "only FAILED settlement rewards can be retried")
|
||||||
|
}
|
||||||
|
var item model.YumiGameKingDeliveryItem
|
||||||
|
if err := s.db.WithContext(ctx).Where("owner_type = ? AND owner_id = ?", OwnerSettlement, record.ID).First(&item).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.dispatchDeliveryItem(ctx, item.ID, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveUnknownDeliveryItem 只能由运营核账后调用。delivered=false 是明确未到账授权,
|
||||||
|
// 服务先把 UNKNOWN 原子转为 FAILED,再复用普通 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
|
||||||
|
}
|
||||||
241
internal/service/gameking/draw.go
Normal file
241
internal/service/gameking/draw.go
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
package gameking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
cryptorand "crypto/rand"
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
"chatapp3-golang/internal/utils"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Draw 消耗一次由游戏金币支出获得的机会,并在同一事务冻结奖项和奖励组。
|
||||||
|
func (s *Service) Draw(ctx context.Context, user AuthUser, req DrawRequest) (*DrawResult, error) {
|
||||||
|
if user.UserID <= 0 {
|
||||||
|
return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "authenticated user is required")
|
||||||
|
}
|
||||||
|
if req.ActivityID.Int64() <= 0 {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_activity_id", "activityId is required")
|
||||||
|
}
|
||||||
|
req.RequestID = strings.TrimSpace(req.RequestID)
|
||||||
|
if req.RequestID == "" || len(req.RequestID) > 64 {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_request_id", "requestId is required and must not exceed 64 characters")
|
||||||
|
}
|
||||||
|
// 网络超时后的同 requestId 重放必须先于 ACTIVE 校验;已提交的结果即使活动已经结束、
|
||||||
|
// 关闭或进入结算,也要原样返回,且绝不再次发奖。
|
||||||
|
if replay, err := s.findDrawReplay(ctx, user, req); err != nil || replay != nil {
|
||||||
|
return replay, err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
activity, err := s.resolveAppActivity(ctx, normalizeSysOrigin(user.SysOrigin), req.ActivityID.Int64(), now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if activityStatus(activity, now) != activityActive {
|
||||||
|
return nil, NewAppError(http.StatusConflict, "activity_not_active", "game king activity is not active")
|
||||||
|
}
|
||||||
|
|
||||||
|
var record model.YumiGameKingDrawRecord
|
||||||
|
var prize model.YumiGameKingPrize
|
||||||
|
var remaining int64
|
||||||
|
replayed := false
|
||||||
|
var deliveryItemID int64
|
||||||
|
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var lockedActivity model.YumiGameKingActivity
|
||||||
|
// 与事件入账相同,抽奖使用共享活动锁;结算排他锁会等待已进入抽奖完成。
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where("id = ?", activity.ID).First(&lockedActivity).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
txNow := time.Now()
|
||||||
|
if !lockedActivity.Enabled || lockedActivity.SettlementStatus != SettlementNotStarted ||
|
||||||
|
txNow.Before(lockedActivity.StartTime) || !txNow.Before(lockedActivity.EndTime) {
|
||||||
|
return NewAppError(http.StatusConflict, "activity_not_active", "game king activity is not active")
|
||||||
|
}
|
||||||
|
|
||||||
|
var userRow model.YumiGameKingUser
|
||||||
|
if err := withWriteLock(tx).Where("activity_id = ? AND user_id = ?", activity.ID, user.UserID).First(&userRow).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return NewAppError(http.StatusConflict, "draw_chance_unavailable", "no draw chance is available")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 同一用户的请求都由聚合行串行化,因此并发复用 requestId 不会重复扣机会。
|
||||||
|
if err := tx.Where("activity_id = ? AND user_id = ? AND request_id = ?", activity.ID, user.UserID, req.RequestID).First(&record).Error; err == nil {
|
||||||
|
if err := tx.Where("id = ?", record.PrizeID).First(&prize).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
replayed = true
|
||||||
|
remaining = userRow.TotalConsumed/lockedActivity.CoinPerDraw - userRow.UsedChances
|
||||||
|
if remaining < 0 {
|
||||||
|
remaining = 0
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
earned := userRow.TotalConsumed / lockedActivity.CoinPerDraw
|
||||||
|
if userRow.UsedChances >= earned {
|
||||||
|
return NewAppError(http.StatusConflict, "draw_chance_unavailable", "no draw chance is available")
|
||||||
|
}
|
||||||
|
selected, err := selectAndReservePrizeTx(tx, activity.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
prize = selected
|
||||||
|
|
||||||
|
recordID, err := utils.NextID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
deliveryItemID, err = utils.NextID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
record = model.YumiGameKingDrawRecord{
|
||||||
|
ID: recordID, ActivityID: activity.ID, UserID: user.UserID, RequestID: req.RequestID,
|
||||||
|
PrizeID: prize.ID, PrizeName: prize.PrizeName, PrizeImage: prize.PrizeImage,
|
||||||
|
ResourceGroupID: prize.ResourceGroupID, DeliveryStatus: DeliveryPending,
|
||||||
|
DrawTime: txNow, CreateTime: txNow, UpdateTime: txNow,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&record).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Create(&model.YumiGameKingDeliveryItem{
|
||||||
|
ID: deliveryItemID, OwnerType: OwnerDraw, OwnerID: record.ID, ActivityID: activity.ID,
|
||||||
|
UserID: user.UserID, ResourceGroupID: prize.ResourceGroupID, DeliveryStatus: DeliveryPending,
|
||||||
|
CreateTime: txNow, UpdateTime: txNow,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
userRow.UsedChances++
|
||||||
|
userRow.UpdateTime = txNow
|
||||||
|
if err := tx.Save(&userRow).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
remaining = earned - userRow.UsedChances
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !replayed {
|
||||||
|
// 发奖在提交后执行,外部超时不会回滚已经向 H5 展示的抽奖结果,也不会再次扣机会。
|
||||||
|
_ = s.dispatchDeliveryItem(ctx, deliveryItemID, false)
|
||||||
|
_ = s.db.WithContext(ctx).Where("id = ?", record.ID).First(&record).Error
|
||||||
|
}
|
||||||
|
return &DrawResult{
|
||||||
|
Record: drawRecordView(record), Prize: prizeView(prize), RemainingChances: remaining, IdempotentReplay: replayed,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) findDrawReplay(ctx context.Context, user AuthUser, req DrawRequest) (*DrawResult, error) {
|
||||||
|
var record model.YumiGameKingDrawRecord
|
||||||
|
query := s.db.WithContext(ctx).Table("yumi_game_king_draw_record AS draw").
|
||||||
|
Select("draw.*").
|
||||||
|
Joins("JOIN yumi_game_king_activity AS activity ON activity.id = draw.activity_id").
|
||||||
|
Where("draw.user_id = ? AND draw.request_id = ? AND activity.sys_origin = ?", user.UserID, req.RequestID, normalizeSysOrigin(user.SysOrigin))
|
||||||
|
if req.ActivityID.Int64() > 0 {
|
||||||
|
query = query.Where("draw.activity_id = ?", req.ActivityID.Int64())
|
||||||
|
}
|
||||||
|
result := query.Order("draw.id DESC").Limit(1).Scan(&record)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 || record.ID <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var prize model.YumiGameKingPrize
|
||||||
|
if err := s.db.WithContext(ctx).Where("id = ? AND activity_id = ?", record.PrizeID, record.ActivityID).First(&prize).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var activity model.YumiGameKingActivity
|
||||||
|
if err := s.db.WithContext(ctx).Where("id = ?", record.ActivityID).First(&activity).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var aggregate model.YumiGameKingUser
|
||||||
|
if err := s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", record.ActivityID, user.UserID).First(&aggregate).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
remaining := aggregate.TotalConsumed/activity.CoinPerDraw - aggregate.UsedChances
|
||||||
|
if remaining < 0 {
|
||||||
|
remaining = 0
|
||||||
|
}
|
||||||
|
return &DrawResult{Record: drawRecordView(record), Prize: prizeView(prize), RemainingChances: remaining, IdempotentReplay: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectAndReservePrizeTx(tx *gorm.DB, activityID int64) (model.YumiGameKingPrize, error) {
|
||||||
|
for attempt := 0; attempt < 20; attempt++ {
|
||||||
|
var rows []model.YumiGameKingPrize
|
||||||
|
if err := tx.Where("activity_id = ? AND enabled = ? AND weight > 0 AND stock <> 0", activityID, true).
|
||||||
|
Order("sort_order ASC").Find(&rows).Error; err != nil {
|
||||||
|
return model.YumiGameKingPrize{}, err
|
||||||
|
}
|
||||||
|
if len(rows) != 7 {
|
||||||
|
// 配置固定七个,但有限库存耗尽后允许从剩余奖项继续抽;只有配置本身缺失才报错。
|
||||||
|
var configured int64
|
||||||
|
if err := tx.Model(&model.YumiGameKingPrize{}).Where("activity_id = ? AND enabled = ? AND weight > 0", activityID, true).Count(&configured).Error; err != nil {
|
||||||
|
return model.YumiGameKingPrize{}, err
|
||||||
|
}
|
||||||
|
if configured != 7 {
|
||||||
|
return model.YumiGameKingPrize{}, NewAppError(http.StatusConflict, "prize_config_invalid", "seven enabled positive-weight prizes are required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return model.YumiGameKingPrize{}, NewAppError(http.StatusConflict, "prize_stock_empty", "all prize stock is exhausted")
|
||||||
|
}
|
||||||
|
selected, err := weightedPrize(rows)
|
||||||
|
if err != nil {
|
||||||
|
return model.YumiGameKingPrize{}, err
|
||||||
|
}
|
||||||
|
if selected.Stock < 0 {
|
||||||
|
return selected, nil
|
||||||
|
}
|
||||||
|
result := tx.Model(&model.YumiGameKingPrize{}).
|
||||||
|
Where("id = ? AND stock > 0", selected.ID).
|
||||||
|
Updates(map[string]any{"stock": gorm.Expr("stock - 1"), "update_time": time.Now()})
|
||||||
|
if result.Error != nil {
|
||||||
|
return model.YumiGameKingPrize{}, result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 1 {
|
||||||
|
selected.Stock--
|
||||||
|
return selected, nil
|
||||||
|
}
|
||||||
|
// 另一个抽奖刚好抢完有限库存,重新读取候选并按剩余权重抽取。
|
||||||
|
}
|
||||||
|
return model.YumiGameKingPrize{}, NewAppError(http.StatusConflict, "prize_stock_busy", "prize stock changed concurrently; please retry")
|
||||||
|
}
|
||||||
|
|
||||||
|
func weightedPrize(rows []model.YumiGameKingPrize) (model.YumiGameKingPrize, error) {
|
||||||
|
var total int64
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.Weight <= 0 || total > math.MaxInt64-row.Weight {
|
||||||
|
return model.YumiGameKingPrize{}, NewAppError(http.StatusConflict, "prize_weight_invalid", "prize weights are invalid")
|
||||||
|
}
|
||||||
|
total += row.Weight
|
||||||
|
}
|
||||||
|
if total <= 0 {
|
||||||
|
return model.YumiGameKingPrize{}, NewAppError(http.StatusConflict, "prize_weight_invalid", "prize weights are invalid")
|
||||||
|
}
|
||||||
|
randomValue, err := cryptorand.Int(cryptorand.Reader, big.NewInt(total))
|
||||||
|
if err != nil {
|
||||||
|
return model.YumiGameKingPrize{}, err
|
||||||
|
}
|
||||||
|
needle := randomValue.Int64()
|
||||||
|
var cursor int64
|
||||||
|
for _, row := range rows {
|
||||||
|
cursor += row.Weight
|
||||||
|
if needle < cursor {
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows[len(rows)-1], nil
|
||||||
|
}
|
||||||
271
internal/service/gameking/event.go
Normal file
271
internal/service/gameking/event.go
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
package gameking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
"chatapp3-golang/internal/service/taskcenter"
|
||||||
|
"chatapp3-golang/internal/utils"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConsumeGameEvent 实现 taskcenter.GameConsumeSink。GAME_CONSUME_GOLD 是唯一统计入口,
|
||||||
|
// 不再允许后台配置钱包 eventType 或把非游戏支出混入活动。
|
||||||
|
func (s *Service) ConsumeGameEvent(ctx context.Context, event taskcenter.ValidatedEvent) error {
|
||||||
|
if !strings.EqualFold(event.EventType, taskcenter.EventTypeGameConsumeGold) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if event.UserID <= 0 || event.DeltaValue <= 0 || strings.TrimSpace(event.EventID) == "" {
|
||||||
|
return NewAppError(http.StatusBadRequest, "invalid_game_consume_event", "game consume event requires eventId, userId and positive deltaValue")
|
||||||
|
}
|
||||||
|
event.SysOrigin = normalizeSysOrigin(event.SysOrigin)
|
||||||
|
event.EventID = strings.TrimSpace(event.EventID)
|
||||||
|
|
||||||
|
var activities []model.YumiGameKingActivity
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Where("sys_origin = ? AND enabled = ? AND settlement_status = ? AND start_time <= ? AND end_time > ?",
|
||||||
|
event.SysOrigin, true, SettlementNotStarted, event.OccurredAt, event.OccurredAt).
|
||||||
|
Order("start_time ASC").
|
||||||
|
Find(&activities).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, activity := range activities {
|
||||||
|
if _, err := s.applyEventToActivity(ctx, activity.ID, event); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyEventToActivity 在一个事务内完成账本幂等、总榜和活动时区日榜聚合。
|
||||||
|
func (s *Service) applyEventToActivity(ctx context.Context, activityID int64, event taskcenter.ValidatedEvent) (bool, error) {
|
||||||
|
accepted := false
|
||||||
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var activity model.YumiGameKingActivity
|
||||||
|
// 事件仅持有共享活动锁:不同用户可并发入账;结算使用排他锁并等待所有已进入的
|
||||||
|
// 事件事务完成,从而在切换 settlement_status 时形成明确冻结边界。
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where("id = ?", activityID).First(&activity).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 结算事务先锁活动并切换状态;此处复核状态和 [start,end),因此结算冻结后到达的
|
||||||
|
// 延迟 MQ/补数不会让 Top30 继续漂移。
|
||||||
|
if !activity.Enabled || activity.SettlementStatus != SettlementNotStarted ||
|
||||||
|
event.SysOrigin != activity.SysOrigin || event.OccurredAt.Before(activity.StartTime) || !event.OccurredAt.Before(activity.EndTime) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ledgerID, err := utils.NextID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payloadJSON, _ := json.Marshal(event.Payload)
|
||||||
|
ledger := model.YumiGameKingConsumeLedger{
|
||||||
|
ID: ledgerID, ActivityID: activity.ID, EventID: event.EventID, UserID: event.UserID,
|
||||||
|
SysOrigin: event.SysOrigin, Amount: event.DeltaValue, EventTime: event.OccurredAt,
|
||||||
|
PayloadJSON: string(payloadJSON), CreateTime: time.Now(),
|
||||||
|
}
|
||||||
|
result := tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "activity_id"}, {Name: "event_id"}},
|
||||||
|
DoNothing: true,
|
||||||
|
}).Create(&ledger)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if err := addOverallConsumeTx(tx, activity.ID, event.UserID, event.DeltaValue, event.OccurredAt, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, location, err := normalizeTimezone(activity.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := addDailyConsumeTx(tx, activity.ID, dateKey(event.OccurredAt, location), event.UserID, event.DeltaValue, event.OccurredAt, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
accepted = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return accepted, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func addOverallConsumeTx(tx *gorm.DB, activityID, userID, amount int64, eventTime, now time.Time) error {
|
||||||
|
row := model.YumiGameKingUser{
|
||||||
|
ActivityID: activityID, UserID: userID, TotalConsumed: amount, UsedChances: 0,
|
||||||
|
ConsumeReachedTime: eventTime, CreateTime: now, UpdateTime: now,
|
||||||
|
}
|
||||||
|
// 首笔事件不能“SELECT 未命中后 INSERT”:同一用户并发首笔会发生 PK 冲突或死锁,
|
||||||
|
// 同步上报若不重试就会漏计。单条 upsert 由聚合主键串行累加,并且不覆盖 used_chances。
|
||||||
|
return tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "activity_id"}, {Name: "user_id"}},
|
||||||
|
DoUpdates: clause.Assignments(map[string]any{
|
||||||
|
"total_consumed": gorm.Expr("total_consumed + ?", amount),
|
||||||
|
"consume_reached_time": gorm.Expr("GREATEST(consume_reached_time, ?)", eventTime),
|
||||||
|
"update_time": now,
|
||||||
|
}),
|
||||||
|
}).Create(&row).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func addDailyConsumeTx(tx *gorm.DB, activityID int64, statDate time.Time, userID, amount int64, eventTime, now time.Time) error {
|
||||||
|
row := model.YumiGameKingUserDaily{
|
||||||
|
ActivityID: activityID, StatDate: statDate, UserID: userID, TotalConsumed: amount,
|
||||||
|
ConsumeReachedTime: eventTime, CreateTime: now, UpdateTime: now,
|
||||||
|
}
|
||||||
|
return tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "activity_id"}, {Name: "stat_date"}, {Name: "user_id"}},
|
||||||
|
DoUpdates: clause.Assignments(map[string]any{
|
||||||
|
"total_consumed": gorm.Expr("total_consumed + ?", amount),
|
||||||
|
"consume_reached_time": gorm.Expr("GREATEST(consume_reached_time, ?)", eventTime),
|
||||||
|
"update_time": now,
|
||||||
|
}),
|
||||||
|
}).Create(&row).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
type archivedTaskEvent struct {
|
||||||
|
DeltaValue int64 `json:"delta_value"`
|
||||||
|
Payload map[string]any `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskCenterGameKingBackfillIndex = "idx_task_center_game_king_backfill"
|
||||||
|
|
||||||
|
type backfillIndexColumn struct {
|
||||||
|
ColumnName string `gorm:"column:column_name"`
|
||||||
|
SeqInIndex int `gorm:"column:seq_in_index"`
|
||||||
|
SubPart *int64 `gorm:"column:sub_part"`
|
||||||
|
IndexType string `gorm:"column:index_type"`
|
||||||
|
IsVisible string `gorm:"column:is_visible"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backfill 从任务中心的权威事件归档表按主键游标补数。调用方只控制 lastId/limit,
|
||||||
|
// 不能提交 eventType 或钱包口径;查询始终绑定复合索引并限制到活动 [start,end)。
|
||||||
|
func (s *Service) Backfill(ctx context.Context, activityID, lastID int64, limit int) (*BackfillResult, error) {
|
||||||
|
// 生产归档表约 35M 行;索引若未独立完成,后续游标定位或范围扫描可能退化成全表扫描。
|
||||||
|
// 因此元数据门禁必须位于任何活动/游标查询之前,并且每次调用重新检查,在线 DDL 完成后
|
||||||
|
// 无需重启即可恢复补数。information_schema 查询只读取固定索引的少量元数据行。
|
||||||
|
if err := s.requireBackfillIndex(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
activity, err := s.loadActivity(ctx, activityID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 游标只有在仍可入账时才允许推进;否则 settled/disabled 活动会扫描到底却全部
|
||||||
|
// accepted=false,给运营造成“补数成功”的假象。结束后等待结算期间仍允许补数。
|
||||||
|
if !activity.Enabled {
|
||||||
|
return nil, NewAppError(http.StatusConflict, "activity_disabled", "disabled activity cannot be backfilled")
|
||||||
|
}
|
||||||
|
if activity.SettlementStatus != SettlementNotStarted {
|
||||||
|
return nil, NewAppError(http.StatusConflict, "settlement_frozen", "settlement has frozen activity consumption")
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = defaultBackfillLimit
|
||||||
|
}
|
||||||
|
if limit > maxBackfillLimit {
|
||||||
|
limit = maxBackfillLimit
|
||||||
|
}
|
||||||
|
if lastID < 0 {
|
||||||
|
lastID = 0
|
||||||
|
}
|
||||||
|
cursorTime := activity.StartTime
|
||||||
|
if lastID > 0 {
|
||||||
|
var cursor model.TaskCenterEventArchiveOutbox
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Where("id = ? AND sys_origin = ? AND event_type = ? AND occurred_at >= ? AND occurred_at < ?",
|
||||||
|
lastID, activity.SysOrigin, taskcenter.EventTypeGameConsumeGold, activity.StartTime, activity.EndTime).
|
||||||
|
First(&cursor).Error; err != nil {
|
||||||
|
if isRecordNotFound(err) {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_backfill_cursor", "lastId does not belong to this activity event window")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cursorTime = cursor.OccurredAt
|
||||||
|
}
|
||||||
|
var rows []model.TaskCenterEventArchiveOutbox
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Where("sys_origin = ? AND event_type = ? AND occurred_at >= ? AND occurred_at < ? AND (occurred_at > ? OR (occurred_at = ? AND id > ?))",
|
||||||
|
activity.SysOrigin, taskcenter.EventTypeGameConsumeGold, activity.StartTime, activity.EndTime, cursorTime, cursorTime, lastID).
|
||||||
|
Order("occurred_at ASC, id ASC").Limit(limit + 1).Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
finished := len(rows) <= limit
|
||||||
|
if len(rows) > limit {
|
||||||
|
rows = rows[:limit]
|
||||||
|
}
|
||||||
|
result := &BackfillResult{EventType: taskcenter.EventTypeGameConsumeGold, Finished: finished, NextLastID: lastID}
|
||||||
|
for _, row := range rows {
|
||||||
|
var archived archivedTaskEvent
|
||||||
|
if err := json.Unmarshal([]byte(row.RawJSON), &archived); err != nil || archived.DeltaValue <= 0 {
|
||||||
|
return nil, NewAppError(http.StatusUnprocessableEntity, "invalid_archived_event", fmt.Sprintf("task center archive row %d has no valid delta_value", row.ID))
|
||||||
|
}
|
||||||
|
accepted, err := s.applyEventToActivity(ctx, activity.ID, taskcenter.ValidatedEvent{
|
||||||
|
SysOrigin: activity.SysOrigin, EventID: row.EventID, EventType: taskcenter.EventTypeGameConsumeGold,
|
||||||
|
UserID: row.UserID, DeltaValue: archived.DeltaValue, OccurredAt: row.OccurredAt, Payload: archived.Payload,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !accepted {
|
||||||
|
var existing int64
|
||||||
|
if err := s.db.WithContext(ctx).Model(&model.YumiGameKingConsumeLedger{}).
|
||||||
|
Where("activity_id = ? AND event_id = ?", activity.ID, row.EventID).Count(&existing).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existing == 0 {
|
||||||
|
// 入口校验后结算可能在本页中途取得排他锁。未命中 ledger 说明这条不是幂等
|
||||||
|
// 重复而是已被冻结拒绝;整页返回冲突且不暴露推进后的 cursor,调用方沿旧游标重试。
|
||||||
|
return nil, NewAppError(http.StatusConflict, "backfill_window_closed", "activity stopped accepting consumption during backfill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.Scanned++
|
||||||
|
if accepted {
|
||||||
|
result.Accepted++
|
||||||
|
}
|
||||||
|
result.NextLastID = row.ID
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireBackfillIndex 只接受迁移 058 创建的完整 BTREE 列序;VARCHAR 前缀索引也会拒绝,
|
||||||
|
// 因为它不能提供与完整等值前缀相同的执行计划保证。缺表、缺索引或列序漂移统一 fail-closed。
|
||||||
|
func (s *Service) requireBackfillIndex(ctx context.Context) error {
|
||||||
|
var columns []backfillIndexColumn
|
||||||
|
if err := s.db.WithContext(ctx).Raw(`
|
||||||
|
SELECT COLUMN_NAME, SEQ_IN_INDEX, SUB_PART, INDEX_TYPE, IS_VISIBLE
|
||||||
|
FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'task_center_event_archive_outbox'
|
||||||
|
AND INDEX_NAME = ?
|
||||||
|
ORDER BY SEQ_IN_INDEX ASC`, taskCenterGameKingBackfillIndex).Scan(&columns).Error; err != nil {
|
||||||
|
return NewAppError(http.StatusServiceUnavailable, "backfill_index_check_failed", "cannot verify the required task center backfill index")
|
||||||
|
}
|
||||||
|
|
||||||
|
required := [...]string{"sys_origin", "event_type", "occurred_at", "id"}
|
||||||
|
if len(columns) == len(required) {
|
||||||
|
valid := true
|
||||||
|
for index, requiredName := range required {
|
||||||
|
column := columns[index]
|
||||||
|
if column.SeqInIndex != index+1 || !strings.EqualFold(column.ColumnName, requiredName) || column.SubPart != nil ||
|
||||||
|
!strings.EqualFold(column.IndexType, "BTREE") || !strings.EqualFold(column.IsVisible, "YES") {
|
||||||
|
valid = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if valid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NewAppError(
|
||||||
|
http.StatusServiceUnavailable,
|
||||||
|
"backfill_index_required",
|
||||||
|
"migration 058 backfill index (sys_origin,event_type,occurred_at,id) must be online before backfill",
|
||||||
|
)
|
||||||
|
}
|
||||||
351
internal/service/gameking/normalize.go
Normal file
351
internal/service/gameking/normalize.go
Normal file
@ -0,0 +1,351 @@
|
|||||||
|
package gameking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/integration"
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
var fixedRankRanges = [][2]int{{1, 1}, {2, 2}, {3, 3}, {4, 7}, {8, 10}, {11, 30}}
|
||||||
|
|
||||||
|
func normalizeSysOrigin(value string) string {
|
||||||
|
value = strings.ToUpper(strings.TrimSpace(value))
|
||||||
|
if value == "" {
|
||||||
|
return "LIKEI"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTimezone(value string) (string, *time.Location, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
value = defaultTimezone
|
||||||
|
}
|
||||||
|
location, err := time.LoadLocation(value)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, NewAppError(http.StatusBadRequest, "invalid_timezone", "timeZone must be a valid IANA timezone")
|
||||||
|
}
|
||||||
|
return value, location, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRankingType(value string) (string, error) {
|
||||||
|
value = strings.ToUpper(strings.TrimSpace(value))
|
||||||
|
if value == "" {
|
||||||
|
value = rankingTypeTycoon
|
||||||
|
}
|
||||||
|
if value != rankingTypeTycoon {
|
||||||
|
return "", NewAppError(http.StatusBadRequest, "invalid_ranking_type", "rankingType must be TYCOON")
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRankingPeriod(value string) (string, error) {
|
||||||
|
value = strings.ToUpper(strings.TrimSpace(value))
|
||||||
|
if value == "" {
|
||||||
|
value = rankingPeriodOverall
|
||||||
|
}
|
||||||
|
if value != rankingPeriodOverall {
|
||||||
|
return "", NewAppError(http.StatusBadRequest, "invalid_ranking_period", "configured rankingPeriod must be OVERALL")
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeActivityInput(req SaveActivityRequest) (SaveActivityRequest, error) {
|
||||||
|
req.ActivityCode = strings.TrimSpace(req.ActivityCode)
|
||||||
|
req.ActivityName = strings.TrimSpace(req.ActivityName)
|
||||||
|
req.ActivityDesc = strings.TrimSpace(req.ActivityDesc)
|
||||||
|
if req.ActivityCode == "" || len(req.ActivityCode) > 64 {
|
||||||
|
return req, NewAppError(http.StatusBadRequest, "invalid_activity_code", "activityCode is required and must not exceed 64 characters")
|
||||||
|
}
|
||||||
|
if req.ActivityName == "" || len(req.ActivityName) > 128 {
|
||||||
|
return req, NewAppError(http.StatusBadRequest, "invalid_activity_name", "activityName is required and must not exceed 128 characters")
|
||||||
|
}
|
||||||
|
if len(req.ActivityDesc) > 1000 {
|
||||||
|
return req, NewAppError(http.StatusBadRequest, "invalid_activity_desc", "activityDesc must not exceed 1000 characters")
|
||||||
|
}
|
||||||
|
req.SysOrigin = normalizeSysOrigin(req.SysOrigin)
|
||||||
|
if req.SysOrigin != "LIKEI" {
|
||||||
|
return req, NewAppError(http.StatusBadRequest, "invalid_sys_origin", "Yumi game king only supports LIKEI sysOrigin")
|
||||||
|
}
|
||||||
|
timezone, _, err := normalizeTimezone(req.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return req, err
|
||||||
|
}
|
||||||
|
req.Timezone = timezone
|
||||||
|
if req.StartTime <= 0 || req.EndTime <= req.StartTime {
|
||||||
|
return req, NewAppError(http.StatusBadRequest, "invalid_activity_time", "startTime and endTime are required and startTime must be before endTime")
|
||||||
|
}
|
||||||
|
if req.SettlementDelayMinutes == nil {
|
||||||
|
value := defaultSettlementDelayMinutes
|
||||||
|
req.SettlementDelayMinutes = &value
|
||||||
|
}
|
||||||
|
if *req.SettlementDelayMinutes < 0 || *req.SettlementDelayMinutes > maxSettlementDelayMinutes {
|
||||||
|
return req, NewAppError(http.StatusBadRequest, "invalid_settlement_delay", "settlementDelayMinutes must be between 0 and 1440")
|
||||||
|
}
|
||||||
|
if req.CoinPerDraw <= 0 {
|
||||||
|
return req, NewAppError(http.StatusBadRequest, "invalid_coin_per_draw", "coinPerDraw must be greater than zero")
|
||||||
|
}
|
||||||
|
req.RankingType, err = normalizeRankingType(req.RankingType)
|
||||||
|
if err != nil {
|
||||||
|
return req, err
|
||||||
|
}
|
||||||
|
req.RankingPeriod, err = normalizeRankingPeriod(req.RankingPeriod)
|
||||||
|
return req, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) normalizePrizes(ctx context.Context, sysOrigin string, values []PrizeInput) ([]normalizedPrize, error) {
|
||||||
|
if len(values) != 7 {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_count", "exactly seven prizes are required")
|
||||||
|
}
|
||||||
|
seenSort := make(map[int]struct{}, 7)
|
||||||
|
result := make([]normalizedPrize, 0, 7)
|
||||||
|
for _, value := range values {
|
||||||
|
value.PrizeName = strings.TrimSpace(value.PrizeName)
|
||||||
|
value.PrizeImage = strings.TrimSpace(value.PrizeImage)
|
||||||
|
if value.PrizeName == "" || len(value.PrizeName) > 128 {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_name", "every prizeName is required and must not exceed 128 characters")
|
||||||
|
}
|
||||||
|
if value.PrizeImage == "" || len(value.PrizeImage) > 500 {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_image", "every prizeImage is required and must not exceed 500 characters")
|
||||||
|
}
|
||||||
|
if !value.Enabled {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_enabled", "all seven prizes must be enabled")
|
||||||
|
}
|
||||||
|
if value.Weight <= 0 {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_weight", "every prize weight must be greater than zero")
|
||||||
|
}
|
||||||
|
if value.Stock < -1 {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_stock", "prize stock must be -1 or greater")
|
||||||
|
}
|
||||||
|
if value.SortOrder < 1 || value.SortOrder > 7 {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_sort", "prize sortOrder must cover 1 through 7")
|
||||||
|
}
|
||||||
|
if _, exists := seenSort[value.SortOrder]; exists {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "duplicate_prize_sort", "prize sortOrder must be unique")
|
||||||
|
}
|
||||||
|
seenSort[value.SortOrder] = struct{}{}
|
||||||
|
if value.ResourceGroupID.Int64() <= 0 {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_reward_group", "every prize must reference a reward group")
|
||||||
|
}
|
||||||
|
snapshot, err := s.loadRewardGroupSnapshot(ctx, sysOrigin, value.ResourceGroupID.Int64())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, normalizedPrize{Input: value, Snapshot: snapshot})
|
||||||
|
}
|
||||||
|
sort.Slice(result, func(i, j int) bool { return result[i].Input.SortOrder < result[j].Input.SortOrder })
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) normalizeRankRewards(ctx context.Context, sysOrigin string, values []RankRewardInput) ([]normalizedRankReward, error) {
|
||||||
|
if len(values) != len(fixedRankRanges) {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_count", "exactly six rank reward tiers are required")
|
||||||
|
}
|
||||||
|
byRange := make(map[string]RankRewardInput, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
key := fmt.Sprintf("%d-%d", value.StartRank, value.EndRank)
|
||||||
|
if _, exists := byRange[key]; exists {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "duplicate_rank_reward", "rank reward ranges must be unique")
|
||||||
|
}
|
||||||
|
byRange[key] = value
|
||||||
|
}
|
||||||
|
result := make([]normalizedRankReward, 0, len(fixedRankRanges))
|
||||||
|
for _, rankRange := range fixedRankRanges {
|
||||||
|
key := fmt.Sprintf("%d-%d", rankRange[0], rankRange[1])
|
||||||
|
value, exists := byRange[key]
|
||||||
|
if !exists {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_ranges", "rank rewards must be 1, 2, 3, 4-7, 8-10 and 11-30")
|
||||||
|
}
|
||||||
|
if value.ResourceGroupID.Int64() <= 0 {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_group", "every rank tier must reference a reward group")
|
||||||
|
}
|
||||||
|
value.RewardName = strings.TrimSpace(value.RewardName)
|
||||||
|
snapshot, err := s.loadRewardGroupSnapshot(ctx, sysOrigin, value.ResourceGroupID.Int64())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if value.RewardName == "" {
|
||||||
|
value.RewardName = snapshot.GroupName
|
||||||
|
}
|
||||||
|
if len(value.RewardName) > 128 {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_name", "rewardName must not exceed 128 characters")
|
||||||
|
}
|
||||||
|
result = append(result, normalizedRankReward{Input: value, Snapshot: snapshot})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadRewardGroupSnapshot(ctx context.Context, sysOrigin string, groupID int64) (rewardGroupSnapshot, error) {
|
||||||
|
if s.gateway == nil {
|
||||||
|
return rewardGroupSnapshot{}, NewAppError(http.StatusServiceUnavailable, "reward_gateway_unavailable", "reward gateway is unavailable")
|
||||||
|
}
|
||||||
|
detail, err := s.gateway.GetRewardGroupDetail(ctx, groupID)
|
||||||
|
if err != nil {
|
||||||
|
return rewardGroupSnapshot{}, NewAppError(http.StatusBadGateway, "reward_group_lookup_failed", err.Error())
|
||||||
|
}
|
||||||
|
if int64(detail.ID) != groupID || len(detail.RewardConfigList) == 0 {
|
||||||
|
return rewardGroupSnapshot{}, NewAppError(http.StatusBadRequest, "reward_group_empty", "reward group must exist and contain at least one reward")
|
||||||
|
}
|
||||||
|
// 租户信息缺失也必须 fail-closed;否则旧 Java DTO 或异常响应会让跨租户奖励组绕过校验。
|
||||||
|
if strings.TrimSpace(detail.SysOrigin) == "" || !strings.EqualFold(strings.TrimSpace(detail.SysOrigin), sysOrigin) {
|
||||||
|
return rewardGroupSnapshot{}, NewAppError(http.StatusBadRequest, "reward_group_sys_origin_mismatch", "reward group sysOrigin does not match activity")
|
||||||
|
}
|
||||||
|
// 启用和每次发奖前都必须拿到明确的上架状态;缺失状态不能被当成“已上架”。
|
||||||
|
if detail.ShelfStatus == nil || !*detail.ShelfStatus {
|
||||||
|
return rewardGroupSnapshot{}, NewAppError(http.StatusBadRequest, "reward_group_not_shelved", "reward group must be shelved")
|
||||||
|
}
|
||||||
|
items := make([]RewardItemView, 0, len(detail.RewardConfigList))
|
||||||
|
for _, item := range detail.RewardConfigList {
|
||||||
|
if int64(item.ID) <= 0 || strings.TrimSpace(item.Type) == "" || int64(item.Quantity) <= 0 || int64(item.Quantity) > math.MaxInt32 {
|
||||||
|
return rewardGroupSnapshot{}, NewAppError(http.StatusBadRequest, "reward_group_item_invalid", "reward group contains an invalid reward item")
|
||||||
|
}
|
||||||
|
items = append(items, RewardItemView{
|
||||||
|
ID: int64(item.ID),
|
||||||
|
Type: strings.TrimSpace(item.Type),
|
||||||
|
DetailType: strings.TrimSpace(item.DetailType),
|
||||||
|
Content: strings.TrimSpace(item.Content),
|
||||||
|
Quantity: int64(item.Quantity),
|
||||||
|
Cover: strings.TrimSpace(item.Cover),
|
||||||
|
SourceURL: strings.TrimSpace(item.SourceURL),
|
||||||
|
Name: strings.TrimSpace(item.Name),
|
||||||
|
Remark: strings.TrimSpace(item.Remark),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 奖励组接口不承诺返回顺序;按配置 ID 及实际发奖字段规范化后再冻结/比对,
|
||||||
|
// 避免单纯数组重排被误判,同时确保 type/detailType/content/quantity 全部参与校验。
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
if items[i].ID != items[j].ID {
|
||||||
|
return items[i].ID < items[j].ID
|
||||||
|
}
|
||||||
|
if items[i].Type != items[j].Type {
|
||||||
|
return items[i].Type < items[j].Type
|
||||||
|
}
|
||||||
|
if items[i].DetailType != items[j].DetailType {
|
||||||
|
return items[i].DetailType < items[j].DetailType
|
||||||
|
}
|
||||||
|
if items[i].Content != items[j].Content {
|
||||||
|
return items[i].Content < items[j].Content
|
||||||
|
}
|
||||||
|
return items[i].Quantity < items[j].Quantity
|
||||||
|
})
|
||||||
|
for index := range items {
|
||||||
|
items[index].Sort = index + 1
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(items)
|
||||||
|
if err != nil {
|
||||||
|
return rewardGroupSnapshot{}, err
|
||||||
|
}
|
||||||
|
return rewardGroupSnapshot{GroupID: groupID, GroupName: strings.TrimSpace(detail.Name), ItemsJSON: string(raw), Items: items}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rewardItems(raw string) []RewardItemView {
|
||||||
|
items := make([]RewardItemView, 0)
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||||
|
return []RewardItemView{}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func activityView(row model.YumiGameKingActivity) ActivityView {
|
||||||
|
return ActivityView{
|
||||||
|
ID: row.ID,
|
||||||
|
ActivityCode: row.ActivityCode,
|
||||||
|
ActivityName: row.ActivityName,
|
||||||
|
ActivityDesc: row.ActivityDesc,
|
||||||
|
SysOrigin: row.SysOrigin,
|
||||||
|
Timezone: row.Timezone,
|
||||||
|
StartTime: unixMilli(row.StartTime),
|
||||||
|
EndTime: unixMilli(row.EndTime),
|
||||||
|
SettlementDelayMinutes: row.SettlementDelayMinutes,
|
||||||
|
SettlementTime: unixMilli(row.SettlementTime),
|
||||||
|
CoinPerDraw: row.CoinPerDraw,
|
||||||
|
EventTypes: []string{"GAME_CONSUME_GOLD"},
|
||||||
|
RankingType: row.RankingType,
|
||||||
|
RankingPeriod: row.RankingPeriod,
|
||||||
|
Enabled: row.Enabled,
|
||||||
|
SettlementStatus: row.SettlementStatus,
|
||||||
|
CreateTime: unixMilli(row.CreateTime),
|
||||||
|
UpdateTime: unixMilli(row.UpdateTime),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prizeView(row model.YumiGameKingPrize) PrizeView {
|
||||||
|
return PrizeView{
|
||||||
|
ID: row.ID,
|
||||||
|
ActivityID: row.ActivityID,
|
||||||
|
PrizeName: row.PrizeName,
|
||||||
|
PrizeImage: row.PrizeImage,
|
||||||
|
Weight: row.Weight,
|
||||||
|
Stock: row.Stock,
|
||||||
|
ResourceGroupID: row.ResourceGroupID,
|
||||||
|
RewardGroupName: row.RewardGroupName,
|
||||||
|
Enabled: row.Enabled,
|
||||||
|
SortOrder: row.SortOrder,
|
||||||
|
RewardItems: rewardItems(row.RewardItemsJSON),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rankRewardView(row model.YumiGameKingRankReward) RankRewardView {
|
||||||
|
return RankRewardView{
|
||||||
|
ID: row.ID,
|
||||||
|
ActivityID: row.ActivityID,
|
||||||
|
StartRank: row.StartRank,
|
||||||
|
EndRank: row.EndRank,
|
||||||
|
ResourceGroupID: row.ResourceGroupID,
|
||||||
|
RewardName: row.RewardName,
|
||||||
|
RewardItems: rewardItems(row.RewardItemsJSON),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func activityStatus(row model.YumiGameKingActivity, now time.Time) string {
|
||||||
|
if !row.Enabled {
|
||||||
|
return activityDisabled
|
||||||
|
}
|
||||||
|
if now.Before(row.StartTime) {
|
||||||
|
return activityUpcoming
|
||||||
|
}
|
||||||
|
if now.Before(row.EndTime) {
|
||||||
|
return activityActive
|
||||||
|
}
|
||||||
|
return activityEnded
|
||||||
|
}
|
||||||
|
|
||||||
|
func dateKey(at time.Time, location *time.Location) time.Time {
|
||||||
|
local := at.In(location)
|
||||||
|
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
}
|
||||||
|
|
||||||
|
func withWriteLock(db *gorm.DB) *gorm.DB {
|
||||||
|
return db.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRecordNotFound(err error) bool { return errors.Is(err, gorm.ErrRecordNotFound) }
|
||||||
|
|
||||||
|
func truncateFailure(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if len(value) <= 500 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return value[:500]
|
||||||
|
}
|
||||||
|
|
||||||
|
func integrationProfile(entry *RankEntryView, profile integration.UserProfile) {
|
||||||
|
entry.Account = profile.Account
|
||||||
|
entry.Nickname = profile.UserNickname
|
||||||
|
entry.Avatar = profile.UserAvatar
|
||||||
|
}
|
||||||
330
internal/service/gameking/page.go
Normal file
330
internal/service/gameking/page.go
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
package gameking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Me 返回机会账本和活动默认榜单中的当前名次。
|
||||||
|
func (s *Service) Me(ctx context.Context, user AuthUser, activityID int64) (*UserStateResponse, error) {
|
||||||
|
if user.UserID <= 0 {
|
||||||
|
return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "authenticated user is required")
|
||||||
|
}
|
||||||
|
activity, err := s.resolveAppActivity(ctx, normalizeSysOrigin(user.SysOrigin), activityID, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var row model.YumiGameKingUser
|
||||||
|
err = s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", activity.ID, user.UserID).First(&row).Error
|
||||||
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
row = model.YumiGameKingUser{ActivityID: activity.ID, UserID: user.UserID}
|
||||||
|
}
|
||||||
|
earned := row.TotalConsumed / activity.CoinPerDraw
|
||||||
|
available := earned - row.UsedChances
|
||||||
|
if available < 0 {
|
||||||
|
available = 0
|
||||||
|
}
|
||||||
|
remainder := row.TotalConsumed % activity.CoinPerDraw
|
||||||
|
next := activity.CoinPerDraw
|
||||||
|
if remainder > 0 {
|
||||||
|
next -= remainder
|
||||||
|
}
|
||||||
|
var rank *int
|
||||||
|
if row.TotalConsumed > 0 {
|
||||||
|
value, rankErr := s.userRank(ctx, activity, user.UserID, activity.RankingPeriod, time.Now())
|
||||||
|
if rankErr != nil {
|
||||||
|
return nil, rankErr
|
||||||
|
}
|
||||||
|
rank = value
|
||||||
|
}
|
||||||
|
return &UserStateResponse{
|
||||||
|
ActivityID: activity.ID, TotalConsumed: row.TotalConsumed, EarnedChances: earned,
|
||||||
|
UsedChances: row.UsedChances, AvailableChances: available, NextChanceRemaining: next, Rank: rank,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ranking 返回活动时区日榜或总榜。VICTORIOUS 是旧 H5 兼容维度;当前活动没有胜利事件,
|
||||||
|
// 因此显式请求时返回空榜而不是 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
163
internal/service/gameking/settlement.go
Normal file
163
internal/service/gameking/settlement.go
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
package gameking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
"chatapp3-golang/internal/utils"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
settlementDispatchBatch = 4
|
||||||
|
settlementDueBatch = 10
|
||||||
|
staleDeliveryBatch = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// Settle 在结算截点后冻结 TYCOON/OVERALL Top30,并发送尚未开始的 PENDING 奖励。
|
||||||
|
// 重复调用不会重建快照,也不会自动重试 FAILED/UNKNOWN。
|
||||||
|
func (s *Service) Settle(ctx context.Context, activityID int64) error {
|
||||||
|
if activityID <= 0 {
|
||||||
|
return NewAppError(http.StatusBadRequest, "invalid_activity_id", "activityId is required")
|
||||||
|
}
|
||||||
|
if err := s.freezeSettlement(ctx, activityID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var items []model.YumiGameKingDeliveryItem
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Where("activity_id = ? AND owner_type = ? AND delivery_status = ?", activityID, OwnerSettlement, DeliveryPending).
|
||||||
|
Order("id ASC").Limit(settlementDispatchBatch).Find(&items).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 人工结算同样限制每次最多发送 4 组,最坏外部调用约 80 秒并为 DB 留出余量;
|
||||||
|
// 剩余 PENDING 由后续 settle-due 批次继续恢复,FAILED/UNKNOWN 不会自动发送。
|
||||||
|
var lastErr error
|
||||||
|
for _, item := range items {
|
||||||
|
if err := s.dispatchDeliveryItem(ctx, item.ID, false); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) freezeSettlement(ctx context.Context, activityID int64) error {
|
||||||
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var activity model.YumiGameKingActivity
|
||||||
|
// 排他活动锁与事件/抽奖共享锁构成冻结闸门:先进入的业务事务完成后,状态在同一
|
||||||
|
// 事务切为 PROCESSING;此后新事件会在共享锁放行后看到非 NOT_STARTED 并拒绝入账。
|
||||||
|
if err := withWriteLock(tx).Where("id = ?", activityID).First(&activity).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return NewAppError(http.StatusNotFound, "activity_not_found", "game king activity was not found")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if !activity.Enabled {
|
||||||
|
return NewAppError(http.StatusConflict, "activity_disabled", "disabled activity cannot be settled")
|
||||||
|
}
|
||||||
|
if now.Before(activity.SettlementTime) {
|
||||||
|
return NewAppError(http.StatusConflict, "settlement_not_due", "activity settlement time has not arrived")
|
||||||
|
}
|
||||||
|
if activity.SettlementStatus != SettlementNotStarted {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.YumiGameKingActivity{}).Where("id = ?", activity.ID).
|
||||||
|
Updates(map[string]any{"settlement_status": SettlementProcessing, "update_time": now}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var users []model.YumiGameKingUser
|
||||||
|
if err := tx.Where("activity_id = ? AND total_consumed > 0", activity.ID).
|
||||||
|
Order("total_consumed DESC, consume_reached_time ASC, user_id ASC").Limit(30).Find(&users).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(users) == 0 {
|
||||||
|
return tx.Model(&model.YumiGameKingActivity{}).Where("id = ?", activity.ID).
|
||||||
|
Updates(map[string]any{"settlement_status": SettlementCompleted, "update_time": now}).Error
|
||||||
|
}
|
||||||
|
var rewards []model.YumiGameKingRankReward
|
||||||
|
if err := tx.Where("activity_id = ?", activity.ID).Order("start_rank ASC").Find(&rewards).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(rewards) != len(fixedRankRanges) {
|
||||||
|
return NewAppError(http.StatusConflict, "rank_reward_config_invalid", "six rank reward tiers are required for settlement")
|
||||||
|
}
|
||||||
|
for index, user := range users {
|
||||||
|
rank := index + 1
|
||||||
|
reward, ok := rankRewardFor(rewards, rank)
|
||||||
|
if !ok {
|
||||||
|
return NewAppError(http.StatusConflict, "rank_reward_config_invalid", "rank reward tier does not cover Top30")
|
||||||
|
}
|
||||||
|
recordID, err := utils.NextID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
itemID, err := utils.NextID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
businessNo := fmt.Sprintf("YUMI_GAME_KING_SETTLEMENT:%d:%d", activity.ID, rank)
|
||||||
|
record := model.YumiGameKingSettlementRecord{
|
||||||
|
ID: recordID, ActivityID: activity.ID, UserID: user.UserID, RankNo: rank,
|
||||||
|
TotalConsumed: user.TotalConsumed, RankRewardID: reward.ID, ResourceGroupID: reward.ResourceGroupID,
|
||||||
|
BusinessNo: businessNo, DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&record).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Create(&model.YumiGameKingDeliveryItem{
|
||||||
|
ID: itemID, OwnerType: OwnerSettlement, OwnerID: record.ID, ActivityID: activity.ID,
|
||||||
|
UserID: user.UserID, ResourceGroupID: reward.ResourceGroupID,
|
||||||
|
DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func rankRewardFor(rows []model.YumiGameKingRankReward, rank int) (model.YumiGameKingRankReward, bool) {
|
||||||
|
for _, row := range rows {
|
||||||
|
if rank >= row.StartRank && rank <= row.EndRank {
|
||||||
|
return row, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return model.YumiGameKingRankReward{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettleDue 是外部 cron 的唯一批量触发业务方法;本仓库不启动 ticker。
|
||||||
|
func (s *Service) SettleDue(ctx context.Context) (*SettleDueResult, error) {
|
||||||
|
// 先把过期 PROCESSING 保守转 UNKNOWN;只做状态修复,不盲发。
|
||||||
|
if err := s.markStaleProcessingUnknown(ctx, staleDeliveryBatch); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var activities []model.YumiGameKingActivity
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Where("enabled = ? AND settlement_time <= ? AND settlement_status = ?", true, time.Now(), SettlementNotStarted).
|
||||||
|
Order("settlement_time ASC, id ASC").Limit(settlementDueBatch).Find(&activities).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := &SettleDueResult{FailedIDs: []string{}}
|
||||||
|
for _, activity := range activities {
|
||||||
|
result.Scanned++
|
||||||
|
// 批量端点只快速冻结排名并生成 PENDING,不在单个活动内串行发送 Top30。
|
||||||
|
if err := s.freezeSettlement(ctx, activity.ID); err != nil {
|
||||||
|
result.FailedIDs = append(result.FailedIDs, strconv.FormatInt(activity.ID, 10))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.Settled++
|
||||||
|
}
|
||||||
|
// 每次最多领取 4 个全局 PENDING;单项 dispatch 硬超时 20 秒,外部调用上界约
|
||||||
|
// 80 秒,为冻结/状态修复留约 40 秒,chatapp-cron 应配置 >=120 秒超时。
|
||||||
|
if _, err := s.RecoverPendingDeliveries(ctx, settlementDispatchBatch); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
359
internal/service/gameking/types.go
Normal file
359
internal/service/gameking/types.go
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
package gameking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/common"
|
||||||
|
"chatapp3-golang/internal/integration"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultTimezone = "Asia/Riyadh"
|
||||||
|
defaultSettlementDelayMinutes = 30
|
||||||
|
maxSettlementDelayMinutes = 1440
|
||||||
|
maxBackfillLimit = 500
|
||||||
|
defaultBackfillLimit = 200
|
||||||
|
maxRankingLimit = 100
|
||||||
|
defaultRankingLimit = 30
|
||||||
|
maxRecordLimit = 100
|
||||||
|
defaultRecordLimit = 20
|
||||||
|
|
||||||
|
rankingTypeTycoon = "TYCOON"
|
||||||
|
rankingPeriodOverall = "OVERALL"
|
||||||
|
|
||||||
|
activityUpcoming = "UPCOMING"
|
||||||
|
activityActive = "ACTIVE"
|
||||||
|
activityEnded = "ENDED"
|
||||||
|
activityDisabled = "DISABLED"
|
||||||
|
|
||||||
|
SettlementNotStarted = "NOT_STARTED"
|
||||||
|
SettlementProcessing = "PROCESSING"
|
||||||
|
SettlementCompleted = "COMPLETED"
|
||||||
|
SettlementPartialFailed = "PARTIAL_FAILED"
|
||||||
|
|
||||||
|
DeliveryPending = "PENDING"
|
||||||
|
DeliveryProcessing = "PROCESSING"
|
||||||
|
DeliverySuccess = "SUCCESS"
|
||||||
|
DeliveryFailed = "FAILED"
|
||||||
|
DeliveryUnknown = "UNKNOWN"
|
||||||
|
|
||||||
|
OwnerDraw = "DRAW"
|
||||||
|
OwnerSettlement = "SETTLEMENT"
|
||||||
|
|
||||||
|
rewardOriginGameKing = "GAME_KING_AWARD"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AppError = common.AppError
|
||||||
|
type AuthUser = common.AuthUser
|
||||||
|
|
||||||
|
var NewAppError = common.NewAppError
|
||||||
|
|
||||||
|
type gameKingDB interface {
|
||||||
|
WithContext(context.Context) *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type gameKingGateway interface {
|
||||||
|
GetRewardGroupDetail(context.Context, int64) (integration.RewardGroupDetail, error)
|
||||||
|
SendActivityReward(context.Context, integration.SendActivityRewardRequest) error
|
||||||
|
MapUserProfiles(context.Context, []int64) (map[int64]integration.UserProfile, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service 实现 Yumi 游戏王配置、事件入账、抽奖、排行榜与结算业务。
|
||||||
|
// 自动扫描由 chatapp-cron 通过内部接口触发,本服务不持有 ticker。
|
||||||
|
type Service struct {
|
||||||
|
db gameKingDB
|
||||||
|
gateway gameKingGateway
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(db gameKingDB, gateway gameKingGateway) *Service {
|
||||||
|
return &Service{db: db, gateway: gateway}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexibleInt64 同时接受 JSON 字符串和数字,响应始终输出字符串,避免前端丢失 Snowflake 精度。
|
||||||
|
type FlexibleInt64 int64
|
||||||
|
|
||||||
|
func (v *FlexibleInt64) UnmarshalJSON(data []byte) error {
|
||||||
|
raw := strings.TrimSpace(string(data))
|
||||||
|
if raw == "" || raw == "null" || raw == `""` {
|
||||||
|
*v = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) {
|
||||||
|
unquoted, err := strconv.Unquote(raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw = strings.TrimSpace(unquoted)
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid int64 value: %w", err)
|
||||||
|
}
|
||||||
|
*v = FlexibleInt64(parsed)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v FlexibleInt64) Int64() int64 { return int64(v) }
|
||||||
|
|
||||||
|
func (v FlexibleInt64) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(strconv.FormatInt(int64(v), 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
type RewardItemView struct {
|
||||||
|
ID int64 `json:"id,string"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
DetailType string `json:"detailType,omitempty"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
Quantity int64 `json:"quantity"`
|
||||||
|
Cover string `json:"cover,omitempty"`
|
||||||
|
SourceURL string `json:"sourceUrl,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Remark string `json:"remark,omitempty"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActivityView struct {
|
||||||
|
ID int64 `json:"id,string"`
|
||||||
|
ActivityCode string `json:"activityCode"`
|
||||||
|
ActivityName string `json:"activityName"`
|
||||||
|
ActivityDesc string `json:"activityDesc"`
|
||||||
|
SysOrigin string `json:"sysOrigin"`
|
||||||
|
Timezone string `json:"timeZone"`
|
||||||
|
StartTime int64 `json:"startTime"`
|
||||||
|
EndTime int64 `json:"endTime"`
|
||||||
|
SettlementDelayMinutes int `json:"settlementDelayMinutes"`
|
||||||
|
SettlementTime int64 `json:"settlementTime"`
|
||||||
|
CoinPerDraw int64 `json:"coinPerDraw"`
|
||||||
|
EventTypes []string `json:"eventTypes"`
|
||||||
|
RankingType string `json:"rankingType"`
|
||||||
|
RankingPeriod string `json:"rankingPeriod"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
SettlementStatus string `json:"settlementStatus"`
|
||||||
|
CreateTime int64 `json:"createTime"`
|
||||||
|
UpdateTime int64 `json:"updateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrizeView struct {
|
||||||
|
ID int64 `json:"id,string"`
|
||||||
|
ActivityID int64 `json:"activityId,string"`
|
||||||
|
PrizeName string `json:"prizeName"`
|
||||||
|
PrizeImage string `json:"prizeImage"`
|
||||||
|
Weight int64 `json:"weight"`
|
||||||
|
Stock int64 `json:"stock"`
|
||||||
|
ResourceGroupID int64 `json:"resourceGroupId,string"`
|
||||||
|
RewardGroupName string `json:"rewardGroupName,omitempty"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
SortOrder int `json:"sortOrder"`
|
||||||
|
RewardItems []RewardItemView `json:"rewardItems"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RankRewardView struct {
|
||||||
|
ID int64 `json:"id,string"`
|
||||||
|
ActivityID int64 `json:"activityId,string"`
|
||||||
|
StartRank int `json:"startRank"`
|
||||||
|
EndRank int `json:"endRank"`
|
||||||
|
ResourceGroupID int64 `json:"resourceGroupId,string"`
|
||||||
|
RewardName string `json:"rewardName"`
|
||||||
|
RewardItems []RewardItemView `json:"rewardItems"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DetailResponse struct {
|
||||||
|
Activity ActivityView `json:"activity"`
|
||||||
|
Prizes []PrizeView `json:"prizes"`
|
||||||
|
RankRewards []RankRewardView `json:"rankRewards"`
|
||||||
|
SupportedRankingTypes []string `json:"supportedRankingTypes"`
|
||||||
|
SupportedRankingPeriods []string `json:"supportedRankingPeriods"`
|
||||||
|
ActivityStatus string `json:"activityStatus"`
|
||||||
|
ServerTime int64 `json:"serverTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserStateResponse struct {
|
||||||
|
ActivityID int64 `json:"activityId,string"`
|
||||||
|
TotalConsumed int64 `json:"totalConsumed"`
|
||||||
|
EarnedChances int64 `json:"earnedChances"`
|
||||||
|
UsedChances int64 `json:"usedChances"`
|
||||||
|
AvailableChances int64 `json:"availableChances"`
|
||||||
|
NextChanceRemaining int64 `json:"nextChanceRemaining"`
|
||||||
|
Rank *int `json:"rank"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeliveryItemView struct {
|
||||||
|
ID int64 `json:"id,string"`
|
||||||
|
TrackID int64 `json:"trackId,string"`
|
||||||
|
OwnerType string `json:"ownerType"`
|
||||||
|
OwnerID int64 `json:"ownerId,string"`
|
||||||
|
BusinessNo string `json:"businessNo"`
|
||||||
|
UserID int64 `json:"userId,string"`
|
||||||
|
ResourceGroupID int64 `json:"resourceGroupId,string"`
|
||||||
|
DeliveryStatus string `json:"deliveryStatus"`
|
||||||
|
RetryCount int `json:"retryCount"`
|
||||||
|
FailureReason string `json:"failureReason"`
|
||||||
|
DeliverTime int64 `json:"deliverTime,omitempty"`
|
||||||
|
UpdateTime int64 `json:"updateTime"`
|
||||||
|
RewardItems []RewardItemView `json:"rewardItems"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DrawRecordView struct {
|
||||||
|
ID int64 `json:"id,string"`
|
||||||
|
ActivityID int64 `json:"activityId,string"`
|
||||||
|
UserID int64 `json:"userId,string"`
|
||||||
|
RequestID string `json:"requestId"`
|
||||||
|
PrizeID int64 `json:"prizeId,string"`
|
||||||
|
PrizeName string `json:"prizeName"`
|
||||||
|
PrizeImage string `json:"prizeImage"`
|
||||||
|
ResourceGroupID int64 `json:"resourceGroupId,string"`
|
||||||
|
DeliveryStatus string `json:"deliveryStatus"`
|
||||||
|
RetryCount int `json:"retryCount"`
|
||||||
|
FailureReason string `json:"failureReason"`
|
||||||
|
DrawTime int64 `json:"drawTime"`
|
||||||
|
DeliverTime int64 `json:"deliverTime,omitempty"`
|
||||||
|
DeliveryItems []DeliveryItemView `json:"deliveryItems,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DrawRequest struct {
|
||||||
|
ActivityID FlexibleInt64 `json:"activityId"`
|
||||||
|
RequestID string `json:"requestId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DrawResult struct {
|
||||||
|
Record DrawRecordView `json:"record"`
|
||||||
|
Prize PrizeView `json:"prize"`
|
||||||
|
RemainingChances int64 `json:"remainingChances"`
|
||||||
|
IdempotentReplay bool `json:"idempotentReplay"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RankEntryView struct {
|
||||||
|
Rank int `json:"rank"`
|
||||||
|
UserID int64 `json:"userId,string"`
|
||||||
|
Account string `json:"account"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
Score int64 `json:"score"`
|
||||||
|
TotalConsumed int64 `json:"totalConsumed"`
|
||||||
|
TotalWon int64 `json:"totalWon"`
|
||||||
|
Me bool `json:"me"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RankingResponse struct {
|
||||||
|
RankingType string `json:"rankingType"`
|
||||||
|
Period string `json:"period"`
|
||||||
|
StatDate string `json:"statDate"`
|
||||||
|
Entries []RankEntryView `json:"entries"`
|
||||||
|
My *RankEntryView `json:"my"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettlementRecordView struct {
|
||||||
|
ID int64 `json:"id,string"`
|
||||||
|
ActivityID int64 `json:"activityId,string"`
|
||||||
|
UserID int64 `json:"userId,string"`
|
||||||
|
Rank int `json:"rank"`
|
||||||
|
TotalConsumed int64 `json:"totalConsumed"`
|
||||||
|
RankRewardID int64 `json:"rankRewardId,string"`
|
||||||
|
ResourceGroupID int64 `json:"resourceGroupId,string"`
|
||||||
|
BusinessNo string `json:"businessNo"`
|
||||||
|
DeliveryStatus string `json:"deliveryStatus"`
|
||||||
|
RetryCount int `json:"retryCount"`
|
||||||
|
FailureReason string `json:"failureReason"`
|
||||||
|
CreateTime int64 `json:"createTime"`
|
||||||
|
DeliverTime int64 `json:"deliverTime,omitempty"`
|
||||||
|
DeliveryItems []DeliveryItemView `json:"deliveryItems,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrizeInput struct {
|
||||||
|
ID FlexibleInt64 `json:"id"`
|
||||||
|
PrizeName string `json:"prizeName"`
|
||||||
|
PrizeImage string `json:"prizeImage"`
|
||||||
|
Weight int64 `json:"weight"`
|
||||||
|
Stock int64 `json:"stock"`
|
||||||
|
ResourceGroupID FlexibleInt64 `json:"resourceGroupId"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
SortOrder int `json:"sortOrder"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RankRewardInput struct {
|
||||||
|
ID FlexibleInt64 `json:"id"`
|
||||||
|
StartRank int `json:"startRank"`
|
||||||
|
EndRank int `json:"endRank"`
|
||||||
|
ResourceGroupID FlexibleInt64 `json:"resourceGroupId"`
|
||||||
|
RewardName string `json:"rewardName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveActivityRequest struct {
|
||||||
|
ID FlexibleInt64 `json:"id"`
|
||||||
|
ActivityCode string `json:"activityCode"`
|
||||||
|
ActivityName string `json:"activityName"`
|
||||||
|
ActivityDesc string `json:"activityDesc"`
|
||||||
|
SysOrigin string `json:"sysOrigin"`
|
||||||
|
Timezone string `json:"timeZone"`
|
||||||
|
StartTime int64 `json:"startTime"`
|
||||||
|
EndTime int64 `json:"endTime"`
|
||||||
|
SettlementDelayMinutes *int `json:"settlementDelayMinutes"`
|
||||||
|
CoinPerDraw int64 `json:"coinPerDraw"`
|
||||||
|
RankingType string `json:"rankingType"`
|
||||||
|
RankingPeriod string `json:"rankingPeriod"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Prizes []PrizeInput `json:"prizes"`
|
||||||
|
RankRewards []RankRewardInput `json:"rankRewards"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SavePrizesRequest struct {
|
||||||
|
ActivityID FlexibleInt64 `json:"activityId"`
|
||||||
|
Prizes []PrizeInput `json:"prizes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveRankRewardsRequest struct {
|
||||||
|
ActivityID FlexibleInt64 `json:"activityId"`
|
||||||
|
RankRewards []RankRewardInput `json:"rankRewards"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BackfillResult struct {
|
||||||
|
EventType string `json:"eventType"`
|
||||||
|
Scanned int `json:"scanned"`
|
||||||
|
Accepted int `json:"accepted"`
|
||||||
|
NextLastID int64 `json:"nextLastId,string"`
|
||||||
|
Finished bool `json:"finished"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettleDueResult struct {
|
||||||
|
Scanned int `json:"scanned"`
|
||||||
|
Settled int `json:"settled"`
|
||||||
|
FailedIDs []string `json:"failedIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rewardGroupSnapshot struct {
|
||||||
|
GroupID int64
|
||||||
|
GroupName string
|
||||||
|
ItemsJSON string
|
||||||
|
Items []RewardItemView
|
||||||
|
}
|
||||||
|
|
||||||
|
type normalizedPrize struct {
|
||||||
|
Input PrizeInput
|
||||||
|
Snapshot rewardGroupSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
type normalizedRankReward struct {
|
||||||
|
Input RankRewardInput
|
||||||
|
Snapshot rewardGroupSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func unixMilli(value time.Time) int64 {
|
||||||
|
if value.IsZero() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return value.UnixMilli()
|
||||||
|
}
|
||||||
|
|
||||||
|
func unixMilliPtr(value *time.Time) int64 {
|
||||||
|
if value == nil || value.IsZero() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return value.UnixMilli()
|
||||||
|
}
|
||||||
@ -67,6 +67,22 @@ func (s *Service) ProcessEvent(ctx context.Context, req EventRequest) (*EventRes
|
|||||||
}
|
}
|
||||||
occurredAt := parseOccurredAt(req.OccurredAt, time.Now())
|
occurredAt := parseOccurredAt(req.OccurredAt, time.Now())
|
||||||
receivedAt := time.Now()
|
receivedAt := time.Now()
|
||||||
|
// 游戏王以任务中心已经归一化的 GAME_CONSUME_GOLD 为唯一事实来源。这里必须位于
|
||||||
|
// task-center 自身去重事务之前:即使当前没有配置任务,活动仍要统计;sink 失败时也
|
||||||
|
// 不能提前写入任务去重表,否则 MQ 重投会永久跳过活动入账。
|
||||||
|
if eventType == EventTypeGameConsumeGold && s.gameConsumeSink != nil {
|
||||||
|
if err := s.gameConsumeSink.ConsumeGameEvent(ctx, ValidatedEvent{
|
||||||
|
SysOrigin: sysOrigin,
|
||||||
|
EventID: eventID,
|
||||||
|
EventType: eventType,
|
||||||
|
UserID: userID,
|
||||||
|
DeltaValue: req.DeltaValue,
|
||||||
|
OccurredAt: occurredAt,
|
||||||
|
Payload: req.Payload,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
rawJSON := buildArchiveRawJSON(req, sysOrigin, eventID, eventType, userID, req.DeltaValue, occurredAt, receivedAt, "", "")
|
rawJSON := buildArchiveRawJSON(req, sysOrigin, eventID, eventType, userID, req.DeltaValue, occurredAt, receivedAt, "", "")
|
||||||
|
|
||||||
bundle, err := s.loadEnabledBundle(ctx, sysOrigin)
|
bundle, err := s.loadEnabledBundle(ctx, sysOrigin)
|
||||||
|
|||||||
@ -62,13 +62,32 @@ type taskCenterJavaGateway interface {
|
|||||||
ExistsGoldEvent(ctx context.Context, eventID string) (bool, error)
|
ExistsGoldEvent(ctx context.Context, eventID string) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidatedEvent 是任务中心完成身份、事件类型和正数金额校验后的稳定事件视图。
|
||||||
|
// 下游活动只依赖该视图,避免重复解释 HTTP/MQ 两种输入格式。
|
||||||
|
type ValidatedEvent struct {
|
||||||
|
SysOrigin string
|
||||||
|
EventID string
|
||||||
|
EventType string
|
||||||
|
UserID int64
|
||||||
|
DeltaValue int64
|
||||||
|
OccurredAt time.Time
|
||||||
|
Payload map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameConsumeSink 接收权威 GAME_CONSUME_GOLD 事件。
|
||||||
|
// sink 自己必须通过业务唯一键幂等,因为 HTTP 与 MQ 可能同时送达同一事件。
|
||||||
|
type GameConsumeSink interface {
|
||||||
|
ConsumeGameEvent(context.Context, ValidatedEvent) error
|
||||||
|
}
|
||||||
|
|
||||||
// Service 负责任务配置、进度、领取和事件入库。
|
// Service 负责任务配置、进度、领取和事件入库。
|
||||||
type Service struct {
|
type Service struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
db taskCenterDB
|
db taskCenterDB
|
||||||
java taskCenterJavaGateway
|
java taskCenterJavaGateway
|
||||||
location *time.Location
|
location *time.Location
|
||||||
rocketConsumer rmq.PushConsumer
|
rocketConsumer rmq.PushConsumer
|
||||||
|
gameConsumeSink GameConsumeSink
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(cfg config.Config, db taskCenterDB, java taskCenterJavaGateway) *Service {
|
func NewService(cfg config.Config, db taskCenterDB, java taskCenterJavaGateway) *Service {
|
||||||
@ -83,6 +102,13 @@ func NewService(cfg config.Config, db taskCenterDB, java taskCenterJavaGateway)
|
|||||||
return &Service{cfg: cfg, db: db, java: java, location: location}
|
return &Service{cfg: cfg, db: db, java: java, location: location}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetGameConsumeSink 把固定游戏消耗事件扇出给活动模块。
|
||||||
|
func (s *Service) SetGameConsumeSink(sink GameConsumeSink) {
|
||||||
|
if s != nil {
|
||||||
|
s.gameConsumeSink = sink
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TaskConfigPayload 是后台配置页的任务读模型。
|
// TaskConfigPayload 是后台配置页的任务读模型。
|
||||||
type TaskConfigPayload struct {
|
type TaskConfigPayload struct {
|
||||||
ID int64 `json:"id,string"`
|
ID int64 `json:"id,string"`
|
||||||
|
|||||||
274
internal/service/yumigiftchallenge/activity.go
Normal file
274
internal/service/yumigiftchallenge/activity.go
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
package yumigiftchallenge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) normalizeSysOrigin(value string) string {
|
||||||
|
value = strings.ToUpper(strings.TrimSpace(value))
|
||||||
|
if value == "" {
|
||||||
|
value = strings.ToUpper(strings.TrimSpace(s.cfg.YumiGiftChallenge.DefaultSysOrigin))
|
||||||
|
}
|
||||||
|
if value == "" {
|
||||||
|
value = defaultSysOrigin
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) requireYumiOrigin(value string) (string, error) {
|
||||||
|
origin := s.normalizeSysOrigin(value)
|
||||||
|
// Yumi 是产品品牌,真实 Java SysOriginPlatformEnum 为 LIKEI。显式拒绝其他来源,
|
||||||
|
// 防止后台误建一个永远收不到 Yumi 送礼事件的孤立活动。
|
||||||
|
if origin != defaultSysOrigin {
|
||||||
|
return "", NewAppError(http.StatusBadRequest, "invalid_sys_origin", "Yumi gift challenge sysOrigin must be LIKEI")
|
||||||
|
}
|
||||||
|
return origin, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveLocation(value string) (*time.Location, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
value = defaultTimezone
|
||||||
|
}
|
||||||
|
location, err := time.LoadLocation(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error())
|
||||||
|
}
|
||||||
|
return location, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dateOnly(value time.Time, location *time.Location) time.Time {
|
||||||
|
local := value.In(location)
|
||||||
|
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dateKey(value time.Time, location *time.Location) string {
|
||||||
|
return value.In(location).Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDateKey(value string, location *time.Location) (time.Time, error) {
|
||||||
|
parsed, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(value), location)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, NewAppError(http.StatusBadRequest, "invalid_stat_date", "statDate must be yyyy-MM-dd")
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func activityStatus(activity model.YumiGiftChallengeActivity, now time.Time) string {
|
||||||
|
if !activity.Enabled {
|
||||||
|
return "DISABLED"
|
||||||
|
}
|
||||||
|
if now.Before(activity.StartTime) {
|
||||||
|
return "NOT_STARTED"
|
||||||
|
}
|
||||||
|
if !now.Before(activity.EndTime) {
|
||||||
|
return "ENDED"
|
||||||
|
}
|
||||||
|
return "ONGOING"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadActivity(ctx context.Context, id int64) (*model.YumiGiftChallengeActivity, error) {
|
||||||
|
if id <= 0 {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_activity_id", "activityId is required")
|
||||||
|
}
|
||||||
|
var row model.YumiGiftChallengeActivity
|
||||||
|
err := s.db.WithContext(ctx).Where("id = ?", id).First(&row).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, NewAppError(http.StatusNotFound, "activity_not_found", "activity not found")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) selectActivity(ctx context.Context, origin string, id int64, enabledOnly bool) (*model.YumiGiftChallengeActivity, error) {
|
||||||
|
if id > 0 {
|
||||||
|
row, err := s.loadActivity(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if row.SysOrigin != origin || (enabledOnly && !row.Enabled) {
|
||||||
|
return nil, NewAppError(http.StatusNotFound, "activity_not_found", "activity not found")
|
||||||
|
}
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
query := s.db.WithContext(ctx).Model(&model.YumiGiftChallengeActivity{}).
|
||||||
|
Where("sys_origin = ?", origin)
|
||||||
|
if enabledOnly {
|
||||||
|
query = query.Where("enabled = ?", true)
|
||||||
|
}
|
||||||
|
// 进行中优先,其次最近待开始,最后最近结束;CASE 只在单一来源的少量活动配置上执行,
|
||||||
|
// 热榜和送礼链路不会走该排序。
|
||||||
|
var row model.YumiGiftChallengeActivity
|
||||||
|
err := query.Order(clause.Expr{SQL: "CASE WHEN start_time <= ? AND end_time > ? THEN 0 WHEN start_time > ? THEN 1 ELSE 2 END", Vars: []any{now, now, now}}).
|
||||||
|
Order(clause.Expr{SQL: "CASE WHEN start_time > ? THEN start_time END ASC", Vars: []any{now}}).
|
||||||
|
Order("end_time DESC").First(&row).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, NewAppError(http.StatusNotFound, "activity_not_found", "activity not found")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadActivityChildren(ctx context.Context, activityID int64) ([]model.YumiGiftChallengeTaskConfig, []model.YumiGiftChallengeRankReward, error) {
|
||||||
|
var tasks []model.YumiGiftChallengeTaskConfig
|
||||||
|
if err := s.db.WithContext(ctx).Where("activity_id = ?", activityID).Order("sort_order ASC, id ASC").Find(&tasks).Error; err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
var rewards []model.YumiGiftChallengeRankReward
|
||||||
|
if err := s.db.WithContext(ctx).Where("activity_id = ?", activityID).
|
||||||
|
Order("period_type ASC, start_rank ASC, id ASC").Find(&rewards).Error; err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return tasks, rewards, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadRewardSnapshots(ctx context.Context, activityID int64) (map[int64][]model.YumiGiftChallengeRewardSnapshot, error) {
|
||||||
|
var rows []model.YumiGiftChallengeRewardSnapshot
|
||||||
|
if err := s.db.WithContext(ctx).Where("activity_id = ?", activityID).
|
||||||
|
Order("resource_group_id ASC, sort_order ASC, reward_config_id ASC").Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make(map[int64][]model.YumiGiftChallengeRewardSnapshot)
|
||||||
|
for _, row := range rows {
|
||||||
|
result[row.ResourceGroupID] = append(result[row.ResourceGroupID], row)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rewardItemViews(rows []model.YumiGiftChallengeRewardSnapshot) []RewardItemView {
|
||||||
|
items := make([]RewardItemView, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
items = append(items, RewardItemView{
|
||||||
|
ID: row.RewardConfigID, Type: row.RewardType, DetailType: row.DetailType,
|
||||||
|
Content: row.Content, Quantity: row.Quantity, Cover: row.Cover,
|
||||||
|
SourceURL: row.SourceURL, Name: row.DisplayName, Sort: row.SortOrder,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildActivityView(row model.YumiGiftChallengeActivity) ActivityView {
|
||||||
|
return ActivityView{
|
||||||
|
ID: row.ID, ActivityCode: row.ActivityCode, ActivityName: row.ActivityName,
|
||||||
|
ActivityDesc: row.ActivityDesc, SysOrigin: row.SysOrigin, Timezone: row.Timezone,
|
||||||
|
StartTime: row.StartTime.UnixMilli(), EndTime: row.EndTime.UnixMilli(),
|
||||||
|
DailySettlementDelayMinutes: row.DailySettlementDelayMinutes,
|
||||||
|
OverallSettlementDelayMinutes: row.OverallSettlementDelayMinutes,
|
||||||
|
OverallSettlementTime: row.OverallSettlementTime.UnixMilli(), DisplayTopN: row.DisplayTopN,
|
||||||
|
Enabled: row.Enabled, OverallSettlementStatus: row.OverallSettlementStatus,
|
||||||
|
Version: row.Version, CreateTime: row.CreateTime.UnixMilli(), UpdateTime: row.UpdateTime.UnixMilli(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) buildDetail(ctx context.Context, activity model.YumiGiftChallengeActivity) (*DetailResponse, error) {
|
||||||
|
tasks, rewards, err := s.loadActivityChildren(ctx, activity.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
snapshots, err := s.loadRewardSnapshots(ctx, activity.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
taskViews := make([]TaskView, 0, len(tasks))
|
||||||
|
for _, row := range tasks {
|
||||||
|
view := TaskView{
|
||||||
|
ID: row.ID, ActivityID: row.ActivityID, TaskCode: row.TaskCode, TaskType: row.TaskType,
|
||||||
|
TaskTitle: row.TaskTitle, TaskDesc: row.TaskDesc, TargetValue: row.TargetValue,
|
||||||
|
ResourceGroupID: row.ResourceGroupID, Enabled: row.Enabled, SortOrder: row.SortOrder,
|
||||||
|
RewardItems: []RewardItemView{},
|
||||||
|
}
|
||||||
|
if row.ResourceGroupID != nil {
|
||||||
|
view.RewardItems = rewardItemViews(snapshots[*row.ResourceGroupID])
|
||||||
|
}
|
||||||
|
taskViews = append(taskViews, view)
|
||||||
|
}
|
||||||
|
rewardViews := make([]RankRewardView, 0, len(rewards))
|
||||||
|
for _, row := range rewards {
|
||||||
|
rewardViews = append(rewardViews, RankRewardView{
|
||||||
|
ID: row.ID, ActivityID: row.ActivityID, PeriodType: row.PeriodType,
|
||||||
|
StartRank: row.StartRank, EndRank: row.EndRank, ResourceGroupID: row.ResourceGroupID,
|
||||||
|
RewardName: row.RewardName, RewardItems: rewardItemViews(snapshots[row.ResourceGroupID]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &DetailResponse{
|
||||||
|
Activity: buildActivityView(activity), Tasks: taskViews, RankRewards: rewardViews,
|
||||||
|
SupportedPeriods: []string{PeriodDaily, PeriodOverall},
|
||||||
|
SupportedTaskTypes: []string{TaskEnterPage, TaskSendGiftGold},
|
||||||
|
ActivityStatus: activityStatus(activity, time.Now()), ServerTime: time.Now().UnixMilli(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDetail 返回 Yumi H5 活动详情,用户身份只用于来源隔离,不接受客户端伪造 userId。
|
||||||
|
func (s *Service) GetDetail(ctx context.Context, user AuthUser, activityID int64) (*DetailResponse, error) {
|
||||||
|
origin, err := s.requireYumiOrigin(user.SysOrigin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
activity, err := s.selectActivity(ctx, origin, activityID, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.buildDetail(ctx, *activity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAdminDetail 返回后台完整配置详情。
|
||||||
|
func (s *Service) GetAdminDetail(ctx context.Context, activityID int64) (*DetailResponse, error) {
|
||||||
|
activity, err := s.loadActivity(ctx, activityID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.buildDetail(ctx, *activity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActivities 分页读取配置;查询始终命中 sys_origin+时间索引,不扫描送礼明细。
|
||||||
|
func (s *Service) ListActivities(ctx context.Context, origin string, page, size int) (map[string]any, error) {
|
||||||
|
origin, err := s.requireYumiOrigin(origin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if size < 1 || size > 100 {
|
||||||
|
size = 20
|
||||||
|
}
|
||||||
|
query := s.db.WithContext(ctx).Model(&model.YumiGiftChallengeActivity{}).Where("sys_origin = ?", origin)
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var rows []model.YumiGiftChallengeActivity
|
||||||
|
if err := query.Order("start_time DESC, id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
records := make([]ActivityView, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
records = append(records, buildActivityView(row))
|
||||||
|
}
|
||||||
|
return map[string]any{"records": records, "total": total, "current": page, "size": size}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortTaskRows(rows []model.YumiGiftChallengeTaskConfig) {
|
||||||
|
sort.Slice(rows, func(i, j int) bool {
|
||||||
|
if rows[i].SortOrder == rows[j].SortOrder {
|
||||||
|
return rows[i].ID < rows[j].ID
|
||||||
|
}
|
||||||
|
return rows[i].SortOrder < rows[j].SortOrder
|
||||||
|
})
|
||||||
|
}
|
||||||
800
internal/service/yumigiftchallenge/config.go
Normal file
800
internal/service/yumigiftchallenge/config.go
Normal file
@ -0,0 +1,800 @@
|
|||||||
|
package yumigiftchallenge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
"chatapp3-golang/internal/utils"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
var taskCodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||||
|
|
||||||
|
// SaveActivity 保存完整配置;活动开始后只允许修改名称和描述,不触碰已生效统计口径。
|
||||||
|
func (s *Service) SaveActivity(ctx context.Context, req SaveRequest) (*DetailResponse, error) {
|
||||||
|
activityID := req.ID.Int64()
|
||||||
|
if activityID > 0 {
|
||||||
|
current, err := s.loadActivity(ctx, activityID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !time.Now().Before(current.StartTime) {
|
||||||
|
return s.saveStartedActivityMetadata(ctx, *current, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
origin, err := s.requireYumiOrigin(req.SysOrigin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
location, startAt, endAt, err := validateActivityInput(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tasks, err := normalizeTaskInputs(req.Tasks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rewards, err := normalizeRankRewardInputs(req.RankRewards, req.DisplayTopN)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if activityID <= 0 {
|
||||||
|
activityID, err = utils.NextID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
proposed := model.YumiGiftChallengeActivity{
|
||||||
|
ID: activityID, ActivityCode: strings.TrimSpace(req.ActivityCode), ActivityName: strings.TrimSpace(req.ActivityName),
|
||||||
|
ActivityDesc: strings.TrimSpace(req.ActivityDesc), SysOrigin: origin, Timezone: location.String(),
|
||||||
|
StartTime: startAt, EndTime: endAt,
|
||||||
|
DailySettlementDelayMinutes: req.DailySettlementDelayMinutes,
|
||||||
|
OverallSettlementDelayMinutes: req.OverallSettlementDelayMinutes,
|
||||||
|
OverallSettlementTime: endAt.Add(time.Duration(req.OverallSettlementDelayMinutes) * time.Minute),
|
||||||
|
DisplayTopN: req.DisplayTopN, Enabled: req.Enabled, OverallSettlementStatus: StatusNotStarted,
|
||||||
|
CreateTime: now, UpdateTime: now,
|
||||||
|
}
|
||||||
|
for i := range tasks {
|
||||||
|
tasks[i].ActivityID, tasks[i].CreateTime, tasks[i].UpdateTime = activityID, now, now
|
||||||
|
}
|
||||||
|
for i := range rewards {
|
||||||
|
rewards[i].ActivityID, rewards[i].CreateTime, rewards[i].UpdateTime = activityID, now, now
|
||||||
|
}
|
||||||
|
var snapshots []model.YumiGiftChallengeRewardSnapshot
|
||||||
|
var headers []model.YumiGiftChallengePeriodSettlement
|
||||||
|
if req.Enabled {
|
||||||
|
// 所有外部奖励组读取与校验都在事务前完成;任何失败都不会先禁用或删除旧配置。
|
||||||
|
snapshots, err = s.buildRewardSnapshots(ctx, proposed, tasks, rewards)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
headers, err = buildPeriodHeaders(proposed)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if req.ID.Int64() <= 0 {
|
||||||
|
if req.Enabled {
|
||||||
|
if !proposed.StartTime.After(time.Now()) || !proposed.EndTime.After(time.Now()) {
|
||||||
|
return NewAppError(http.StatusConflict, "invalid_enable_window", "enabled activity must start in the future")
|
||||||
|
}
|
||||||
|
if err := ensureNoEnabledOverlap(tx, proposed); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Create(&proposed).Error; err != nil {
|
||||||
|
if isDuplicateKey(err) {
|
||||||
|
return NewAppError(http.StatusConflict, "duplicate_activity_code", "activityCode already exists")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if req.Version == nil {
|
||||||
|
return NewAppError(http.StatusBadRequest, "version_required", "version is required when updating")
|
||||||
|
}
|
||||||
|
var current model.YumiGiftChallengeActivity
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", activityID).First(¤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)
|
||||||
|
}
|
||||||
305
internal/service/yumigiftchallenge/delivery.go
Normal file
305
internal/service/yumigiftchallenge/delivery.go
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
package yumigiftchallenge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/integration"
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
"chatapp3-golang/internal/utils"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) createDeliveryItemsTx(tx *gorm.DB, ownerType string, ownerID, activityID, userID int64, resourceGroupID *int64) error {
|
||||||
|
if resourceGroupID == nil || *resourceGroupID <= 0 {
|
||||||
|
return NewAppError(http.StatusConflict, "reward_group_missing", "reward group is missing")
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := tx.Model(&model.YumiGiftChallengeDeliveryItem{}).
|
||||||
|
Where("owner_type = ? AND owner_id = ?", ownerType, ownerID).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var snapshots []model.YumiGiftChallengeRewardSnapshot
|
||||||
|
if err := tx.Where("activity_id = ? AND resource_group_id = ?", activityID, *resourceGroupID).
|
||||||
|
Order("sort_order ASC, reward_config_id ASC").Find(&snapshots).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(snapshots) == 0 {
|
||||||
|
return NewAppError(http.StatusConflict, "reward_snapshot_missing", "frozen reward snapshot is missing")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
items := make([]model.YumiGiftChallengeDeliveryItem, 0, len(snapshots))
|
||||||
|
for _, snapshot := range snapshots {
|
||||||
|
id, err := utils.NextID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items = append(items, model.YumiGiftChallengeDeliveryItem{
|
||||||
|
ID: id, OwnerType: ownerType, OwnerID: ownerID, ActivityID: activityID, UserID: userID,
|
||||||
|
RewardConfigID: snapshot.RewardConfigID, ResourceGroupID: snapshot.ResourceGroupID,
|
||||||
|
RewardType: snapshot.RewardType, DetailType: snapshot.DetailType, Content: snapshot.Content,
|
||||||
|
Quantity: snapshot.Quantity, SortOrder: snapshot.SortOrder, Remark: snapshot.Remark,
|
||||||
|
DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// owner_type+owner_id+reward_config_id 唯一键是逐奖励项幂等边界;若并发已经物化,整个事务回滚后调用方重读即可。
|
||||||
|
if err := tx.Create(&items).Error; err != nil && !isDuplicateKey(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) deliverOwnerItems(ctx context.Context, ownerType string, ownerID int64) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.expireOwnerProcessing(ctx, ownerType, ownerID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
// 请求或 worker 取消后必须在下一次 claim 前停住,未抢占的奖励项保持 PENDING,
|
||||||
|
// 不能拿已取消的 context 批量调用 Java 并把整期奖励污染成 UNKNOWN。
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
item, err := s.claimNextDeliveryItem(ctx, ownerType, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if item == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
request := integration.SendFrozenActivityRewardRequest{
|
||||||
|
// 外层 trackId 是当前 delivery item 主键,保证不同用户和不同奖励项互不冲突。
|
||||||
|
TrackID: item.ID, AcceptUserID: item.UserID, Origin: "ACTIVITY_REWARD", SysOrigin: defaultSysOrigin,
|
||||||
|
Prizes: []integration.FrozenActivityReward{{
|
||||||
|
// Java PrizeDescribe.trackId 沿用资源组(商品)ID;真正的发奖幂等号是外层 delivery item ID。
|
||||||
|
TrackID: item.ResourceGroupID, Type: item.RewardType, DetailType: item.DetailType,
|
||||||
|
Content: item.Content, Quantity: item.Quantity, Remark: item.Remark,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
deliveryErr := s.gateway.SendFrozenActivityReward(ctx, request)
|
||||||
|
// 外部调用一旦发出,即使原 context 已取消也必须用独立短 context 落下 SUCCESS/UNKNOWN;
|
||||||
|
// 状态落库失败时停止后续发送,避免失去可核账的幂等边界。
|
||||||
|
stateCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
finishErr := s.finishDeliveryAttempt(stateCtx, *item, deliveryErr)
|
||||||
|
cancel()
|
||||||
|
if finishErr != nil {
|
||||||
|
return finishErr
|
||||||
|
}
|
||||||
|
if deliveryErr != nil && (errors.Is(deliveryErr, context.Canceled) || errors.Is(deliveryErr, context.DeadlineExceeded) || ctx.Err() != nil) {
|
||||||
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||||
|
return ctxErr
|
||||||
|
}
|
||||||
|
return deliveryErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stateCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
err := s.refreshOwnerAndPeriod(stateCtx, ownerType, ownerID)
|
||||||
|
cancel()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) lockOwner(tx *gorm.DB, ownerType string, ownerID int64) error {
|
||||||
|
if ownerType == OwnerTask {
|
||||||
|
var row model.YumiGiftChallengeUserTaskDaily
|
||||||
|
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", ownerID).First(&row).Error
|
||||||
|
}
|
||||||
|
if ownerType == OwnerSettlement {
|
||||||
|
var row model.YumiGiftChallengeSettlement
|
||||||
|
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", ownerID).First(&row).Error
|
||||||
|
}
|
||||||
|
return NewAppError(http.StatusBadRequest, "invalid_owner_type", "invalid delivery owner type")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) expireOwnerProcessing(ctx context.Context, ownerType string, ownerID int64) error {
|
||||||
|
leaseSeconds := s.cfg.YumiGiftChallenge.DeliveryProcessingLeaseSeconds
|
||||||
|
if leaseSeconds < 30 {
|
||||||
|
leaseSeconds = 300
|
||||||
|
}
|
||||||
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 所有状态聚合统一 owner→item 锁序,避免补发、核账和正常发送互相死锁。
|
||||||
|
if err := s.lockOwner(tx, ownerType, ownerID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Model(&model.YumiGiftChallengeDeliveryItem{}).
|
||||||
|
Where("owner_type = ? AND owner_id = ? AND delivery_status = ? AND update_time < ?",
|
||||||
|
ownerType, ownerID, DeliveryProcessing, time.Now().Add(-time.Duration(leaseSeconds)*time.Second)).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"delivery_status": DeliveryUnknown, "failure_reason": "DELIVERY_RESULT_UNKNOWN: processing lease expired; reconcile downstream before resolving",
|
||||||
|
"update_time": time.Now(),
|
||||||
|
}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) claimNextDeliveryItem(ctx context.Context, ownerType string, ownerID int64) (*model.YumiGiftChallengeDeliveryItem, error) {
|
||||||
|
var claimed *model.YumiGiftChallengeDeliveryItem
|
||||||
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := s.lockOwner(tx, ownerType, ownerID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var item model.YumiGiftChallengeDeliveryItem
|
||||||
|
err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
|
||||||
|
Where("owner_type = ? AND owner_id = ? AND delivery_status = ?", ownerType, ownerID, DeliveryPending).
|
||||||
|
Order("sort_order ASC, id ASC").First(&item).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
result := tx.Model(&model.YumiGiftChallengeDeliveryItem{}).
|
||||||
|
Where("id = ? AND delivery_status = ?", item.ID, DeliveryPending).
|
||||||
|
Updates(map[string]any{"delivery_status": DeliveryProcessing, "retry_count": gorm.Expr("retry_count + 1"), "update_time": now})
|
||||||
|
if result.Error != nil || result.RowsAffected != 1 {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
item.DeliveryStatus = DeliveryProcessing
|
||||||
|
claimed = &item
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return claimed, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) finishDeliveryAttempt(ctx context.Context, candidate model.YumiGiftChallengeDeliveryItem, deliveryErr error) error {
|
||||||
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := s.lockOwner(tx, candidate.OwnerType, candidate.OwnerID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var item model.YumiGiftChallengeDeliveryItem
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", candidate.ID).First(&item).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if item.DeliveryStatus != DeliveryProcessing {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
updates := map[string]any{"update_time": now}
|
||||||
|
if deliveryErr == nil {
|
||||||
|
updates["delivery_status"], updates["failure_reason"], updates["deliver_time"] = DeliverySuccess, "", now
|
||||||
|
} else {
|
||||||
|
// 外部调用超时、断连或返回异常时无法证明未到账,统一 UNKNOWN;自动流程绝不盲目重发。
|
||||||
|
updates["delivery_status"] = DeliveryUnknown
|
||||||
|
updates["failure_reason"] = truncate(fmt.Sprintf("DELIVERY_RESULT_UNKNOWN: %v", deliveryErr), 500)
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.YumiGiftChallengeDeliveryItem{}).Where("id = ?", item.ID).Updates(updates).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.finishOwnerStatusLocked(tx, candidate.OwnerType, candidate.OwnerID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) finishOwnerStatusLocked(tx *gorm.DB, ownerType string, ownerID int64) error {
|
||||||
|
var total, unfinished, unknown int64
|
||||||
|
base := tx.Model(&model.YumiGiftChallengeDeliveryItem{}).Where("owner_type = ? AND owner_id = ?", ownerType, ownerID)
|
||||||
|
if err := base.Count(&total).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return NewAppError(http.StatusConflict, "delivery_items_missing", "delivery items are missing")
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.YumiGiftChallengeDeliveryItem{}).
|
||||||
|
Where("owner_type = ? AND owner_id = ? AND delivery_status <> ?", ownerType, ownerID, DeliverySuccess).Count(&unfinished).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.YumiGiftChallengeDeliveryItem{}).
|
||||||
|
Where("owner_type = ? AND owner_id = ? AND delivery_status = ?", ownerType, ownerID, DeliveryUnknown).Count(&unknown).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
status, reason := DeliveryProcessing, ""
|
||||||
|
updates := map[string]any{"update_time": time.Now()}
|
||||||
|
if unfinished == 0 {
|
||||||
|
status, updates["deliver_time"] = DeliverySuccess, time.Now()
|
||||||
|
} else if unknown > 0 {
|
||||||
|
status, reason = DeliveryUnknown, "MANUAL_RECONCILIATION_REQUIRED: resolve UNKNOWN items after downstream audit"
|
||||||
|
}
|
||||||
|
updates["delivery_status"], updates["failure_reason"] = status, reason
|
||||||
|
if ownerType == OwnerTask {
|
||||||
|
return tx.Model(&model.YumiGiftChallengeUserTaskDaily{}).Where("id = ?", ownerID).Updates(updates).Error
|
||||||
|
}
|
||||||
|
return tx.Model(&model.YumiGiftChallengeSettlement{}).Where("id = ?", ownerID).Updates(updates).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) refreshOwnerAndPeriod(ctx context.Context, ownerType string, ownerID int64) error {
|
||||||
|
var activityID int64
|
||||||
|
var period, periodKey string
|
||||||
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := s.lockOwner(tx, ownerType, ownerID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.finishOwnerStatusLocked(tx, ownerType, ownerID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ownerType == OwnerSettlement {
|
||||||
|
var row model.YumiGiftChallengeSettlement
|
||||||
|
if err := tx.Where("id = ?", ownerID).First(&row).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
activityID, period, periodKey = row.ActivityID, row.PeriodType, row.PeriodKey
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil || ownerType != OwnerSettlement {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.refreshPeriodStatus(ctx, activityID, period, periodKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveUnknownDeliveryItem 只有运营核账后才能把 UNKNOWN 标成到账或重置为 PENDING 补发。
|
||||||
|
func (s *Service) ResolveUnknownDeliveryItem(ctx context.Context, itemID int64, delivered bool) error {
|
||||||
|
var candidate model.YumiGiftChallengeDeliveryItem
|
||||||
|
if err := s.db.WithContext(ctx).Where("id = ?", itemID).First(&candidate).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return NewAppError(http.StatusNotFound, "delivery_item_not_found", "delivery item not found")
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := s.lockOwner(tx, candidate.OwnerType, candidate.OwnerID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var item model.YumiGiftChallengeDeliveryItem
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", itemID).First(&item).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if item.DeliveryStatus != DeliveryUnknown {
|
||||||
|
return NewAppError(http.StatusConflict, "delivery_item_not_unknown", "only UNKNOWN item can be reconciled")
|
||||||
|
}
|
||||||
|
status := DeliveryPending
|
||||||
|
updates := map[string]any{"delivery_status": status, "failure_reason": "", "update_time": time.Now()}
|
||||||
|
if delivered {
|
||||||
|
status = DeliverySuccess
|
||||||
|
updates["delivery_status"], updates["deliver_time"] = status, time.Now()
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.YumiGiftChallengeDeliveryItem{}).Where("id = ?", itemID).Updates(updates).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.finishOwnerStatusLocked(tx, candidate.OwnerType, candidate.OwnerID)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !delivered {
|
||||||
|
if err := s.deliverOwnerItems(ctx, candidate.OwnerType, candidate.OwnerID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.refreshOwnerAndPeriod(ctx, candidate.OwnerType, candidate.OwnerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(value string, max int) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if len(value) <= max {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return value[:max]
|
||||||
|
}
|
||||||
221
internal/service/yumigiftchallenge/event.go
Normal file
221
internal/service/yumigiftchallenge/event.go
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
package yumigiftchallenge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
"chatapp3-golang/internal/utils"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errMalformedGiftPayload = errors.New("malformed yumi gift challenge payload")
|
||||||
|
|
||||||
|
// ProcessGiftPayload 解析 RocketMQ 原始消息并复用活动入账逻辑。
|
||||||
|
func (s *Service) ProcessGiftPayload(ctx context.Context, payload string) error {
|
||||||
|
payload = strings.TrimSpace(payload)
|
||||||
|
if payload == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
event, err := decodeGiftEvent(payload, s.cfg.YumiGiftChallenge.RocketMQ.Tag)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %v", errMalformedGiftPayload, err)
|
||||||
|
}
|
||||||
|
return s.ProcessGiftEvent(ctx, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessGiftEvent 只接受普通 GOLD 非背包礼物;幸运礼物和 MAGIC 在金额判断前强制排除。
|
||||||
|
func (s *Service) ProcessGiftEvent(ctx context.Context, event giftEvent) error {
|
||||||
|
trackID := strings.TrimSpace(event.TrackID.String())
|
||||||
|
userID := event.SendUserID.Int64()
|
||||||
|
giftID := event.GiftConfig.ID.Int64()
|
||||||
|
if trackID == "" || userID <= 0 || giftID <= 0 || event.CreateTime.Int64() <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
origin := strings.ToUpper(strings.TrimSpace(event.SysOrigin))
|
||||||
|
if origin == "" || origin != defaultSysOrigin {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
giftType := strings.ToUpper(strings.TrimSpace(event.GiftConfig.Type))
|
||||||
|
giftTab := strings.ToUpper(strings.TrimSpace(event.GiftConfig.GiftTab))
|
||||||
|
acceptCount, acceptsValid := countDistinctAcceptUsers(event.Accepts)
|
||||||
|
quantity, giftCandy := event.Quantity.Int64(), event.GiftConfig.GiftCandy
|
||||||
|
// give_gift_v3 不保证存在可直接信任的 actualAmount;礼物主流水总消耗口径是
|
||||||
|
// 单价 × 数量 × 去重有效收礼人数。缺失收礼人必须忽略,不能默认按 1 人计分。
|
||||||
|
if event.BagGift == nil || *event.BagGift || (event.GiftValue.Bag != nil && *event.GiftValue.Bag) || strings.TrimSpace(giftTab) == "" ||
|
||||||
|
!acceptsValid || quantity <= 0 || !giftCandy.Positive() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
amount, err := giftCandy.MultiplyInt(quantity)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
amount, err = amount.MultiplyInt(int64(acceptCount))
|
||||||
|
if err != nil || !amount.Positive() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if giftType != "GOLD" || giftTab == "LUCKY_GIFT" || giftTab == "MAGIC" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
eventTime := time.UnixMilli(event.CreateTime.Int64())
|
||||||
|
var activity model.YumiGiftChallengeActivity
|
||||||
|
err = s.db.WithContext(ctx).Where(
|
||||||
|
"sys_origin = ? AND enabled = ? AND start_time <= ? AND end_time > ?",
|
||||||
|
origin, true, eventTime, eventTime,
|
||||||
|
).Order("start_time DESC").First(&activity).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
location, err := resolveLocation(activity.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
statDate := dateOnly(eventTime, location)
|
||||||
|
periodKey := dateKey(statDate, location)
|
||||||
|
|
||||||
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 热链路只持有共享门闩锁,同一活动的送礼事件可以并发;结算使用排他锁,
|
||||||
|
// 会等待已经进入事务的事件提交,再冻结确定的边界。
|
||||||
|
var dailyHeader model.YumiGiftChallengePeriodSettlement
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where(
|
||||||
|
"activity_id = ? AND period_type = ? AND period_key = ?", activity.ID, PeriodDaily, periodKey,
|
||||||
|
).First(&dailyHeader).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var overallHeader model.YumiGiftChallengePeriodSettlement
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where(
|
||||||
|
"activity_id = ? AND period_type = ? AND period_key = ?", activity.ID, PeriodOverall, PeriodOverall,
|
||||||
|
).First(&overallHeader).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 总榜门闩关闭后整笔拒绝;日榜门闩关闭只阻止日榜和当日任务,合法迟到礼物仍计入尚未冻结的总榜。
|
||||||
|
if overallHeader.Status != StatusNotStarted {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ledgerID, err := utils.NextID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ledger := model.YumiGiftChallengeGiftLedger{
|
||||||
|
ID: ledgerID, ActivityID: activity.ID, SourceEventTrackID: trackID, UserID: userID,
|
||||||
|
GiftID: giftID, GiftTab: giftTab, Amount: amount, EventTime: eventTime,
|
||||||
|
StatDate: periodKey, CreateTime: time.Now(),
|
||||||
|
}
|
||||||
|
if err := tx.Create(&ledger).Error; err != nil {
|
||||||
|
if isDuplicateKey(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
// score_reached_time 取已消费事件时间的最大值;乱序迟到事件不能把并列分数的到达时间倒拨,
|
||||||
|
// 从而保证 score DESC、reached_time ASC、user_id ASC 的排序稳定。
|
||||||
|
if err := tx.Exec(`
|
||||||
|
INSERT INTO yumi_gift_challenge_user_score
|
||||||
|
(activity_id, user_id, score, score_reached_time, create_time, update_time)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
score = score + VALUES(score),
|
||||||
|
score_reached_time = GREATEST(score_reached_time, VALUES(score_reached_time)),
|
||||||
|
update_time = VALUES(update_time)`,
|
||||||
|
activity.ID, userID, amount, eventTime, now, now).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if dailyHeader.Status == StatusNotStarted {
|
||||||
|
if err := tx.Exec(`
|
||||||
|
INSERT INTO yumi_gift_challenge_user_daily_score
|
||||||
|
(activity_id, stat_date, user_id, score, score_reached_time, create_time, update_time)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
score = score + VALUES(score),
|
||||||
|
score_reached_time = GREATEST(score_reached_time, VALUES(score_reached_time)),
|
||||||
|
update_time = VALUES(update_time)`,
|
||||||
|
activity.ID, periodKey, userID, amount, eventTime, now, now).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.ensureTaskSnapshotsTx(tx, activity, periodKey, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 每个事件只执行一次本更新,因为 ledger 唯一键已在同一事务内成功占位。
|
||||||
|
if err := tx.Exec(`
|
||||||
|
UPDATE yumi_gift_challenge_user_task_daily
|
||||||
|
SET progress_value = LEAST(target_value, progress_value + ?),
|
||||||
|
completed_time = CASE
|
||||||
|
WHEN completed_time IS NULL AND progress_value + ? >= target_value THEN ?
|
||||||
|
ELSE completed_time
|
||||||
|
END,
|
||||||
|
update_time = ?
|
||||||
|
WHERE activity_id = ? AND stat_date = ? AND user_id = ? AND task_type = ?`,
|
||||||
|
amount, amount, eventTime, now, activity.ID, periodKey, userID, TaskSendGiftGold).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func countDistinctAcceptUsers(users []*giftEventUser) (int, bool) {
|
||||||
|
if len(users) == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
seen := make(map[int64]struct{}, len(users))
|
||||||
|
for _, user := range users {
|
||||||
|
if user == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
id := user.AcceptUserID.Int64()
|
||||||
|
if id <= 0 {
|
||||||
|
// 任一收礼元素不完整都说明事件口径不可信,整条拒绝而不是静默少算人数。
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
}
|
||||||
|
return len(seen), len(seen) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeGiftEvent(payload, expectedTag string) (giftEvent, error) {
|
||||||
|
var event giftEvent
|
||||||
|
if err := json.Unmarshal([]byte(payload), &event); err != nil {
|
||||||
|
return event, err
|
||||||
|
}
|
||||||
|
if event.TrackID.String() != "" || event.SendUserID.Int64() > 0 || event.GiftConfig.ID.Int64() > 0 {
|
||||||
|
return event, nil
|
||||||
|
}
|
||||||
|
var envelope messageEnvelope
|
||||||
|
if err := json.Unmarshal([]byte(payload), &envelope); err != nil {
|
||||||
|
return event, err
|
||||||
|
}
|
||||||
|
if tag := strings.TrimSpace(envelope.Tag); tag != "" && strings.TrimSpace(expectedTag) != "" && tag != strings.TrimSpace(expectedTag) {
|
||||||
|
return giftEvent{}, nil
|
||||||
|
}
|
||||||
|
body := strings.TrimSpace(string(envelope.Body))
|
||||||
|
if body == "" || body == "null" {
|
||||||
|
return giftEvent{}, nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(body, `"`) {
|
||||||
|
if err := json.Unmarshal(envelope.Body, &body); err != nil {
|
||||||
|
return event, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(body), &event); err != nil {
|
||||||
|
return event, err
|
||||||
|
}
|
||||||
|
return event, nil
|
||||||
|
}
|
||||||
113
internal/service/yumigiftchallenge/lifecycle.go
Normal file
113
internal/service/yumigiftchallenge/lifecycle.go
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
package yumigiftchallenge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
rmq "github.com/apache/rocketmq-clients/golang/v5"
|
||||||
|
"github.com/apache/rocketmq-clients/golang/v5/credentials"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartMessageConsumer 异步启动 Yumi 独立消费组;MQ 临时不可用不能阻断 API 启动。
|
||||||
|
func (s *Service) StartMessageConsumer(ctx context.Context) error {
|
||||||
|
if !s.cfg.YumiGiftChallenge.RocketMQ.Enabled {
|
||||||
|
log.Printf("yumi gift challenge rocketmq consumer disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
mqConfig := s.cfg.YumiGiftChallenge.RocketMQ
|
||||||
|
if strings.TrimSpace(mqConfig.Endpoint) == "" || strings.TrimSpace(mqConfig.AccessKey) == "" || strings.TrimSpace(mqConfig.AccessSecret) == "" {
|
||||||
|
log.Printf("yumi gift challenge rocketmq consumer skipped: endpoint or credentials missing")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
go s.startRocketMQConsumerWithRetry(ctx)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) startRocketMQConsumerWithRetry(ctx context.Context) {
|
||||||
|
retryDelay := 5 * time.Second
|
||||||
|
for ctx.Err() == nil {
|
||||||
|
if err := s.startRocketMQConsumer(ctx); err == nil {
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
log.Printf("start yumi gift challenge rocketmq consumer failed: %v; retry in %s", err, retryDelay)
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(retryDelay)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
if retryDelay < time.Minute {
|
||||||
|
retryDelay *= 2
|
||||||
|
if retryDelay > time.Minute {
|
||||||
|
retryDelay = time.Minute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) startRocketMQConsumer(ctx context.Context) error {
|
||||||
|
mqConfig := s.cfg.YumiGiftChallenge.RocketMQ
|
||||||
|
filter := rmq.SUB_ALL
|
||||||
|
if tag := strings.TrimSpace(mqConfig.Tag); tag != "" {
|
||||||
|
filter = rmq.NewFilterExpression(tag)
|
||||||
|
}
|
||||||
|
consumer, err := rmq.NewPushConsumer(&rmq.Config{
|
||||||
|
Endpoint: mqConfig.Endpoint, NameSpace: mqConfig.Namespace, ConsumerGroup: mqConfig.ConsumerGroup,
|
||||||
|
Credentials: &credentials.SessionCredentials{
|
||||||
|
AccessKey: mqConfig.AccessKey, AccessSecret: mqConfig.AccessSecret, SecurityToken: mqConfig.SecurityToken,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rmq.WithPushSubscriptionExpressions(map[string]*rmq.FilterExpression{mqConfig.Topic: filter}),
|
||||||
|
rmq.WithPushConsumptionThreadCount(8),
|
||||||
|
rmq.WithPushMaxCacheMessageCount(256),
|
||||||
|
rmq.WithPushMessageListener(&rmq.FuncMessageListener{Consume: func(messageView *rmq.MessageView) rmq.ConsumerResult {
|
||||||
|
if messageView == nil {
|
||||||
|
return rmq.SUCCESS
|
||||||
|
}
|
||||||
|
if err := s.processRocketMQMessage(context.Background(), messageView); err != nil {
|
||||||
|
log.Printf("yumi gift challenge mq process failed. messageId=%s err=%v", messageView.GetMessageId(), err)
|
||||||
|
return rmq.FAILURE
|
||||||
|
}
|
||||||
|
return rmq.SUCCESS
|
||||||
|
}}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := consumer.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.rocketConsumer = consumer
|
||||||
|
log.Printf("yumi gift challenge rocketmq consumer started. topic=%s group=%s tag=%s", mqConfig.Topic, mqConfig.ConsumerGroup, mqConfig.Tag)
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
if s.rocketConsumer != nil {
|
||||||
|
_ = s.rocketConsumer.GracefulStop()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) processRocketMQMessage(ctx context.Context, messageView *rmq.MessageView) error {
|
||||||
|
payload := strings.TrimSpace(string(messageView.GetBody()))
|
||||||
|
if payload == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := s.ProcessGiftPayload(ctx, payload); err != nil {
|
||||||
|
var syntaxErr *json.SyntaxError
|
||||||
|
var typeErr *json.UnmarshalTypeError
|
||||||
|
if errors.Is(err, errMalformedGiftPayload) || errors.As(err, &syntaxErr) || errors.As(err, &typeErr) {
|
||||||
|
// 毒消息重试不会自愈;记录 messageId 后 ACK,数据库或下游错误仍返回 FAILURE 重试。
|
||||||
|
log.Printf("drop malformed yumi gift challenge message. messageId=%s err=%v", messageView.GetMessageId(), err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
320
internal/service/yumigiftchallenge/ranking.go
Normal file
320
internal/service/yumigiftchallenge/ranking.go
Normal file
@ -0,0 +1,320 @@
|
|||||||
|
package yumigiftchallenge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rawRankRow struct {
|
||||||
|
UserID int64
|
||||||
|
Score model.Decimal24_2
|
||||||
|
ScoreReachedTime time.Time
|
||||||
|
Rank int
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRanking 返回 H5 排行榜;神秘人不出现在他人的公开榜,但本人仍可查看真实名次。
|
||||||
|
func (s *Service) GetRanking(ctx context.Context, user AuthUser, activityID int64, period, statDate string, limit int) (*RankingResponse, error) {
|
||||||
|
origin, err := s.requireYumiOrigin(user.SysOrigin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
activity, err := s.selectActivity(ctx, origin, activityID, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.ranking(ctx, *activity, user.UserID, period, statDate, limit, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAdminRanking 返回运营榜单,不应用用户公开隐身过滤,但仍保持同一确定性排序。
|
||||||
|
func (s *Service) GetAdminRanking(ctx context.Context, activityID int64, period, statDate string, limit int) (*RankingResponse, error) {
|
||||||
|
activity, err := s.loadActivity(ctx, activityID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.ranking(ctx, *activity, 0, period, statDate, limit, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ranking(ctx context.Context, activity model.YumiGiftChallengeActivity, viewerID int64, period, statDate string, limit int, admin bool) (*RankingResponse, error) {
|
||||||
|
period = strings.ToUpper(strings.TrimSpace(period))
|
||||||
|
if period == "" {
|
||||||
|
period = PeriodDaily
|
||||||
|
}
|
||||||
|
if period != PeriodDaily && period != PeriodOverall {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_period", "period must be DAILY or OVERALL")
|
||||||
|
}
|
||||||
|
if limit < 1 {
|
||||||
|
if admin {
|
||||||
|
limit = 100
|
||||||
|
} else {
|
||||||
|
limit = maxPublicRankingLimit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if limit > activity.DisplayTopN {
|
||||||
|
limit = activity.DisplayTopN
|
||||||
|
}
|
||||||
|
if limit > maxDisplayTopN {
|
||||||
|
limit = maxDisplayTopN
|
||||||
|
}
|
||||||
|
if !admin && limit > maxPublicRankingLimit {
|
||||||
|
// Java 目前只提供单用户 VIP ability 接口,公开榜必须逐人按隐私优先核验;
|
||||||
|
// 因而在服务端而非仅 H5 端限流,避免恶意 limit=500 放大为 500 次下游 HTTP。
|
||||||
|
limit = maxPublicRankingLimit
|
||||||
|
}
|
||||||
|
location, err := resolveLocation(activity.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var date *time.Time
|
||||||
|
periodKey := PeriodOverall
|
||||||
|
if period == PeriodDaily {
|
||||||
|
selected, err := selectRankingDate(activity, statDate, location)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
date, periodKey = &selected, dateKey(selected, location)
|
||||||
|
}
|
||||||
|
var header model.YumiGiftChallengePeriodSettlement
|
||||||
|
err = s.db.WithContext(ctx).Where("activity_id = ? AND period_type = ? AND period_key = ?", activity.ID, period, periodKey).First(&header).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, NewAppError(http.StatusConflict, "settlement_gate_missing", "period settlement gate is missing")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
settled := header.Status != StatusNotStarted
|
||||||
|
// 与 Aslan 一致:只读取请求的原始 Top N,再过滤神秘人;被隐藏的名次不从 N 之后补位。
|
||||||
|
fetchLimit := limit
|
||||||
|
rows, err := s.loadRankRows(ctx, activity.ID, period, periodKey, date, settled, fetchLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries := make([]RankEntryView, 0, limit)
|
||||||
|
var my *RankEntryView
|
||||||
|
decorated := s.decorateRankEntries(ctx, activity.SysOrigin, rows, viewerID, admin)
|
||||||
|
for index, row := range rows {
|
||||||
|
view, visible := decorated[index].view, decorated[index].visible
|
||||||
|
if row.UserID == viewerID {
|
||||||
|
copy := view
|
||||||
|
my = ©
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
129
internal/service/yumigiftchallenge/runner.go
Normal file
129
internal/service/yumigiftchallenge/runner.go
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
package yumigiftchallenge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultSettleDueBatch = 20
|
||||||
|
maxSettleDueBatch = 100
|
||||||
|
settleDueItemTimeout = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// SettleDue 是 chatapp-cron 的单轮幂等 runner:冻结到期周期,并恢复 PENDING/过租期 PROCESSING。
|
||||||
|
// 本仓库不启动 ticker;同一端点可被多个实例重复调用,数据库门闩和 delivery item 唯一键负责并发幂等。
|
||||||
|
func (s *Service) SettleDue(ctx context.Context, batch int) (*SettleDueResult, error) {
|
||||||
|
batch = normalizeSettleDueBatch(batch)
|
||||||
|
result := &SettleDueResult{FailedKeys: []string{}}
|
||||||
|
var periods []model.YumiGiftChallengePeriodSettlement
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Where("status IN ? AND snapshot_due_time <= ?", []string{StatusNotStarted, StatusProcessing}, time.Now()).
|
||||||
|
Order("snapshot_due_time ASC, id ASC").Limit(batch).Find(&periods).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, period := range periods {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
result.PeriodsScanned++
|
||||||
|
request := SettlementRequest{PeriodType: period.PeriodType}
|
||||||
|
if period.StatDate != nil {
|
||||||
|
request.StatDate = *period.StatDate
|
||||||
|
}
|
||||||
|
itemCtx, cancel := context.WithTimeout(ctx, settleDueItemTimeout)
|
||||||
|
target, err := s.resolveSettlementTarget(itemCtx, period.ActivityID, request)
|
||||||
|
if err == nil {
|
||||||
|
err = s.freezeSettlementTarget(itemCtx, target)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
// 空榜直接完成;有获奖 parent 时保持 PROCESSING,后面的有界 delivery 恢复负责逐项发放。
|
||||||
|
err = s.refreshPeriodStatus(itemCtx, target.activity.ID, target.period, target.periodKey)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
result.FailedKeys = append(result.FailedKeys, fmt.Sprintf("PERIOD:%d:%s:%s", period.ActivityID, period.PeriodType, period.PeriodKey))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.PeriodsFrozen++
|
||||||
|
}
|
||||||
|
if err := s.recoverTaskOwners(ctx, batch, result); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if err := s.recoverSettlementOwners(ctx, batch, result); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) recoverTaskOwners(ctx context.Context, batch int, result *SettleDueResult) error {
|
||||||
|
var rows []model.YumiGiftChallengeUserTaskDaily
|
||||||
|
if err := s.db.WithContext(ctx).Select("id").
|
||||||
|
Where("delivery_status = ? OR (delivery_status = ? AND update_time <= ?)", DeliveryPending, DeliveryProcessing, s.deliveryLeaseCutoff()).
|
||||||
|
Order("update_time ASC, id ASC").Limit(batch).Find(&rows).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.TaskOwnersScanned++
|
||||||
|
itemCtx, cancel := context.WithTimeout(ctx, settleDueItemTimeout)
|
||||||
|
err := s.deliverOwnerItems(itemCtx, OwnerTask, row.ID)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
result.FailedKeys = append(result.FailedKeys, fmt.Sprintf("TASK:%d", row.ID))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.OwnersProcessed++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) recoverSettlementOwners(ctx context.Context, batch int, result *SettleDueResult) error {
|
||||||
|
var rows []model.YumiGiftChallengeSettlement
|
||||||
|
if err := s.db.WithContext(ctx).Select("id").
|
||||||
|
Where("delivery_status = ? OR (delivery_status = ? AND update_time <= ?)", DeliveryPending, DeliveryProcessing, s.deliveryLeaseCutoff()).
|
||||||
|
Order("update_time ASC, id ASC").Limit(batch).Find(&rows).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.SettlementOwnersScanned++
|
||||||
|
itemCtx, cancel := context.WithTimeout(ctx, settleDueItemTimeout)
|
||||||
|
err := s.ensureSettlementDeliveryItems(itemCtx, row.ID)
|
||||||
|
if err == nil {
|
||||||
|
err = s.deliverOwnerItems(itemCtx, OwnerSettlement, row.ID)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
result.FailedKeys = append(result.FailedKeys, fmt.Sprintf("SETTLEMENT:%d", row.ID))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.OwnersProcessed++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSettleDueBatch(batch int) int {
|
||||||
|
if batch <= 0 {
|
||||||
|
return defaultSettleDueBatch
|
||||||
|
}
|
||||||
|
if batch > maxSettleDueBatch {
|
||||||
|
return maxSettleDueBatch
|
||||||
|
}
|
||||||
|
return batch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) deliveryLeaseCutoff() time.Time {
|
||||||
|
leaseSeconds := s.cfg.YumiGiftChallenge.DeliveryProcessingLeaseSeconds
|
||||||
|
if leaseSeconds < 30 {
|
||||||
|
leaseSeconds = 300
|
||||||
|
}
|
||||||
|
return time.Now().Add(-time.Duration(leaseSeconds) * time.Second)
|
||||||
|
}
|
||||||
314
internal/service/yumigiftchallenge/settlement.go
Normal file
314
internal/service/yumigiftchallenge/settlement.go
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
package yumigiftchallenge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
"chatapp3-golang/internal/utils"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Settle 使用两阶段提交:先冻结门闩和获奖 parent,提交后再物化并发送奖励项。
|
||||||
|
func (s *Service) Settle(ctx context.Context, activityID int64, req SettlementRequest) error {
|
||||||
|
target, err := s.resolveSettlementTarget(ctx, activityID, req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.freezeSettlementTarget(ctx, target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.resumeSettlementTarget(ctx, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
type settlementTarget struct {
|
||||||
|
activity *model.YumiGiftChallengeActivity
|
||||||
|
period string
|
||||||
|
periodKey string
|
||||||
|
statDate *string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolveSettlementTarget(ctx context.Context, activityID int64, req SettlementRequest) (settlementTarget, error) {
|
||||||
|
activity, err := s.loadActivity(ctx, activityID)
|
||||||
|
if err != nil {
|
||||||
|
return settlementTarget{}, err
|
||||||
|
}
|
||||||
|
if !activity.Enabled {
|
||||||
|
return settlementTarget{}, NewAppError(http.StatusConflict, "activity_disabled", "activity is disabled")
|
||||||
|
}
|
||||||
|
period := strings.ToUpper(strings.TrimSpace(req.PeriodType))
|
||||||
|
if period != PeriodDaily && period != PeriodOverall {
|
||||||
|
return settlementTarget{}, NewAppError(http.StatusBadRequest, "invalid_period", "periodType must be DAILY or OVERALL")
|
||||||
|
}
|
||||||
|
location, err := resolveLocation(activity.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return settlementTarget{}, err
|
||||||
|
}
|
||||||
|
periodKey := PeriodOverall
|
||||||
|
var statDate *string
|
||||||
|
if period == PeriodDaily {
|
||||||
|
if strings.TrimSpace(req.StatDate) == "" {
|
||||||
|
return settlementTarget{}, NewAppError(http.StatusBadRequest, "stat_date_required", "statDate is required for DAILY settlement")
|
||||||
|
}
|
||||||
|
date, err := parseDateKey(req.StatDate, location)
|
||||||
|
if err != nil {
|
||||||
|
return settlementTarget{}, err
|
||||||
|
}
|
||||||
|
periodKey = dateKey(date, location)
|
||||||
|
statDate = &periodKey
|
||||||
|
}
|
||||||
|
return settlementTarget{activity: activity, period: period, periodKey: periodKey, statDate: statDate}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) freezeSettlementTarget(ctx context.Context, target settlementTarget) error {
|
||||||
|
// 阶段一独立事务只负责关闭写门闩并冻结获奖 parent;阶段二失败不会重新开放榜单或重算获奖人。
|
||||||
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var header model.YumiGiftChallengePeriodSettlement
|
||||||
|
queryErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where(
|
||||||
|
"activity_id = ? AND period_type = ? AND period_key = ?", target.activity.ID, target.period, target.periodKey,
|
||||||
|
).First(&header).Error
|
||||||
|
if errors.Is(queryErr, gorm.ErrRecordNotFound) {
|
||||||
|
return NewAppError(http.StatusNotFound, "settlement_gate_not_found", "settlement gate not found")
|
||||||
|
}
|
||||||
|
if queryErr != nil {
|
||||||
|
return queryErr
|
||||||
|
}
|
||||||
|
if time.Now().Before(header.SnapshotDueTime) {
|
||||||
|
return NewAppError(http.StatusConflict, "settlement_not_due", "settlement delay window has not elapsed")
|
||||||
|
}
|
||||||
|
if header.Status != StatusNotStarted {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.YumiGiftChallengePeriodSettlement{}).Where("id = ?", header.ID).
|
||||||
|
Updates(map[string]any{"status": StatusProcessing, "update_time": time.Now()}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if target.period == PeriodOverall {
|
||||||
|
if err := tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ?", target.activity.ID).
|
||||||
|
Updates(map[string]any{"overall_settlement_status": StatusProcessing, "update_time": time.Now()}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.freezeSettlementParentsTx(tx, *target.activity, target.period, target.periodKey, target.statDate)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resumeSettlementTarget(ctx context.Context, target settlementTarget) error {
|
||||||
|
var parents []model.YumiGiftChallengeSettlement
|
||||||
|
if err := s.db.WithContext(ctx).Where("activity_id = ? AND period_type = ? AND period_key = ?", target.activity.ID, target.period, target.periodKey).
|
||||||
|
Order("rank_no ASC").Find(&parents).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, parent := range parents {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.ensureSettlementDeliveryItems(ctx, parent.ID); err != nil {
|
||||||
|
// parent 和门闩已经提交,返回错误让运营看到;再次执行同一结算会从固定 parent 继续,不重算榜单。
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.deliverOwnerItems(ctx, OwnerSettlement, parent.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.refreshPeriodStatus(ctx, target.activity.ID, target.period, target.periodKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) freezeSettlementParentsTx(tx *gorm.DB, activity model.YumiGiftChallengeActivity, period, periodKey string, statDate *string) error {
|
||||||
|
var rewardConfigs []model.YumiGiftChallengeRankReward
|
||||||
|
if err := tx.Where("activity_id = ? AND period_type = ?", activity.ID, period).
|
||||||
|
Order("start_rank ASC").Find(&rewardConfigs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
maxRank := 0
|
||||||
|
for _, reward := range rewardConfigs {
|
||||||
|
if reward.EndRank > maxRank {
|
||||||
|
maxRank = reward.EndRank
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if maxRank == 0 {
|
||||||
|
return NewAppError(http.StatusConflict, "rank_reward_missing", "rank reward configuration is missing")
|
||||||
|
}
|
||||||
|
rows, err := s.loadRankRowsTx(tx, activity.ID, period, statDate, maxRank)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
for _, row := range rows {
|
||||||
|
reward := matchRankReward(rewardConfigs, row.Rank)
|
||||||
|
if reward == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, err := utils.NextID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
parent := model.YumiGiftChallengeSettlement{
|
||||||
|
ID: id, ActivityID: activity.ID, PeriodType: period, PeriodKey: periodKey, StatDate: statDate,
|
||||||
|
UserID: row.UserID, RankNo: row.Rank, Score: row.Score, RankRewardID: reward.ID,
|
||||||
|
ResourceGroupID: reward.ResourceGroupID,
|
||||||
|
BusinessNo: fmt.Sprintf("YUMI_GIFT_CHALLENGE:RANK:%d:%s:%s:%d", activity.ID, period, periodKey, row.UserID),
|
||||||
|
DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now,
|
||||||
|
}
|
||||||
|
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&parent).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadRankRowsTx(tx *gorm.DB, activityID int64, period string, statDate *string, limit int) ([]rawRankRow, error) {
|
||||||
|
result := make([]rawRankRow, 0, limit)
|
||||||
|
if period == PeriodDaily {
|
||||||
|
var rows []model.YumiGiftChallengeUserDailyScore
|
||||||
|
if err := tx.Where("activity_id = ? AND stat_date = ? AND score > 0", activityID, *statDate).
|
||||||
|
Order("score DESC, score_reached_time ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for index, row := range rows {
|
||||||
|
result = append(result, rawRankRow{UserID: row.UserID, Score: row.Score, Rank: index + 1})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
var rows []model.YumiGiftChallengeUserScore
|
||||||
|
if err := tx.Where("activity_id = ? AND score > 0", activityID).
|
||||||
|
Order("score DESC, score_reached_time ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for index, row := range rows {
|
||||||
|
result = append(result, rawRankRow{UserID: row.UserID, Score: row.Score, Rank: index + 1})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchRankReward(rows []model.YumiGiftChallengeRankReward, rank int) *model.YumiGiftChallengeRankReward {
|
||||||
|
for index := range rows {
|
||||||
|
if rank >= rows[index].StartRank && rank <= rows[index].EndRank {
|
||||||
|
return &rows[index]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ensureSettlementDeliveryItems(ctx context.Context, settlementID int64) error {
|
||||||
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var parent model.YumiGiftChallengeSettlement
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", settlementID).First(&parent).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
groupID := parent.ResourceGroupID
|
||||||
|
return s.createDeliveryItemsTx(tx, OwnerSettlement, parent.ID, parent.ActivityID, parent.UserID, &groupID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) refreshPeriodStatus(ctx context.Context, activityID int64, period, periodKey string) error {
|
||||||
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var header model.YumiGiftChallengePeriodSettlement
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where(
|
||||||
|
"activity_id = ? AND period_type = ? AND period_key = ?", activityID, period, periodKey,
|
||||||
|
).First(&header).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var total, unfinished, unknown int64
|
||||||
|
base := tx.Model(&model.YumiGiftChallengeSettlement{}).
|
||||||
|
Where("activity_id = ? AND period_type = ? AND period_key = ?", activityID, period, periodKey)
|
||||||
|
if err := base.Count(&total).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if total > 0 {
|
||||||
|
if err := tx.Model(&model.YumiGiftChallengeSettlement{}).
|
||||||
|
Where("activity_id = ? AND period_type = ? AND period_key = ? AND delivery_status <> ?", activityID, period, periodKey, DeliverySuccess).
|
||||||
|
Count(&unfinished).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.YumiGiftChallengeSettlement{}).
|
||||||
|
Where("activity_id = ? AND period_type = ? AND period_key = ? AND delivery_status = ?", activityID, period, periodKey, DeliveryUnknown).
|
||||||
|
Count(&unknown).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status := StatusCompleted
|
||||||
|
if unfinished > 0 {
|
||||||
|
status = StatusProcessing
|
||||||
|
if unknown > 0 {
|
||||||
|
status = StatusReconciliationRequired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.YumiGiftChallengePeriodSettlement{}).Where("id = ?", header.ID).
|
||||||
|
Updates(map[string]any{"status": status, "update_time": time.Now()}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if period == PeriodOverall {
|
||||||
|
return tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ?", activityID).
|
||||||
|
Updates(map[string]any{"overall_settlement_status": status, "update_time": time.Now()}).Error
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageSettlementRecords 分页查询获奖 parent 及逐奖励项状态,供运营核账。
|
||||||
|
func (s *Service) PageSettlementRecords(ctx context.Context, activityID int64, period, statDate, status string, page, size int) (map[string]any, error) {
|
||||||
|
if _, err := s.loadActivity(ctx, activityID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if size < 1 || size > 100 {
|
||||||
|
size = 20
|
||||||
|
}
|
||||||
|
query := s.db.WithContext(ctx).Model(&model.YumiGiftChallengeSettlement{}).Where("activity_id = ?", activityID)
|
||||||
|
if period = strings.ToUpper(strings.TrimSpace(period)); period != "" {
|
||||||
|
query = query.Where("period_type = ?", period)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(statDate) != "" {
|
||||||
|
query = query.Where("period_key = ?", strings.TrimSpace(statDate))
|
||||||
|
}
|
||||||
|
if status = strings.ToUpper(strings.TrimSpace(status)); status != "" {
|
||||||
|
query = query.Where("delivery_status = ?", status)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var rows []model.YumiGiftChallengeSettlement
|
||||||
|
if err := query.Order("create_time DESC, rank_no ASC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
records := make([]SettlementRecordView, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
var items []model.YumiGiftChallengeDeliveryItem
|
||||||
|
if err := s.db.WithContext(ctx).Where("owner_type = ? AND owner_id = ?", OwnerSettlement, row.ID).
|
||||||
|
Order("sort_order ASC, id ASC").Find(&items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
itemViews := make([]DeliveryItemView, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
itemViews = append(itemViews, DeliveryItemView{
|
||||||
|
ID: item.ID, OwnerType: item.OwnerType, OwnerID: item.OwnerID, RewardConfigID: item.RewardConfigID,
|
||||||
|
ResourceGroupID: item.ResourceGroupID, RewardType: item.RewardType, DetailType: item.DetailType,
|
||||||
|
Content: item.Content, Quantity: item.Quantity, SortOrder: item.SortOrder, Remark: item.Remark,
|
||||||
|
DeliveryStatus: item.DeliveryStatus, RetryCount: item.RetryCount,
|
||||||
|
FailureReason: item.FailureReason, DeliverTime: millis(item.DeliverTime),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
view := SettlementRecordView{
|
||||||
|
ID: row.ID, ActivityID: row.ActivityID, PeriodType: row.PeriodType, PeriodKey: row.PeriodKey,
|
||||||
|
UserID: row.UserID, Rank: row.RankNo, Score: row.Score, RankRewardID: row.RankRewardID,
|
||||||
|
ResourceGroupID: row.ResourceGroupID, BusinessNo: row.BusinessNo, DeliveryStatus: row.DeliveryStatus,
|
||||||
|
RetryCount: row.RetryCount, FailureReason: row.FailureReason, CreateTime: row.CreateTime.UnixMilli(),
|
||||||
|
DeliverTime: millis(row.DeliverTime), DeliveryItems: itemViews,
|
||||||
|
}
|
||||||
|
if row.StatDate != nil {
|
||||||
|
view.StatDate = *row.StatDate
|
||||||
|
}
|
||||||
|
records = append(records, view)
|
||||||
|
}
|
||||||
|
return map[string]any{"records": records, "total": total, "current": page, "size": size}, nil
|
||||||
|
}
|
||||||
241
internal/service/yumigiftchallenge/task.go
Normal file
241
internal/service/yumigiftchallenge/task.go
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
package yumigiftchallenge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
"chatapp3-golang/internal/utils"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enter 在活动时区创建当天任务快照,并幂等完成进入页面任务。
|
||||||
|
func (s *Service) Enter(ctx context.Context, user AuthUser, req EnterRequest) (*UserStateResponse, error) {
|
||||||
|
origin, err := s.requireYumiOrigin(user.SysOrigin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if user.UserID <= 0 {
|
||||||
|
return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "authenticated user is required")
|
||||||
|
}
|
||||||
|
activity, err := s.selectActivity(ctx, origin, req.ActivityID.Int64(), true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if activityStatus(*activity, time.Now()) != "ONGOING" {
|
||||||
|
return nil, NewAppError(http.StatusConflict, "activity_not_ongoing", "activity is not ongoing")
|
||||||
|
}
|
||||||
|
location, err := resolveLocation(activity.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
statDate := dateKey(time.Now(), location)
|
||||||
|
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var header model.YumiGiftChallengePeriodSettlement
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where(
|
||||||
|
"activity_id = ? AND period_type = ? AND period_key = ?", activity.ID, PeriodDaily, statDate,
|
||||||
|
).First(&header).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if header.Status != StatusNotStarted {
|
||||||
|
return NewAppError(http.StatusConflict, "daily_period_closed", "daily task period is already frozen")
|
||||||
|
}
|
||||||
|
if err := s.ensureTaskSnapshotsTx(tx, *activity, statDate, user.UserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
return tx.Model(&model.YumiGiftChallengeUserTaskDaily{}).
|
||||||
|
Where("activity_id = ? AND stat_date = ? AND user_id = ? AND task_type = ? AND completed_time IS NULL",
|
||||||
|
activity.ID, statDate, user.UserID, TaskEnterPage).
|
||||||
|
Updates(map[string]any{"progress_value": model.Decimal24_2("1.00"), "completed_time": now, "update_time": now}).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.buildUserState(ctx, *activity, statDate, user.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ensureTaskSnapshotsTx(tx *gorm.DB, activity model.YumiGiftChallengeActivity, statDate string, userID int64) error {
|
||||||
|
var configs []model.YumiGiftChallengeTaskConfig
|
||||||
|
if err := tx.Where("activity_id = ? AND enabled = ?", activity.ID, true).
|
||||||
|
Order("sort_order ASC, id ASC").Find(&configs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(configs) != 3 {
|
||||||
|
return NewAppError(http.StatusConflict, "invalid_activity_tasks", "enabled activity must contain exactly three tasks")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
for _, config := range configs {
|
||||||
|
id, err := utils.NextID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
row := model.YumiGiftChallengeUserTaskDaily{
|
||||||
|
ID: id, ActivityID: activity.ID, StatDate: statDate, UserID: userID,
|
||||||
|
TaskConfigID: config.ID, TaskCode: config.TaskCode, TaskType: config.TaskType,
|
||||||
|
TaskTitle: config.TaskTitle, SortOrder: config.SortOrder, TargetValue: config.TargetValue,
|
||||||
|
ResourceGroupID: config.ResourceGroupID,
|
||||||
|
BusinessNo: fmt.Sprintf("YUMI_GIFT_CHALLENGE:TASK:%d:%s:%d:%s", activity.ID, statDate, userID, config.TaskCode),
|
||||||
|
DeliveryStatus: DeliveryNotClaimed, CreateTime: now, UpdateTime: now,
|
||||||
|
}
|
||||||
|
// 活动、统计日、用户和 taskCode 唯一键同时承接页面重进和送礼并发,只有第一份任务配置成为当天快照。
|
||||||
|
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustLocation(value string) *time.Location {
|
||||||
|
location, err := time.LoadLocation(strings.TrimSpace(value))
|
||||||
|
if err != nil {
|
||||||
|
return time.UTC
|
||||||
|
}
|
||||||
|
return location
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimTask 先在本地事务抢占任务 parent 并物化 delivery item,再逐项调用外部发奖。
|
||||||
|
func (s *Service) ClaimTask(ctx context.Context, user AuthUser, taskCode string, req ClaimRequest) (*UserStateResponse, error) {
|
||||||
|
origin, err := s.requireYumiOrigin(user.SysOrigin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
taskCode = strings.TrimSpace(taskCode)
|
||||||
|
if !taskCodePattern.MatchString(taskCode) {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_task_code", "invalid taskCode")
|
||||||
|
}
|
||||||
|
requestID := strings.TrimSpace(req.RequestID)
|
||||||
|
if requestID == "" || len(requestID) > 128 {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_request_id", "requestId is required and max length is 128")
|
||||||
|
}
|
||||||
|
activity, err := s.selectActivity(ctx, origin, req.ActivityID.Int64(), true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if activityStatus(*activity, time.Now()) != "ONGOING" {
|
||||||
|
return nil, NewAppError(http.StatusConflict, "activity_not_ongoing", "activity is not ongoing")
|
||||||
|
}
|
||||||
|
location, err := resolveLocation(activity.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
statDate := dateKey(time.Now(), location)
|
||||||
|
var ownerID int64
|
||||||
|
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := s.ensureTaskSnapshotsTx(tx, *activity, statDate, user.UserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var task model.YumiGiftChallengeUserTaskDaily
|
||||||
|
queryErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Where("activity_id = ? AND stat_date = ? AND user_id = ? AND task_code = ?", activity.ID, statDate, user.UserID, taskCode).
|
||||||
|
First(&task).Error
|
||||||
|
if errors.Is(queryErr, gorm.ErrRecordNotFound) {
|
||||||
|
return NewAppError(http.StatusNotFound, "task_not_found", "task not found")
|
||||||
|
}
|
||||||
|
if queryErr != nil {
|
||||||
|
return queryErr
|
||||||
|
}
|
||||||
|
ownerID = task.ID
|
||||||
|
if task.CompletedTime == nil || task.ProgressValue.Compare(task.TargetValue) < 0 {
|
||||||
|
return NewAppError(http.StatusConflict, "task_not_completed", "task is not completed")
|
||||||
|
}
|
||||||
|
switch task.DeliveryStatus {
|
||||||
|
case DeliverySuccess:
|
||||||
|
return nil
|
||||||
|
case DeliveryUnknown:
|
||||||
|
return NewAppError(http.StatusConflict, "delivery_unknown", "reward result is unknown; contact support for reconciliation")
|
||||||
|
case DeliveryProcessing, DeliveryPending:
|
||||||
|
// 已经抢占的领取由同一 owner/item 幂等边界继续处理,重复 requestId 不会创建第二份奖励。
|
||||||
|
case DeliveryNotClaimed:
|
||||||
|
now := time.Now()
|
||||||
|
if err := tx.Model(&model.YumiGiftChallengeUserTaskDaily{}).Where("id = ?", task.ID).
|
||||||
|
Updates(map[string]any{"delivery_status": DeliveryPending, "claim_request_id": requestID, "claimed_time": now, "update_time": now}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return NewAppError(http.StatusConflict, "invalid_delivery_status", "task reward state is invalid")
|
||||||
|
}
|
||||||
|
return s.createDeliveryItemsTx(tx, OwnerTask, task.ID, activity.ID, user.UserID, task.ResourceGroupID)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ownerID > 0 {
|
||||||
|
if err := s.deliverOwnerItems(ctx, OwnerTask, ownerID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.buildUserState(ctx, *activity, statDate, user.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) buildUserState(ctx context.Context, activity model.YumiGiftChallengeActivity, statDate string, userID int64) (*UserStateResponse, error) {
|
||||||
|
var total model.YumiGiftChallengeUserScore
|
||||||
|
if err := s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", activity.ID, userID).First(&total).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var daily model.YumiGiftChallengeUserDailyScore
|
||||||
|
if err := s.db.WithContext(ctx).Where("activity_id = ? AND stat_date = ? AND user_id = ?", activity.ID, statDate, userID).First(&daily).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
overallRank, err := s.currentUserRank(ctx, activity.ID, nil, userID, total.Score, total.ScoreReachedTime)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dailyRank, err := s.currentUserRank(ctx, activity.ID, &statDate, userID, daily.Score, daily.ScoreReachedTime)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var tasks []model.YumiGiftChallengeUserTaskDaily
|
||||||
|
if err := s.db.WithContext(ctx).Where("activity_id = ? AND stat_date = ? AND user_id = ?", activity.ID, statDate, userID).
|
||||||
|
Order("sort_order ASC, id ASC").Find(&tasks).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
snapshots, err := s.loadRewardSnapshots(ctx, activity.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
views := make([]UserTaskView, 0, len(tasks))
|
||||||
|
for _, row := range tasks {
|
||||||
|
items := []RewardItemView{}
|
||||||
|
if row.ResourceGroupID != nil {
|
||||||
|
items = rewardItemViews(snapshots[*row.ResourceGroupID])
|
||||||
|
}
|
||||||
|
views = append(views, UserTaskView{
|
||||||
|
ID: row.ID, TaskConfigID: row.TaskConfigID, StatDate: row.StatDate,
|
||||||
|
TaskCode: row.TaskCode, TaskType: row.TaskType, TaskTitle: row.TaskTitle, SortOrder: row.SortOrder,
|
||||||
|
TargetValue: row.TargetValue, ProgressValue: row.ProgressValue,
|
||||||
|
Completed: row.CompletedTime != nil, CompletedTime: millis(row.CompletedTime), ResourceGroupID: row.ResourceGroupID,
|
||||||
|
DeliveryStatus: row.DeliveryStatus, ClaimedTime: millis(row.ClaimedTime), DeliverTime: millis(row.DeliverTime), RewardItems: items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &UserStateResponse{
|
||||||
|
ActivityID: activity.ID, StatDate: statDate,
|
||||||
|
TotalScore: total.Score, DailyScore: daily.Score, OverallRank: overallRank, DailyRank: dailyRank, Tasks: views,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) currentUserRank(ctx context.Context, activityID int64, statDate *string, userID int64, score model.Decimal24_2, reached time.Time) (*int, error) {
|
||||||
|
if !score.Positive() || reached.IsZero() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
query := s.db.WithContext(ctx)
|
||||||
|
if statDate == nil {
|
||||||
|
query = query.Model(&model.YumiGiftChallengeUserScore{}).Where("activity_id = ?", activityID)
|
||||||
|
} else {
|
||||||
|
query = query.Model(&model.YumiGiftChallengeUserDailyScore{}).Where("activity_id = ? AND stat_date = ?", activityID, *statDate)
|
||||||
|
}
|
||||||
|
err := query.Where("score > ? OR (score = ? AND score_reached_time < ?) OR (score = ? AND score_reached_time = ? AND user_id < ?)",
|
||||||
|
score, score, reached, score, reached, userID).Count(&count).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rank := int(count) + 1
|
||||||
|
return &rank, nil
|
||||||
|
}
|
||||||
448
internal/service/yumigiftchallenge/types.go
Normal file
448
internal/service/yumigiftchallenge/types.go
Normal file
@ -0,0 +1,448 @@
|
|||||||
|
package yumigiftchallenge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/common"
|
||||||
|
"chatapp3-golang/internal/config"
|
||||||
|
"chatapp3-golang/internal/integration"
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
|
||||||
|
rmq "github.com/apache/rocketmq-clients/golang/v5"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
PeriodDaily = "DAILY"
|
||||||
|
PeriodOverall = "OVERALL"
|
||||||
|
|
||||||
|
TaskEnterPage = "ENTER_PAGE"
|
||||||
|
TaskSendGiftGold = "SEND_GIFT_GOLD"
|
||||||
|
|
||||||
|
StatusNotStarted = "NOT_STARTED"
|
||||||
|
StatusProcessing = "PROCESSING"
|
||||||
|
StatusCompleted = "COMPLETED"
|
||||||
|
StatusReconciliationRequired = "RECONCILIATION_REQUIRED"
|
||||||
|
|
||||||
|
DeliveryNotClaimed = "NOT_CLAIMED"
|
||||||
|
DeliveryPending = "PENDING"
|
||||||
|
DeliveryProcessing = "PROCESSING"
|
||||||
|
DeliverySuccess = "SUCCESS"
|
||||||
|
DeliveryUnknown = "UNKNOWN"
|
||||||
|
|
||||||
|
OwnerTask = "TASK"
|
||||||
|
OwnerSettlement = "SETTLEMENT"
|
||||||
|
|
||||||
|
// Yumi 客户端的业务来源仍是 Java SysOriginPlatformEnum.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()
|
||||||
|
}
|
||||||
221
migrations/058_yumi_game_king.sql
Normal file
221
migrations/058_yumi_game_king.sql
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
-- Yumi Game King: canonical GAME_CONSUME_GOLD -> draw chances, deterministic ranking and settlement.
|
||||||
|
--
|
||||||
|
-- Performance review:
|
||||||
|
-- 1. The event path first resolves the very small set of enabled activities by
|
||||||
|
-- (sys_origin, enabled, start_time, end_time), then inserts a unique activity/event ledger row
|
||||||
|
-- and updates one aggregate primary key. It never scans wallet shards or arbitrary expenses.
|
||||||
|
-- 2. Ranking reads the materialized aggregate through
|
||||||
|
-- (activity_id, total_consumed DESC, consume_reached_time ASC, user_id ASC); settlement freezes
|
||||||
|
-- only Top 30 and does not GROUP BY the append-only ledger.
|
||||||
|
-- 3. Draw idempotency, settlement idempotency, finite-stock reservation and reward delivery claims
|
||||||
|
-- all use primary/unique keys. Unlimited stock (-1) is not updated and therefore avoids a hot row.
|
||||||
|
-- 4. Backfill binds sys_origin + GAME_CONSUME_GOLD and keysets by (occurred_at,id) through the added
|
||||||
|
-- index. The public lastId is resolved to its scoped occurred_at before each page, preserving the
|
||||||
|
-- lastId-only contract without sorting/rescanning the whole activity window.
|
||||||
|
-- 5. The activity tables are empty, but the backfill index targets the existing production
|
||||||
|
-- task_center_event_archive_outbox (about 35 million rows / 50 GiB at review time). Treat that ALTER
|
||||||
|
-- as an independent online DDL: run it before the application rollout, monitor metadata-lock waits,
|
||||||
|
-- temporary disk usage and replica lag until completion, and do not hide it inside a service restart.
|
||||||
|
-- 6. The ALTER explicitly requires ALGORITHM=INPLACE and LOCK=NONE. If the target MySQL version or table
|
||||||
|
-- state cannot honor those guarantees, the migration must fail instead of silently falling back to a
|
||||||
|
-- table-copy or write-blocking algorithm. It performs no UPDATE/DELETE/backfill.
|
||||||
|
-- 7. The one-row-per-sysOrigin scope lock is touched only by admin enable/save transactions; it keeps
|
||||||
|
-- concurrent overlap checks correct without serializing the high-volume game event path.
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `yumi_game_king_activity` (
|
||||||
|
`id` BIGINT NOT NULL,
|
||||||
|
`activity_code` VARCHAR(64) NOT NULL,
|
||||||
|
`activity_name` VARCHAR(128) NOT NULL,
|
||||||
|
`activity_desc` VARCHAR(1000) NOT NULL DEFAULT '',
|
||||||
|
`sys_origin` VARCHAR(32) NOT NULL,
|
||||||
|
`time_zone` VARCHAR(64) NOT NULL DEFAULT 'Asia/Riyadh',
|
||||||
|
`start_time` DATETIME(3) NOT NULL,
|
||||||
|
`end_time` DATETIME(3) NOT NULL,
|
||||||
|
`settlement_delay_minutes` INT NOT NULL DEFAULT 30,
|
||||||
|
`settlement_time` DATETIME(3) NOT NULL,
|
||||||
|
`coin_per_draw` BIGINT NOT NULL,
|
||||||
|
`ranking_type` VARCHAR(32) NOT NULL DEFAULT 'TYCOON',
|
||||||
|
`ranking_period` VARCHAR(32) NOT NULL DEFAULT 'OVERALL',
|
||||||
|
`enabled` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`settlement_status` VARCHAR(32) NOT NULL DEFAULT 'NOT_STARTED',
|
||||||
|
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_yumi_gk_activity_code` (`activity_code`),
|
||||||
|
KEY `idx_yumi_gk_activity_window` (`sys_origin`, `enabled`, `start_time`, `end_time`),
|
||||||
|
KEY `idx_yumi_gk_activity_settle` (`settlement_status`, `settlement_time`, `end_time`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Yumi Game King activity';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `yumi_game_king_scope_lock` (
|
||||||
|
`sys_origin` VARCHAR(32) NOT NULL,
|
||||||
|
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`sys_origin`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Serializes enabled-window checks per Yumi tenant';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `yumi_game_king_prize` (
|
||||||
|
`id` BIGINT NOT NULL,
|
||||||
|
`activity_id` BIGINT NOT NULL,
|
||||||
|
`prize_name` VARCHAR(128) NOT NULL,
|
||||||
|
`prize_image` VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
`weight` BIGINT NOT NULL,
|
||||||
|
`stock` BIGINT NOT NULL DEFAULT -1 COMMENT '-1 unlimited, 0 depleted, positive remaining',
|
||||||
|
`resource_group_id` BIGINT NOT NULL,
|
||||||
|
`reward_group_name` VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
`reward_items_json` TEXT NOT NULL,
|
||||||
|
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`sort_order` TINYINT NOT NULL,
|
||||||
|
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_yumi_gk_prize_position` (`activity_id`, `sort_order`),
|
||||||
|
KEY `idx_yumi_gk_prize_enabled` (`activity_id`, `enabled`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Seven fixed Yumi Game King prizes';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `yumi_game_king_rank_reward` (
|
||||||
|
`id` BIGINT NOT NULL,
|
||||||
|
`activity_id` BIGINT NOT NULL,
|
||||||
|
`start_rank` SMALLINT NOT NULL,
|
||||||
|
`end_rank` SMALLINT NOT NULL,
|
||||||
|
`resource_group_id` BIGINT NOT NULL,
|
||||||
|
`reward_name` VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
`reward_items_json` TEXT NOT NULL,
|
||||||
|
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_yumi_gk_rank_range` (`activity_id`, `start_rank`, `end_rank`),
|
||||||
|
KEY `idx_yumi_gk_rank_start` (`activity_id`, `start_rank`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Six fixed end-settlement rank reward ranges';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `yumi_game_king_consume_ledger` (
|
||||||
|
`id` BIGINT NOT NULL,
|
||||||
|
`activity_id` BIGINT NOT NULL,
|
||||||
|
`event_id` VARCHAR(128) NOT NULL,
|
||||||
|
`user_id` BIGINT NOT NULL,
|
||||||
|
`sys_origin` VARCHAR(32) NOT NULL,
|
||||||
|
`amount` BIGINT NOT NULL,
|
||||||
|
`event_time` DATETIME(3) NOT NULL,
|
||||||
|
`payload_json` TEXT NOT NULL,
|
||||||
|
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_yumi_gk_ledger_event` (`activity_id`, `event_id`),
|
||||||
|
KEY `idx_yumi_gk_ledger_user` (`activity_id`, `user_id`, `event_time`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Idempotent canonical game-consumption ledger';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `yumi_game_king_user` (
|
||||||
|
`activity_id` BIGINT NOT NULL,
|
||||||
|
`user_id` BIGINT NOT NULL,
|
||||||
|
`total_consumed` BIGINT NOT NULL DEFAULT 0,
|
||||||
|
`used_chances` BIGINT NOT NULL DEFAULT 0,
|
||||||
|
`consume_reached_time` DATETIME(3) NOT NULL,
|
||||||
|
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`activity_id`, `user_id`),
|
||||||
|
KEY `idx_yumi_gk_user_rank` (
|
||||||
|
`activity_id`, `total_consumed` DESC, `consume_reached_time` ASC, `user_id` ASC
|
||||||
|
)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Overall game-consumption aggregate and draw chance usage';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `yumi_game_king_user_daily` (
|
||||||
|
`activity_id` BIGINT NOT NULL,
|
||||||
|
`stat_date` DATE NOT NULL,
|
||||||
|
`user_id` BIGINT NOT NULL,
|
||||||
|
`total_consumed` BIGINT NOT NULL DEFAULT 0,
|
||||||
|
`consume_reached_time` DATETIME(3) NOT NULL,
|
||||||
|
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`activity_id`, `stat_date`, `user_id`),
|
||||||
|
KEY `idx_yumi_gk_daily_rank` (
|
||||||
|
`activity_id`, `stat_date`, `total_consumed` DESC, `consume_reached_time` ASC, `user_id` ASC
|
||||||
|
)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Activity-timezone daily game-consumption aggregate';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `yumi_game_king_draw_record` (
|
||||||
|
`id` BIGINT NOT NULL,
|
||||||
|
`activity_id` BIGINT NOT NULL,
|
||||||
|
`user_id` BIGINT NOT NULL,
|
||||||
|
`request_id` VARCHAR(64) NOT NULL,
|
||||||
|
`prize_id` BIGINT NOT NULL,
|
||||||
|
`prize_name` VARCHAR(128) NOT NULL,
|
||||||
|
`prize_image` VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
`resource_group_id` BIGINT NOT NULL,
|
||||||
|
`delivery_status` VARCHAR(32) NOT NULL,
|
||||||
|
`retry_count` INT NOT NULL DEFAULT 0,
|
||||||
|
`failure_reason` VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
`draw_time` DATETIME(3) NOT NULL,
|
||||||
|
`deliver_time` DATETIME(3) NULL,
|
||||||
|
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_yumi_gk_draw_request` (`activity_id`, `user_id`, `request_id`),
|
||||||
|
KEY `idx_yumi_gk_draw_user` (`activity_id`, `user_id`, `create_time`),
|
||||||
|
KEY `idx_yumi_gk_draw_manage` (`activity_id`, `delivery_status`, `id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Idempotent Yumi Game King draw records';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `yumi_game_king_settlement_record` (
|
||||||
|
`id` BIGINT NOT NULL,
|
||||||
|
`activity_id` BIGINT NOT NULL,
|
||||||
|
`user_id` BIGINT NOT NULL,
|
||||||
|
`rank_no` SMALLINT NOT NULL,
|
||||||
|
`total_consumed` BIGINT NOT NULL,
|
||||||
|
`rank_reward_id` BIGINT NOT NULL,
|
||||||
|
`resource_group_id` BIGINT NOT NULL,
|
||||||
|
`business_no` VARCHAR(128) NOT NULL,
|
||||||
|
`delivery_status` VARCHAR(32) NOT NULL,
|
||||||
|
`retry_count` INT NOT NULL DEFAULT 0,
|
||||||
|
`failure_reason` VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
`deliver_time` DATETIME(3) NULL,
|
||||||
|
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_yumi_gk_settle_user` (`activity_id`, `user_id`),
|
||||||
|
UNIQUE KEY `uk_yumi_gk_settle_rank` (`activity_id`, `rank_no`),
|
||||||
|
UNIQUE KEY `uk_yumi_gk_settle_business` (`business_no`),
|
||||||
|
KEY `idx_yumi_gk_settle_status` (`activity_id`, `delivery_status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Frozen deterministic Top30 settlement records';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `yumi_game_king_delivery_item` (
|
||||||
|
`id` BIGINT NOT NULL,
|
||||||
|
`owner_type` VARCHAR(20) NOT NULL COMMENT 'DRAW or SETTLEMENT',
|
||||||
|
`owner_id` BIGINT NOT NULL,
|
||||||
|
`activity_id` BIGINT NOT NULL,
|
||||||
|
`user_id` BIGINT NOT NULL,
|
||||||
|
`resource_group_id` BIGINT NOT NULL,
|
||||||
|
`delivery_status` VARCHAR(32) NOT NULL,
|
||||||
|
`retry_count` INT NOT NULL DEFAULT 0,
|
||||||
|
`failure_reason` VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
`deliver_time` DATETIME(3) NULL,
|
||||||
|
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_yumi_gk_delivery_owner` (`owner_type`, `owner_id`),
|
||||||
|
KEY `idx_yumi_gk_delivery_status` (`delivery_status`, `update_time`, `id`),
|
||||||
|
KEY `idx_yumi_gk_delivery_activity` (`activity_id`, `delivery_status`, `id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Group-level reward delivery and UNKNOWN reconciliation';
|
||||||
|
|
||||||
|
SET @current_schema := DATABASE();
|
||||||
|
SET @add_task_center_game_king_backfill_sql := IF(
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM INFORMATION_SCHEMA.STATISTICS
|
||||||
|
WHERE table_schema = @current_schema
|
||||||
|
AND table_name = 'task_center_event_archive_outbox'
|
||||||
|
AND index_name = 'idx_task_center_game_king_backfill'
|
||||||
|
),
|
||||||
|
'ALTER TABLE `task_center_event_archive_outbox` ADD INDEX `idx_task_center_game_king_backfill` (`sys_origin`, `event_type`, `occurred_at`, `id`), ALGORITHM=INPLACE, LOCK=NONE',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE add_task_center_game_king_backfill_stmt FROM @add_task_center_game_king_backfill_sql;
|
||||||
|
EXECUTE add_task_center_game_king_backfill_stmt;
|
||||||
|
DEALLOCATE PREPARE add_task_center_game_king_backfill_stmt;
|
||||||
243
migrations/059_yumi_gift_challenge.sql
Normal file
243
migrations/059_yumi_gift_challenge.sql
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
-- Yumi Gift Challenge: ordinary GOLD gift ledger, daily/overall ranking, three daily tasks,
|
||||||
|
-- frozen reward templates and two-phase item-level settlement.
|
||||||
|
--
|
||||||
|
-- Performance review:
|
||||||
|
-- 1. The MQ hot path resolves at most one enabled activity through
|
||||||
|
-- (sys_origin, enabled, start_time, end_time), deduplicates by a unique event key, and updates
|
||||||
|
-- one overall plus one daily aggregate primary key. Ranking never GROUP BYs the gift ledger.
|
||||||
|
-- 2. Daily/overall ranks use covering order indexes matching
|
||||||
|
-- score DESC, score_reached_time ASC, user_id ASC; user-rank counts remain activity/date scoped.
|
||||||
|
-- 3. Settlement discovery uses (status, snapshot_due_time), while parent and delivery claims use
|
||||||
|
-- unique owner/business keys and narrow owner/status indexes. UNKNOWN items are never auto-retried.
|
||||||
|
-- 4. This migration creates empty tables only: no UPDATE, DELETE, backfill, table-copy ALTER, or
|
||||||
|
-- unbounded scan is executed. Run DDL under the target database's normal online-DDL policy.
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `yumi_gift_challenge_activity` (
|
||||||
|
`id` BIGINT NOT NULL,
|
||||||
|
`activity_code` VARCHAR(64) NOT NULL,
|
||||||
|
`activity_name` VARCHAR(128) NOT NULL,
|
||||||
|
`activity_desc` VARCHAR(1000) NOT NULL DEFAULT '',
|
||||||
|
`sys_origin` VARCHAR(32) NOT NULL,
|
||||||
|
`time_zone` VARCHAR(64) NOT NULL DEFAULT 'Asia/Riyadh',
|
||||||
|
`start_time` DATETIME(3) NOT NULL,
|
||||||
|
`end_time` DATETIME(3) NOT NULL,
|
||||||
|
`daily_settlement_delay_minutes` INT NOT NULL DEFAULT 30,
|
||||||
|
`overall_settlement_delay_minutes` INT NOT NULL DEFAULT 30,
|
||||||
|
`overall_settlement_time` DATETIME(3) NOT NULL,
|
||||||
|
`display_top_n` INT NOT NULL DEFAULT 100,
|
||||||
|
`enabled` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`overall_settlement_status` VARCHAR(32) NOT NULL DEFAULT 'NOT_STARTED',
|
||||||
|
`version` INT NOT NULL DEFAULT 0,
|
||||||
|
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_yumi_gc_activity_code` (`activity_code`),
|
||||||
|
KEY `idx_yumi_gc_activity_window` (`sys_origin`, `enabled`, `start_time`, `end_time`),
|
||||||
|
KEY `idx_yumi_gc_activity_settle` (`overall_settlement_status`, `overall_settlement_time`, `id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Yumi gift challenge activity cycle';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `yumi_gift_challenge_task_config` (
|
||||||
|
`id` BIGINT NOT NULL,
|
||||||
|
`activity_id` BIGINT NOT NULL,
|
||||||
|
`task_code` VARCHAR(64) NOT NULL,
|
||||||
|
`task_type` VARCHAR(32) NOT NULL,
|
||||||
|
`task_title` VARCHAR(128) NOT NULL,
|
||||||
|
`task_desc` VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
`target_value` DECIMAL(24,2) NOT NULL,
|
||||||
|
`resource_group_id` BIGINT NULL,
|
||||||
|
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`sort_order` TINYINT NOT NULL,
|
||||||
|
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_yumi_gc_task_code` (`activity_id`, `task_code`),
|
||||||
|
UNIQUE KEY `uk_yumi_gc_task_sort` (`activity_id`, `sort_order`),
|
||||||
|
KEY `idx_yumi_gc_task_enabled` (`activity_id`, `enabled`, `sort_order`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Exactly three Yumi gift challenge daily tasks';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `yumi_gift_challenge_rank_reward` (
|
||||||
|
`id` BIGINT NOT NULL,
|
||||||
|
`activity_id` BIGINT NOT NULL,
|
||||||
|
`period_type` VARCHAR(16) NOT NULL,
|
||||||
|
`start_rank` INT NOT NULL,
|
||||||
|
`end_rank` INT NOT NULL,
|
||||||
|
`resource_group_id` BIGINT NOT NULL,
|
||||||
|
`reward_name` VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_yumi_gc_rank_range` (`activity_id`, `period_type`, `start_rank`, `end_rank`),
|
||||||
|
KEY `idx_yumi_gc_rank_match` (`activity_id`, `period_type`, `start_rank`, `end_rank`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Configurable daily and overall rank reward ranges';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `yumi_gift_challenge_reward_snapshot` (
|
||||||
|
`id` BIGINT NOT NULL,
|
||||||
|
`activity_id` BIGINT NOT NULL,
|
||||||
|
`resource_group_id` BIGINT NOT NULL,
|
||||||
|
`reward_config_id` BIGINT NOT NULL,
|
||||||
|
`reward_type` VARCHAR(64) NOT NULL,
|
||||||
|
`detail_type` VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
`content` TEXT NOT NULL,
|
||||||
|
`quantity` BIGINT NOT NULL,
|
||||||
|
`sort_order` INT NOT NULL DEFAULT 0,
|
||||||
|
`remark` VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
`cover` TEXT NOT NULL,
|
||||||
|
`source_url` TEXT NOT NULL,
|
||||||
|
`display_name` VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_yumi_gc_snapshot_item` (`activity_id`, `resource_group_id`, `reward_config_id`),
|
||||||
|
KEY `idx_yumi_gc_snapshot_group` (`activity_id`, `resource_group_id`, `sort_order`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Full reward item template frozen when activity is enabled';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `yumi_gift_challenge_gift_ledger` (
|
||||||
|
`id` BIGINT NOT NULL,
|
||||||
|
`activity_id` BIGINT NOT NULL,
|
||||||
|
`source_event_track_id` VARCHAR(128) NOT NULL,
|
||||||
|
`user_id` BIGINT NOT NULL,
|
||||||
|
`gift_id` BIGINT NOT NULL,
|
||||||
|
`gift_tab` VARCHAR(32) NOT NULL,
|
||||||
|
`amount` DECIMAL(24,2) NOT NULL,
|
||||||
|
`event_time` DATETIME(3) NOT NULL,
|
||||||
|
`stat_date` DATE NOT NULL,
|
||||||
|
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_yumi_gc_ledger_event` (`activity_id`, `source_event_track_id`),
|
||||||
|
KEY `idx_yumi_gc_ledger_user` (`activity_id`, `user_id`, `event_time`),
|
||||||
|
KEY `idx_yumi_gc_ledger_date` (`activity_id`, `stat_date`, `event_time`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Idempotent ordinary GOLD gift activity ledger';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `yumi_gift_challenge_user_score` (
|
||||||
|
`activity_id` BIGINT NOT NULL,
|
||||||
|
`user_id` BIGINT NOT NULL,
|
||||||
|
`score` DECIMAL(24,2) NOT NULL DEFAULT 0.00,
|
||||||
|
`score_reached_time` DATETIME(3) NOT NULL,
|
||||||
|
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`activity_id`, `user_id`),
|
||||||
|
KEY `idx_yumi_gc_overall_rank` (`activity_id`, `score` DESC, `score_reached_time` ASC, `user_id` ASC)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Materialized overall gift challenge score';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `yumi_gift_challenge_user_daily_score` (
|
||||||
|
`activity_id` BIGINT NOT NULL,
|
||||||
|
`stat_date` DATE NOT NULL,
|
||||||
|
`user_id` BIGINT NOT NULL,
|
||||||
|
`score` DECIMAL(24,2) NOT NULL DEFAULT 0.00,
|
||||||
|
`score_reached_time` DATETIME(3) NOT NULL,
|
||||||
|
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`activity_id`, `stat_date`, `user_id`),
|
||||||
|
KEY `idx_yumi_gc_daily_rank` (`activity_id`, `stat_date`, `score` DESC, `score_reached_time` ASC, `user_id` ASC)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Materialized activity-timezone daily gift challenge score';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `yumi_gift_challenge_user_task_daily` (
|
||||||
|
`id` BIGINT NOT NULL,
|
||||||
|
`activity_id` BIGINT NOT NULL,
|
||||||
|
`stat_date` DATE NOT NULL,
|
||||||
|
`user_id` BIGINT NOT NULL,
|
||||||
|
`task_config_id` BIGINT NOT NULL,
|
||||||
|
`task_code` VARCHAR(64) NOT NULL,
|
||||||
|
`task_type` VARCHAR(32) NOT NULL,
|
||||||
|
`task_title` VARCHAR(128) NOT NULL,
|
||||||
|
`sort_order` TINYINT NOT NULL,
|
||||||
|
`target_value` DECIMAL(24,2) NOT NULL,
|
||||||
|
`progress_value` DECIMAL(24,2) NOT NULL DEFAULT 0.00,
|
||||||
|
`completed_time` DATETIME(3) NULL,
|
||||||
|
`resource_group_id` BIGINT NULL,
|
||||||
|
`business_no` VARCHAR(160) NOT NULL,
|
||||||
|
`claim_request_id` VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
`delivery_status` VARCHAR(32) NOT NULL DEFAULT 'NOT_CLAIMED',
|
||||||
|
`retry_count` INT NOT NULL DEFAULT 0,
|
||||||
|
`failure_reason` VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
`claimed_time` DATETIME(3) NULL,
|
||||||
|
`deliver_time` DATETIME(3) NULL,
|
||||||
|
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_yumi_gc_user_task` (`activity_id`, `stat_date`, `user_id`, `task_code`),
|
||||||
|
UNIQUE KEY `uk_yumi_gc_task_business` (`business_no`),
|
||||||
|
KEY `idx_yumi_gc_user_task_page` (`activity_id`, `stat_date`, `user_id`, `sort_order`),
|
||||||
|
KEY `idx_yumi_gc_task_delivery` (`delivery_status`, `update_time`, `id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Frozen per-user per-day task state and reward owner';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `yumi_gift_challenge_period_settlement` (
|
||||||
|
`id` BIGINT NOT NULL,
|
||||||
|
`activity_id` BIGINT NOT NULL,
|
||||||
|
`period_type` VARCHAR(16) NOT NULL,
|
||||||
|
`period_key` VARCHAR(16) NOT NULL,
|
||||||
|
`stat_date` DATE NULL,
|
||||||
|
`snapshot_due_time` DATETIME(3) NOT NULL,
|
||||||
|
`status` VARCHAR(32) NOT NULL DEFAULT 'NOT_STARTED',
|
||||||
|
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_yumi_gc_period` (`activity_id`, `period_type`, `period_key`),
|
||||||
|
KEY `idx_yumi_gc_period_due` (`status`, `snapshot_due_time`, `id`),
|
||||||
|
KEY `idx_yumi_gc_period_activity` (`activity_id`, `status`, `period_type`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Daily and overall write gates including empty periods';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `yumi_gift_challenge_settlement` (
|
||||||
|
`id` BIGINT NOT NULL,
|
||||||
|
`activity_id` BIGINT NOT NULL,
|
||||||
|
`period_type` VARCHAR(16) NOT NULL,
|
||||||
|
`period_key` VARCHAR(16) NOT NULL,
|
||||||
|
`stat_date` DATE NULL,
|
||||||
|
`user_id` BIGINT NOT NULL,
|
||||||
|
`rank_no` INT NOT NULL,
|
||||||
|
`score` DECIMAL(24,2) NOT NULL,
|
||||||
|
`rank_reward_id` BIGINT NOT NULL,
|
||||||
|
`resource_group_id` BIGINT NOT NULL,
|
||||||
|
`business_no` VARCHAR(180) NOT NULL,
|
||||||
|
`delivery_status` VARCHAR(32) NOT NULL DEFAULT 'PENDING',
|
||||||
|
`retry_count` INT NOT NULL DEFAULT 0,
|
||||||
|
`failure_reason` VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
`deliver_time` DATETIME(3) NULL,
|
||||||
|
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_yumi_gc_settle_user` (`activity_id`, `period_type`, `period_key`, `user_id`),
|
||||||
|
UNIQUE KEY `uk_yumi_gc_settle_rank` (`activity_id`, `period_type`, `period_key`, `rank_no`),
|
||||||
|
UNIQUE KEY `uk_yumi_gc_settle_business` (`business_no`),
|
||||||
|
KEY `idx_yumi_gc_settle_status` (`activity_id`, `period_type`, `period_key`, `delivery_status`, `rank_no`),
|
||||||
|
KEY `idx_yumi_gc_settle_recovery` (`delivery_status`, `update_time`, `id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Frozen rank winner parent records';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `yumi_gift_challenge_delivery_item` (
|
||||||
|
`id` BIGINT NOT NULL,
|
||||||
|
`owner_type` VARCHAR(16) NOT NULL COMMENT 'TASK or SETTLEMENT',
|
||||||
|
`owner_id` BIGINT NOT NULL,
|
||||||
|
`activity_id` BIGINT NOT NULL,
|
||||||
|
`user_id` BIGINT NOT NULL,
|
||||||
|
`reward_config_id` BIGINT NOT NULL,
|
||||||
|
`resource_group_id` BIGINT NOT NULL,
|
||||||
|
`reward_type` VARCHAR(64) NOT NULL,
|
||||||
|
`detail_type` VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
`content` TEXT NOT NULL,
|
||||||
|
`quantity` BIGINT NOT NULL,
|
||||||
|
`sort_order` INT NOT NULL DEFAULT 0,
|
||||||
|
`remark` VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
`delivery_status` VARCHAR(32) NOT NULL DEFAULT 'PENDING',
|
||||||
|
`retry_count` INT NOT NULL DEFAULT 0,
|
||||||
|
`failure_reason` VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
`deliver_time` DATETIME(3) NULL,
|
||||||
|
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_yumi_gc_delivery_item` (`owner_type`, `owner_id`, `reward_config_id`),
|
||||||
|
KEY `idx_yumi_gc_delivery_claim` (`owner_type`, `owner_id`, `delivery_status`, `sort_order`, `id`),
|
||||||
|
KEY `idx_yumi_gc_delivery_stale` (`delivery_status`, `update_time`, `id`),
|
||||||
|
KEY `idx_yumi_gc_delivery_activity` (`activity_id`, `delivery_status`, `id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Item-level idempotent frozen reward delivery';
|
||||||
Loading…
x
Reference in New Issue
Block a user