Compare commits
No commits in common. "main" and "test" have entirely different histories.
@ -18,11 +18,9 @@ import (
|
|||||||
"chatapp3-golang/internal/service/cprelationbroadcast"
|
"chatapp3-golang/internal/service/cprelationbroadcast"
|
||||||
"chatapp3-golang/internal/service/errorlog"
|
"chatapp3-golang/internal/service/errorlog"
|
||||||
"chatapp3-golang/internal/service/firstrechargereward"
|
"chatapp3-golang/internal/service/firstrechargereward"
|
||||||
"chatapp3-golang/internal/service/gameking"
|
|
||||||
"chatapp3-golang/internal/service/gameopen"
|
"chatapp3-golang/internal/service/gameopen"
|
||||||
"chatapp3-golang/internal/service/gameprovider"
|
"chatapp3-golang/internal/service/gameprovider"
|
||||||
"chatapp3-golang/internal/service/hostcenter"
|
"chatapp3-golang/internal/service/hostcenter"
|
||||||
"chatapp3-golang/internal/service/hotgame"
|
|
||||||
"chatapp3-golang/internal/service/invite"
|
"chatapp3-golang/internal/service/invite"
|
||||||
"chatapp3-golang/internal/service/lingxian"
|
"chatapp3-golang/internal/service/lingxian"
|
||||||
"chatapp3-golang/internal/service/luckygift"
|
"chatapp3-golang/internal/service/luckygift"
|
||||||
@ -42,7 +40,6 @@ import (
|
|||||||
"chatapp3-golang/internal/service/voiceroomrocket"
|
"chatapp3-golang/internal/service/voiceroomrocket"
|
||||||
"chatapp3-golang/internal/service/weekstar"
|
"chatapp3-golang/internal/service/weekstar"
|
||||||
"chatapp3-golang/internal/service/wheel"
|
"chatapp3-golang/internal/service/wheel"
|
||||||
"chatapp3-golang/internal/service/yumigiftchallenge"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@ -60,7 +57,6 @@ func main() {
|
|||||||
baishunService := baishun.NewBaishunService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
baishunService := baishun.NewBaishunService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||||
binanceRechargeService := binancerecharge.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
binanceRechargeService := binancerecharge.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
lingxianService := lingxian.NewService(app.Config, app.Repository.DB, app.Repository.Redis)
|
lingxianService := lingxian.NewService(app.Config, app.Repository.DB, app.Repository.Redis)
|
||||||
hotgameService := hotgame.NewService(app.Config, app.Repository.DB, app.Repository.Redis)
|
|
||||||
gameOpenService := gameopen.NewGameOpenService(app.Config, app.Repository.DB, &app.Gateways)
|
gameOpenService := gameopen.NewGameOpenService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet)
|
luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet)
|
||||||
rechargeAgencyService := rechargeagency.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
rechargeAgencyService := rechargeagency.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
@ -69,7 +65,6 @@ func main() {
|
|||||||
firstRechargeRewardService := firstrechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
firstRechargeRewardService := firstrechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||||
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
gameKingService := gameking.NewService(app.Repository.DB, &app.Gateways)
|
|
||||||
userBadgeService := userbadge.NewService(app.Repository.DB)
|
userBadgeService := userbadge.NewService(app.Repository.DB)
|
||||||
vipService := vip.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
vipService := vip.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
wheelService := wheel.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
wheelService := wheel.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
@ -83,7 +78,6 @@ func main() {
|
|||||||
voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||||
voiceRoomRocketService.SetRegionBroadcaster(regionIMGroupService)
|
voiceRoomRocketService.SetRegionBroadcaster(regionIMGroupService)
|
||||||
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||||
yumiGiftChallengeService := yumigiftchallenge.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
|
||||||
roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
hostCenterService := hostcenter.NewService(app.Config, &app.Gateways)
|
hostCenterService := hostcenter.NewService(app.Config, &app.Gateways)
|
||||||
managerCenterService := managercenter.NewService(app.Repository.DB, &app.Gateways)
|
managerCenterService := managercenter.NewService(app.Repository.DB, &app.Gateways)
|
||||||
@ -92,8 +86,6 @@ func main() {
|
|||||||
baishunService.SetTaskEventReporter(taskCenterService)
|
baishunService.SetTaskEventReporter(taskCenterService)
|
||||||
gameOpenService.SetTaskEventReporter(taskCenterService)
|
gameOpenService.SetTaskEventReporter(taskCenterService)
|
||||||
weekStarService.SetTaskEventReporter(taskCenterService)
|
weekStarService.SetTaskEventReporter(taskCenterService)
|
||||||
// GAME_CONSUME_GOLD 在任务中心完成统一校验后扇出;HTTP 与 MQ 重投由活动账本幂等。
|
|
||||||
taskCenterService.SetGameConsumeSink(gameKingService)
|
|
||||||
|
|
||||||
workerCtx, workerCancel := context.WithCancel(context.Background())
|
workerCtx, workerCancel := context.WithCancel(context.Background())
|
||||||
defer workerCancel()
|
defer workerCancel()
|
||||||
@ -103,9 +95,6 @@ func main() {
|
|||||||
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
|
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
|
||||||
log.Fatalf("start week star message consumer failed: %v", err)
|
log.Fatalf("start week star message consumer failed: %v", err)
|
||||||
}
|
}
|
||||||
if err := yumiGiftChallengeService.StartMessageConsumer(workerCtx); err != nil {
|
|
||||||
log.Fatalf("start yumi gift challenge message consumer failed: %v", err)
|
|
||||||
}
|
|
||||||
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
|
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
|
||||||
log.Fatalf("start task center message consumer failed: %v", err)
|
log.Fatalf("start task center message consumer failed: %v", err)
|
||||||
}
|
}
|
||||||
@ -126,9 +115,7 @@ func main() {
|
|||||||
gameProviders := gameprovider.NewRegistry(
|
gameProviders := gameprovider.NewRegistry(
|
||||||
baishun.NewAppProvider(baishunService),
|
baishun.NewAppProvider(baishunService),
|
||||||
lingxian.NewAppProvider(lingxianService),
|
lingxian.NewAppProvider(lingxianService),
|
||||||
hotgame.NewAppProvider(hotgameService),
|
|
||||||
)
|
)
|
||||||
gameVisibility := gameprovider.NewVisibilityPolicy(&app.Gateways)
|
|
||||||
engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{
|
engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{
|
||||||
AppPopup: appPopupService,
|
AppPopup: appPopupService,
|
||||||
ErrorLog: errorLogService,
|
ErrorLog: errorLogService,
|
||||||
@ -138,14 +125,11 @@ func main() {
|
|||||||
FirstRechargeReward: firstRechargeRewardService,
|
FirstRechargeReward: firstRechargeRewardService,
|
||||||
Lingxian: lingxianService,
|
Lingxian: lingxianService,
|
||||||
GameOpen: gameOpenService,
|
GameOpen: gameOpenService,
|
||||||
GameKing: gameKingService,
|
|
||||||
GameProviders: gameProviders,
|
GameProviders: gameProviders,
|
||||||
GameVisibility: gameVisibility,
|
|
||||||
LuckyGift: luckyGiftService,
|
LuckyGift: luckyGiftService,
|
||||||
ManagerCenter: managerCenterService,
|
ManagerCenter: managerCenterService,
|
||||||
PropsStore: propsStoreService,
|
PropsStore: propsStoreService,
|
||||||
HostCenter: hostCenterService,
|
HostCenter: hostCenterService,
|
||||||
Hotgame: hotgameService,
|
|
||||||
RechargeAgency: rechargeAgencyService,
|
RechargeAgency: rechargeAgencyService,
|
||||||
RechargeReward: rechargeRewardService,
|
RechargeReward: rechargeRewardService,
|
||||||
RegionIMGroup: regionIMGroupService,
|
RegionIMGroup: regionIMGroupService,
|
||||||
@ -160,7 +144,6 @@ func main() {
|
|||||||
VoiceRoomRedPacket: voiceRoomRedPacketService,
|
VoiceRoomRedPacket: voiceRoomRedPacketService,
|
||||||
VoiceRoomRocket: voiceRoomRocketService,
|
VoiceRoomRocket: voiceRoomRocketService,
|
||||||
WeekStar: weekStarService,
|
WeekStar: weekStarService,
|
||||||
YumiGiftChallenge: yumiGiftChallengeService,
|
|
||||||
})
|
})
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: app.Config.HTTP.ListenAddr,
|
Addr: app.Config.HTTP.ListenAddr,
|
||||||
|
|||||||
@ -4,14 +4,12 @@ import (
|
|||||||
"chatapp3-golang/internal/bootstrap"
|
"chatapp3-golang/internal/bootstrap"
|
||||||
"chatapp3-golang/internal/service/cprelationbroadcast"
|
"chatapp3-golang/internal/service/cprelationbroadcast"
|
||||||
"chatapp3-golang/internal/service/firstrechargereward"
|
"chatapp3-golang/internal/service/firstrechargereward"
|
||||||
"chatapp3-golang/internal/service/gameking"
|
|
||||||
"chatapp3-golang/internal/service/regionimgroup"
|
"chatapp3-golang/internal/service/regionimgroup"
|
||||||
"chatapp3-golang/internal/service/registerreward"
|
"chatapp3-golang/internal/service/registerreward"
|
||||||
"chatapp3-golang/internal/service/taskcenter"
|
"chatapp3-golang/internal/service/taskcenter"
|
||||||
"chatapp3-golang/internal/service/voiceroomredpacket"
|
"chatapp3-golang/internal/service/voiceroomredpacket"
|
||||||
"chatapp3-golang/internal/service/voiceroomrocket"
|
"chatapp3-golang/internal/service/voiceroomrocket"
|
||||||
"chatapp3-golang/internal/service/weekstar"
|
"chatapp3-golang/internal/service/weekstar"
|
||||||
"chatapp3-golang/internal/service/yumigiftchallenge"
|
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@ -30,11 +28,7 @@ func main() {
|
|||||||
|
|
||||||
registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||||
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||||
yumiGiftChallengeService := yumigiftchallenge.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
|
||||||
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
gameKingService := gameking.NewService(app.Repository.DB, &app.Gateways)
|
|
||||||
// consumer 是生产 MQ 的主要入口,必须与 API 直报共用同一个活动幂等账本。
|
|
||||||
taskCenterService.SetGameConsumeSink(gameKingService)
|
|
||||||
firstRechargeRewardService := firstrechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
firstRechargeRewardService := firstrechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
cpRelationBroadcastService := cprelationbroadcast.NewService(app.Config, app.Repository.DB, regionIMGroupService)
|
cpRelationBroadcastService := cprelationbroadcast.NewService(app.Config, app.Repository.DB, regionIMGroupService)
|
||||||
@ -52,9 +46,6 @@ func main() {
|
|||||||
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
|
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
|
||||||
log.Fatalf("start week star message consumer failed: %v", err)
|
log.Fatalf("start week star message consumer failed: %v", err)
|
||||||
}
|
}
|
||||||
if err := yumiGiftChallengeService.StartMessageConsumer(workerCtx); err != nil {
|
|
||||||
log.Fatalf("start yumi gift challenge message consumer failed: %v", err)
|
|
||||||
}
|
|
||||||
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
|
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
|
||||||
log.Fatalf("start task center message consumer failed: %v", err)
|
log.Fatalf("start task center message consumer failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,303 +0,0 @@
|
|||||||
# Yumi 游戏王活动接口
|
|
||||||
|
|
||||||
本文对应 `chatapp3-golang` 的 Yumi Game King 实现。业务只接受 `LIKEI` 租户的权威游戏金币消耗事件,不提供事件白名单输入框,也不会统计通用钱包支出。
|
|
||||||
|
|
||||||
## 1. 通用约定
|
|
||||||
|
|
||||||
- H5 外部网关地址:`/go/app/h5/game-king/yumi/*`;Go 服务内路由不带 `/go`。
|
|
||||||
- 后台外部网关地址:`/go/resident-activity/game-king/*`;Go 服务内路由不带 `/go`。
|
|
||||||
- H5 和后台均传 `Authorization`。H5 支持 `Bearer <token>`;后台沿用 Console 完整 Authorization。
|
|
||||||
- 内部任务传 `X-Internal-Token: <HTTP_INTERNAL_CALLBACK_SECRET>`。
|
|
||||||
- 时间字段均为毫秒时间戳;`timeZone` 使用 IANA 时区,默认 `Asia/Riyadh`。
|
|
||||||
- 所有业务 ID 在 JSON 响应中均为字符串,避免 JavaScript 丢失 Snowflake 精度。请求中的 ID 可传字符串或数字,建议统一传字符串。
|
|
||||||
- 成功响应的数据同时位于 `data` 和 `body`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": true,
|
|
||||||
"errorCode": 0,
|
|
||||||
"errorMsg": "success",
|
|
||||||
"code": "success",
|
|
||||||
"message": "success",
|
|
||||||
"data": {},
|
|
||||||
"body": {},
|
|
||||||
"time": 1784300000000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- 错误时 HTTP 状态码与 `errorCode` 一致,稳定业务码位于 `code` / `errorCodeName`。
|
|
||||||
|
|
||||||
## 2. H5 接口
|
|
||||||
|
|
||||||
### 2.1 活动详情
|
|
||||||
|
|
||||||
`GET /go/app/h5/game-king/yumi/detail?activityId={activityId}`
|
|
||||||
|
|
||||||
`activityId` 可不传;不传时按登录用户 `sysOrigin=LIKEI` 解析当前可见活动。返回活动、固定七个奖项、六个排名奖励档、服务器时间和活动状态。
|
|
||||||
|
|
||||||
关键返回字段:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"activity": {
|
|
||||||
"id": "190000000000000001",
|
|
||||||
"activityCode": "YUMI_GAME_KING_S1",
|
|
||||||
"sysOrigin": "LIKEI",
|
|
||||||
"startTime": 1784300000000,
|
|
||||||
"endTime": 1784900000000,
|
|
||||||
"coinPerDraw": 1000,
|
|
||||||
"eventTypes": ["GAME_CONSUME_GOLD"],
|
|
||||||
"rankingType": "TYCOON",
|
|
||||||
"rankingPeriod": "OVERALL",
|
|
||||||
"settlementStatus": "NOT_STARTED"
|
|
||||||
},
|
|
||||||
"prizes": [],
|
|
||||||
"rankRewards": [],
|
|
||||||
"supportedRankingTypes": ["TYCOON"],
|
|
||||||
"supportedRankingPeriods": ["DAILY", "OVERALL"],
|
|
||||||
"activityStatus": "UPCOMING",
|
|
||||||
"serverTime": 1784299900000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`activityStatus`:`UPCOMING`、`ACTIVE`、`ENDED`、`DISABLED`。
|
|
||||||
|
|
||||||
### 2.2 我的进度
|
|
||||||
|
|
||||||
`GET /go/app/h5/game-king/yumi/me?activityId={activityId}`
|
|
||||||
|
|
||||||
返回:
|
|
||||||
|
|
||||||
- `totalConsumed`:活动时间窗内累计游戏消耗金币。
|
|
||||||
- `earnedChances`:`floor(totalConsumed / coinPerDraw)`。
|
|
||||||
- `usedChances` / `availableChances`:已用和可用抽奖次数。
|
|
||||||
- `nextChanceRemaining`:距离下一次机会仍需消耗的金币。
|
|
||||||
- `rank`:总榜名次;无消费时为 `null`。
|
|
||||||
|
|
||||||
### 2.3 抽奖
|
|
||||||
|
|
||||||
`POST /go/app/h5/game-king/yumi/draw`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"activityId": "190000000000000001",
|
|
||||||
"requestId": "9af73ee9-65ca-4cf5-b23c-f4c222c3ee6e"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `activityId` 和 `requestId` 均必填;`requestId` 最长 64 字符。客户端一次点击生成一个稳定值;超时重试必须复用原值。
|
|
||||||
- 幂等范围是 `activityId + userId + requestId`。已经提交的结果即使活动随后结束,也会原样返回,且不会再次扣次数或发奖。
|
|
||||||
- 权重仅表示相对概率;库存 `-1` 为无限,`0` 为耗尽,正数为剩余库存。有限库存使用条件更新防止超发。
|
|
||||||
- 返回 `record`、`prize`、`remainingChances`、`idempotentReplay`。
|
|
||||||
|
|
||||||
### 2.4 排行榜
|
|
||||||
|
|
||||||
`GET /go/app/h5/game-king/yumi/ranking?activityId={activityId}&rankingType=TYCOON&period=OVERALL&limit=30`
|
|
||||||
|
|
||||||
- `rankingType`:正式口径仅 `TYCOON`。旧 H5 请求 `VICTORIOUS` 时返回空榜,避免兼容页面报错。
|
|
||||||
- `period`:`DAILY` 或 `OVERALL`;配置页固定保存 `OVERALL`,H5 仍可切日榜。
|
|
||||||
- `limit` 默认 30,最大 100。
|
|
||||||
- 排序固定为:消费金币降序、达到当前消费值的事件时间升序、用户 ID 升序。
|
|
||||||
- `DAILY` 按活动时区自然日统计;活动结束结算只使用 `OVERALL` Top30。
|
|
||||||
- 用户资料通过 Java 批量接口补齐;资料服务异常时榜单仍返回 `userId` 和分数。
|
|
||||||
|
|
||||||
### 2.5 我的抽奖记录
|
|
||||||
|
|
||||||
`GET /go/app/h5/game-king/yumi/draw-records?activityId={activityId}&limit=20`
|
|
||||||
|
|
||||||
`limit` 最大 100。返回奖品快照及 `PENDING / PROCESSING / SUCCESS / FAILED / UNKNOWN` 发放状态。
|
|
||||||
|
|
||||||
## 3. 后台管理接口
|
|
||||||
|
|
||||||
所有接口要求菜单别名 `ResidentGameKingActivity`。写接口还要求对应按钮权限:
|
|
||||||
|
|
||||||
| 权限别名 | 接口用途 |
|
|
||||||
| --- | --- |
|
|
||||||
| `resident-activity:yumi-game-king:edit` | 保存活动、奖项、排名奖励 |
|
|
||||||
| `resident-activity:yumi-game-king:enable` | 启停活动 |
|
|
||||||
| `resident-activity:yumi-game-king:settle` | 人工触发到期结算 |
|
|
||||||
| `resident-activity:yumi-game-king:reconcile` | FAILED 重试、UNKNOWN 核账 |
|
|
||||||
| `resident-activity:yumi-game-king:backfill` | 权威事件补数 |
|
|
||||||
|
|
||||||
### 3.1 活动配置
|
|
||||||
|
|
||||||
| 方法 | 地址 | 说明 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| GET | `/go/resident-activity/game-king/list` | 活动列表 |
|
|
||||||
| GET | `/go/resident-activity/game-king/detail/{id}` | 完整详情 |
|
|
||||||
| POST | `/go/resident-activity/game-king/save` | 新增或修改 |
|
|
||||||
| PUT | `/go/resident-activity/game-king/enable/{id}?enabled=true` | 启停;布尔值只接受 `true/false/1/0` |
|
|
||||||
| GET | `/go/resident-activity/game-king/prizes/{id}` | 七个奖项 |
|
|
||||||
| PUT | `/go/resident-activity/game-king/prizes/{id}` | 保存七个奖项 |
|
|
||||||
| GET | `/go/resident-activity/game-king/rank-rewards/{id}` | 六个排名奖励档 |
|
|
||||||
| PUT | `/go/resident-activity/game-king/rank-rewards/{id}` | 保存六个排名奖励档 |
|
|
||||||
|
|
||||||
`POST /save` 示例:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "0",
|
|
||||||
"activityCode": "YUMI_GAME_KING_S1",
|
|
||||||
"activityName": "Yumi Game King",
|
|
||||||
"activityDesc": "",
|
|
||||||
"sysOrigin": "LIKEI",
|
|
||||||
"timeZone": "Asia/Riyadh",
|
|
||||||
"startTime": 1784300000000,
|
|
||||||
"endTime": 1784900000000,
|
|
||||||
"settlementDelayMinutes": 30,
|
|
||||||
"coinPerDraw": 1000,
|
|
||||||
"rankingType": "TYCOON",
|
|
||||||
"rankingPeriod": "OVERALL",
|
|
||||||
"enabled": false,
|
|
||||||
"prizes": [
|
|
||||||
{
|
|
||||||
"id": "0",
|
|
||||||
"prizeName": "Prize 1",
|
|
||||||
"prizeImage": "https://example.com/prize-1.png",
|
|
||||||
"weight": 100,
|
|
||||||
"stock": -1,
|
|
||||||
"resourceGroupId": "190000000000000101",
|
|
||||||
"enabled": true,
|
|
||||||
"sortOrder": 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"rankRewards": [
|
|
||||||
{
|
|
||||||
"id": "0",
|
|
||||||
"startRank": 1,
|
|
||||||
"endRank": 1,
|
|
||||||
"resourceGroupId": "190000000000000201",
|
|
||||||
"rewardName": "Top 1"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
示例数组为节省篇幅只展示一项,真实启用配置必须满足:
|
|
||||||
|
|
||||||
- 恰好 7 个奖项,全部启用,`sortOrder` 完整覆盖 1..7 且不重复;权重必须大于 0。
|
|
||||||
- 恰好 6 个排名档:`1`、`2`、`3`、`4-7`、`8-10`、`11-30`。
|
|
||||||
- `sysOrigin` 固定 `LIKEI`;`rankingType` 固定 `TYCOON`;配置 `rankingPeriod` 固定 `OVERALL`。
|
|
||||||
- 首次启用时 `startTime` 必须严格晚于当前时间;同租户启用活动时间窗不能重叠。
|
|
||||||
- 计数窗口严格为 `[startTime, endTime)`。活动开始后锁定时间、口径、奖项、排名奖励和启停状态,只允许修改名称、说明等展示信息。
|
|
||||||
- `settlementDelayMinutes` 默认 30,可配置 0..1440。
|
|
||||||
- 每次保存/启用及每次真实发奖前都会重新校验奖励资源组:必须存在、已上架、明确属于 `LIKEI` 且非空,并比对冻结的完整奖励项快照。
|
|
||||||
|
|
||||||
单独保存奖项请求:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"activityId": "190000000000000001",
|
|
||||||
"prizes": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
单独保存排名奖励请求:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"activityId": "190000000000000001",
|
|
||||||
"rankRewards": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
路径 ID 与 body `activityId` 必须一致。
|
|
||||||
|
|
||||||
### 3.2 抽奖发放管理
|
|
||||||
|
|
||||||
| 方法 | 地址 | 说明 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| GET | `/go/resident-activity/game-king/draw-records/{id}?deliveryStatus=&limit=100` | 查看抽奖和发放明细 |
|
|
||||||
| POST | `/go/resident-activity/game-king/draw/retry/{recordId}` | 只重试 `FAILED` |
|
|
||||||
| POST | `/go/resident-activity/game-king/delivery-item/resolve/{itemId}?delivered=true` | 人工核销 `UNKNOWN` |
|
|
||||||
|
|
||||||
`delivered` 只接受 `true/false/1/0`:
|
|
||||||
|
|
||||||
- `true`:已通过 `trackId/businessNo` 确认下游到账,只记为成功,不再发送。
|
|
||||||
- `false`:已确认下游未到账,先转为 `FAILED`,再执行一次显式重试。
|
|
||||||
- 非法或缺失值返回 400,绝不默认为 `false`。
|
|
||||||
|
|
||||||
### 3.3 排名结算
|
|
||||||
|
|
||||||
| 方法 | 地址 | 说明 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| GET | `/go/resident-activity/game-king/settlement-records/{id}` | 查看冻结名次与发放状态 |
|
|
||||||
| POST | `/go/resident-activity/game-king/settlement/{id}` | 到结算时间后人工冻结并派发一小批 |
|
|
||||||
| POST | `/go/resident-activity/game-king/settlement/retry/{recordId}` | 只重试 `FAILED` |
|
|
||||||
|
|
||||||
结算只冻结一次 `OVERALL` Top30。记录和发奖任务在同一事务生成;人工接口每次最多派发该活动 4 个 `PENDING`,其余由内部定时任务继续。单项硬超时 20 秒,因此最坏外部调用约 80 秒,并为数据库处理留出余量。
|
|
||||||
|
|
||||||
### 3.4 事件补数
|
|
||||||
|
|
||||||
`POST /go/resident-activity/game-king/backfill/{id}?lastId=0&limit=200`
|
|
||||||
|
|
||||||
- 只读取 `task_center_event_archive_outbox` 的 `LIKEI + GAME_CONSUME_GOLD + 活动时间窗` 数据。
|
|
||||||
- 在任何归档扫描前都会检查迁移 058 的四列在线索引;索引未完成、不可见或列序不符时返回 `503 backfill_index_required`,不会退化为全表扫描。
|
|
||||||
- 仅允许对已启用且结算尚未冻结的活动补数;活动结束后的结算等待期仍可补,进入结算后返回 409,游标不会静默推进。
|
|
||||||
- 调用方只能传不透明游标 `lastId` 和 `limit`,不能传 eventType 或钱包口径;`limit` 最大 500。
|
|
||||||
- 返回 `eventType`、`scanned`、`accepted`、`nextLastId`、`finished`。未完成时用返回的 `nextLastId` 继续。
|
|
||||||
- 若结算恰好在一页补数中途冻结,接口返回 409 且不返回新的游标;调用方保留原 `lastId`,已成功写入的前序事件会由活动账本幂等跳过。
|
|
||||||
- 活动账本以 `activityId + eventId` 幂等,实时事件和补数重复不会重复累计。
|
|
||||||
|
|
||||||
## 4. 游戏消耗事件接入
|
|
||||||
|
|
||||||
唯一统计事件是任务中心的 `GAME_CONSUME_GOLD`。事件经过任务中心统一校验后、在任务中心自身业务去重之前同步扇出给 Game 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 策略确认锁影响,并在低峰发布。
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
# Yumi 礼物挑战接口
|
|
||||||
|
|
||||||
Yumi 的 Java `sysOrigin` 固定为 `LIKEI`。网关外部地址在以下路径前加 `/go`;Go 服务内部路径不加。所有 Snowflake ID 都使用十进制字符串,积分/任务金额使用精确两位小数字符串(如 `"100.50"`)。统一响应业务数据位于 `body`/`data`。
|
|
||||||
|
|
||||||
## H5 用户接口
|
|
||||||
|
|
||||||
鉴权:`Authorization: Bearer <app token>`。
|
|
||||||
|
|
||||||
| 方法 | 地址 | 参数 | 主要返回 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| GET | `/app/h5/gift-challenge/yumi/detail` | query `activityId` 可选 | `activity`、固定三任务 `tasks`、日/总榜奖励 `rankRewards` |
|
|
||||||
| POST | `/app/h5/gift-challenge/yumi/enter` | JSON `{ "activityId": "..." }` | 当日 `totalScore`、`dailyScore`、名次和任务状态;幂等完成进入页面任务 |
|
|
||||||
| GET | `/app/h5/gift-challenge/yumi/ranking` | query `activityId` 可选、`periodType=DAILY\|OVERALL`、DAILY 可传 `statDate=yyyy-MM-dd`、`limit`(默认/最大 30) | 原始 Top N 过滤神秘人后的 `entries`、本人真实 `my`、`settled` |
|
|
||||||
| POST | `/app/h5/gift-challenge/yumi/tasks/:taskCode/claim` | JSON `{ "activityId": "...", "requestId": "..." }` | 更新后的当日用户状态;`requestId` 最长 128 |
|
|
||||||
|
|
||||||
只有 `GOLD`、非背包、非 `LUCKY_GIFT`/`MAGIC` 的普通礼物计分。积分按 `giftCandy × quantity × 去重有效收礼人数` 精确计算,支持最多两位小数。
|
|
||||||
|
|
||||||
## Webconsole 接口
|
|
||||||
|
|
||||||
鉴权:Console `Authorization`。所有 GET 还要求页面菜单 `ResidentYumiGiftChallenge`;写操作分别校验按钮权限。
|
|
||||||
|
|
||||||
基础地址:`/resident-activity/yumi-gift-challenge`。
|
|
||||||
|
|
||||||
| 方法 | 地址 | 参数/返回 | 权限 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| GET | `/list` | query `sysOrigin=LIKEI&page&size`,返回 `{records,total,current,size}` | 页面菜单 |
|
|
||||||
| GET | `/detail/:id` | 完整活动、任务、奖励 | 页面菜单 |
|
|
||||||
| GET | `/tasks/:id` | 返回任务数组 | 页面菜单 |
|
|
||||||
| GET | `/rank-rewards/:id` | 返回日榜/总榜奖励数组 | 页面菜单 |
|
|
||||||
| GET | `/ranking/:id` | query `periodType,statDate,limit` | 页面菜单 |
|
|
||||||
| GET | `/settlement-records/:id` | query `periodType,statDate,deliveryStatus,page,size` | 页面菜单 |
|
|
||||||
| POST | `/save` | 完整活动 JSON;新活动固定 3 个启用任务,榜奖区间可配置 | `resident-activity:yumi-gift-challenge:edit` |
|
|
||||||
| PUT | `/enable/:id` | query `enabled=true\|false` | `resident-activity:yumi-gift-challenge:enable` |
|
|
||||||
| PUT | `/tasks/:id` | JSON `{ "activityId":"...", "tasks":[...] }` | `...:edit` |
|
|
||||||
| PUT | `/rank-rewards/:id` | JSON `{ "activityId":"...", "rankRewards":[...] }` | `...:edit` |
|
|
||||||
| POST | `/settlement/:id` | JSON `{ "periodType":"DAILY\|OVERALL", "statDate":"yyyy-MM-dd" }`;OVERALL 不传日期 | `...:settle` |
|
|
||||||
| POST | `/delivery-item/resolve/:itemId` | JSON `{ "delivered": true\|false }`;仅人工核账 UNKNOWN | `...:reconcile` |
|
|
||||||
|
|
||||||
活动启用时会校验奖励组已上架且属于 LIKEI,并冻结完整奖励项。活动开始后仅允许通过 `/save` 修改名称和描述,其他统计/奖励口径只读。
|
|
||||||
日榜、总榜结算延迟均允许 `0-1440` 分钟,且 `overallSettlementDelayMinutes` 必须大于或等于 `dailySettlementDelayMinutes`,保证最终日榜不会晚于总榜关门。
|
|
||||||
|
|
||||||
## chatapp-cron 触发
|
|
||||||
|
|
||||||
`POST /internal/yumi-gift-challenge/settle-due?batch=20`
|
|
||||||
|
|
||||||
- 鉴权头:`X-Internal-Token`,值与 `CHATAPP_HTTP_INTERNAL_CALLBACK_SECRET` 一致。
|
|
||||||
- Go 服务或 cron 任一侧未配置该密钥时均 fail-closed,不发送/不执行结算请求。
|
|
||||||
- 单轮最多处理 `batch` 个到期 `NOT_STARTED/PROCESSING` 日榜或总榜,并各恢复最多 `batch` 个任务/榜奖 owner;`batch` 默认 20、最大 100。
|
|
||||||
- 返回 `periodsScanned`、`periodsFrozen`、`taskOwnersScanned`、`settlementOwnersScanned`、`ownersProcessed`、`failedKeys`。
|
|
||||||
- HTTP 200 仍可能表示部分 owner 已成功、部分待恢复;cron 会把非空 `failedKeys` 提升为失败告警,下轮依靠数据库状态幂等续跑。
|
|
||||||
- 该端点可重复、可多实例并发调用;周期门闩、业务唯一键和逐奖励项 trackId 保证幂等。过租期 `PROCESSING` 会保守转为 `UNKNOWN`,不会自动盲目补发。
|
|
||||||
|
|
||||||
建议 chatapp-cron 每 10 秒至 1 分钟触发一次,单次 HTTP 超时至少覆盖每项 30 秒的下游上限和配置的 batch。
|
|
||||||
|
|
||||||
## RocketMQ 与 IM
|
|
||||||
|
|
||||||
- 独立消费组:`YUMI_GIFT_CHALLENGE_GIFT`。
|
|
||||||
- 默认 topic/tag:`RC_DEFAULT_APP_ORDINARY` / `give_gift_v3`。
|
|
||||||
- 环境变量前缀:`CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_*`;缺少 endpoint/密钥时消费者不会伪造凭证启动。
|
|
||||||
- 本活动没有新增 IM 消息、房间广播或客户端 IM 协议;H5 通过上述 HTTP 接口刷新任务和排行榜。
|
|
||||||
@ -6,6 +6,4 @@ type AuthUser struct {
|
|||||||
SysOrigin string
|
SysOrigin string
|
||||||
Token string
|
Token string
|
||||||
Authorization string
|
Authorization string
|
||||||
RegionID string
|
|
||||||
RegionCode string
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,6 @@ type Config struct {
|
|||||||
FirstRechargeReward FirstRechargeRewardConfig
|
FirstRechargeReward FirstRechargeRewardConfig
|
||||||
TaskCenter TaskCenterConfig
|
TaskCenter TaskCenterConfig
|
||||||
WeekStar WeekStarConfig
|
WeekStar WeekStarConfig
|
||||||
YumiGiftChallenge YumiGiftChallengeConfig
|
|
||||||
RoomTurnoverReward RoomTurnoverRewardConfig
|
RoomTurnoverReward RoomTurnoverRewardConfig
|
||||||
VoiceRoomRedPacket VoiceRoomRedPacketConfig
|
VoiceRoomRedPacket VoiceRoomRedPacketConfig
|
||||||
VoiceRoomRocket VoiceRoomRocketConfig
|
VoiceRoomRocket VoiceRoomRocketConfig
|
||||||
@ -33,7 +32,6 @@ type Config struct {
|
|||||||
TencentIM TencentIMConfig
|
TencentIM TencentIMConfig
|
||||||
Baishun BaishunConfig
|
Baishun BaishunConfig
|
||||||
Lingxian LingxianConfig
|
Lingxian LingxianConfig
|
||||||
Hotgame HotgameConfig
|
|
||||||
GameOpen GameOpenConfig
|
GameOpen GameOpenConfig
|
||||||
LuckyGift LuckyGiftConfig
|
LuckyGift LuckyGiftConfig
|
||||||
BinanceRecharge BinanceRechargeConfig
|
BinanceRecharge BinanceRechargeConfig
|
||||||
@ -150,13 +148,6 @@ type TaskCenterArchiveConfig struct {
|
|||||||
BatchSize int
|
BatchSize int
|
||||||
FlushSeconds int
|
FlushSeconds int
|
||||||
Workers int
|
Workers int
|
||||||
GC TaskCenterArchiveGCConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
// TaskCenterArchiveGCConfig 只保存清理总开关。cutoff 和批量必须由一次管理预检冻结,
|
|
||||||
// 不能由多节点各自读取易漂移的 NOW()/环境变量后直接删数据。
|
|
||||||
type TaskCenterArchiveGCConfig struct {
|
|
||||||
Enabled bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WeekStarConfig 保存周榜模块配置。
|
// WeekStarConfig 保存周榜模块配置。
|
||||||
@ -180,13 +171,6 @@ type WeekStarRocketMQConfig struct {
|
|||||||
Tag string
|
Tag string
|
||||||
}
|
}
|
||||||
|
|
||||||
// YumiGiftChallengeConfig 保存 Yumi 礼物挑战的独立送礼消费者和发奖租期配置。
|
|
||||||
type YumiGiftChallengeConfig struct {
|
|
||||||
DefaultSysOrigin string
|
|
||||||
DeliveryProcessingLeaseSeconds int
|
|
||||||
RocketMQ WeekStarRocketMQConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
// RoomTurnoverRewardConfig 保存房间流水奖励模块配置。
|
// RoomTurnoverRewardConfig 保存房间流水奖励模块配置。
|
||||||
type RoomTurnoverRewardConfig struct {
|
type RoomTurnoverRewardConfig struct {
|
||||||
DefaultSysOrigin string
|
DefaultSysOrigin string
|
||||||
@ -284,12 +268,6 @@ type LingxianConfig struct {
|
|||||||
AppKey string
|
AppKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
// HotgameConfig 保存热游模块的环境兜底配置。
|
|
||||||
type HotgameConfig struct {
|
|
||||||
AppKey string
|
|
||||||
CallbackBaseURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
// GameOpenConfig 保存统一三方游戏回调配置。
|
// GameOpenConfig 保存统一三方游戏回调配置。
|
||||||
type GameOpenConfig struct {
|
type GameOpenConfig struct {
|
||||||
PublicBaseURL string
|
PublicBaseURL string
|
||||||
@ -330,12 +308,6 @@ func Load() Config {
|
|||||||
weekStarRocketMQEnabledDefault := strings.TrimSpace(weekStarRocketMQEndpoint) != "" &&
|
weekStarRocketMQEnabledDefault := strings.TrimSpace(weekStarRocketMQEndpoint) != "" &&
|
||||||
strings.TrimSpace(weekStarRocketMQAccessKey) != "" &&
|
strings.TrimSpace(weekStarRocketMQAccessKey) != "" &&
|
||||||
strings.TrimSpace(weekStarRocketMQAccessSecret) != ""
|
strings.TrimSpace(weekStarRocketMQAccessSecret) != ""
|
||||||
yumiGiftRocketMQEndpoint := getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT", "CHATAPP_WEEK_STAR_ROCKETMQ_ENDPOINT"}, "")
|
|
||||||
yumiGiftRocketMQAccessKey := getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY", "CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_KEY"}, "")
|
|
||||||
yumiGiftRocketMQAccessSecret := getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_ACCESS_SECRET", "LIKEI_ROCKETMQ_SECRET_KEY", "CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_SECRET"}, "")
|
|
||||||
yumiGiftRocketMQEnabledDefault := strings.TrimSpace(yumiGiftRocketMQEndpoint) != "" &&
|
|
||||||
strings.TrimSpace(yumiGiftRocketMQAccessKey) != "" &&
|
|
||||||
strings.TrimSpace(yumiGiftRocketMQAccessSecret) != ""
|
|
||||||
taskCenterMQEndpoint := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ENDPOINT", "TASK_CENTER_EVENT_MQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT"}, "")
|
taskCenterMQEndpoint := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ENDPOINT", "TASK_CENTER_EVENT_MQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT"}, "")
|
||||||
taskCenterMQAccessKey := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ACCESS_KEY", "TASK_CENTER_EVENT_MQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY"}, "")
|
taskCenterMQAccessKey := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ACCESS_KEY", "TASK_CENTER_EVENT_MQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY"}, "")
|
||||||
taskCenterMQAccessSecret := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ACCESS_SECRET", "TASK_CENTER_EVENT_MQ_SECRET_KEY", "LIKEI_ROCKETMQ_SECRET_KEY"}, "")
|
taskCenterMQAccessSecret := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ACCESS_SECRET", "TASK_CENTER_EVENT_MQ_SECRET_KEY", "LIKEI_ROCKETMQ_SECRET_KEY"}, "")
|
||||||
@ -460,13 +432,9 @@ func Load() Config {
|
|||||||
SecretID: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_SECRET_ID", "TASK_CENTER_ARCHIVE_COS_SECRET_ID", "LIKEI_TENCENT_COS_SECRET_ID"}, ""),
|
SecretID: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_SECRET_ID", "TASK_CENTER_ARCHIVE_COS_SECRET_ID", "LIKEI_TENCENT_COS_SECRET_ID"}, ""),
|
||||||
SecretKey: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_SECRET_KEY", "TASK_CENTER_ARCHIVE_COS_SECRET_KEY", "LIKEI_TENCENT_COS_SECRET_KEY"}, ""),
|
SecretKey: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_SECRET_KEY", "TASK_CENTER_ARCHIVE_COS_SECRET_KEY", "LIKEI_TENCENT_COS_SECRET_KEY"}, ""),
|
||||||
Prefix: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_PREFIX", "TASK_CENTER_ARCHIVE_COS_PREFIX"}, "task-center-events/v1"),
|
Prefix: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_PREFIX", "TASK_CENTER_ARCHIVE_COS_PREFIX"}, "task-center-events/v1"),
|
||||||
BatchSize: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_BATCH_SIZE", "TASK_CENTER_ARCHIVE_BATCH_SIZE"}, 500),
|
BatchSize: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_BATCH_SIZE", "TASK_CENTER_ARCHIVE_BATCH_SIZE"}, 10000),
|
||||||
FlushSeconds: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_FLUSH_SECONDS", "TASK_CENTER_ARCHIVE_FLUSH_SECONDS"}, 60),
|
FlushSeconds: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_FLUSH_SECONDS", "TASK_CENTER_ARCHIVE_FLUSH_SECONDS"}, 60),
|
||||||
Workers: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_WORKERS", "TASK_CENTER_ARCHIVE_WORKERS"}, 2),
|
Workers: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_WORKERS", "TASK_CENTER_ARCHIVE_WORKERS"}, 2),
|
||||||
GC: TaskCenterArchiveGCConfig{
|
|
||||||
// 清理涉及不可逆数据库删除;新环境必须显式开启,避免仅部署代码就开始工作。
|
|
||||||
Enabled: getEnvBoolAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_GC_ENABLED", "TASK_CENTER_ARCHIVE_GC_ENABLED"}, false),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
WeekStar: WeekStarConfig{
|
WeekStar: WeekStarConfig{
|
||||||
@ -485,22 +453,6 @@ func Load() Config {
|
|||||||
Tag: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_TAG", "WEEK_STAR_ROCKETMQ_TAG"}, "give_gift_v3"),
|
Tag: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_TAG", "WEEK_STAR_ROCKETMQ_TAG"}, "give_gift_v3"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
YumiGiftChallenge: YumiGiftChallengeConfig{
|
|
||||||
// Java 的 Yumi 业务来源枚举是 LIKEI;此默认值不能使用仅供品牌识别的 YUMI。
|
|
||||||
DefaultSysOrigin: strings.ToUpper(getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_DEFAULT_SYS_ORIGIN"}, "LIKEI")),
|
|
||||||
DeliveryProcessingLeaseSeconds: getEnvIntAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_DELIVERY_LEASE_SECONDS"}, 300),
|
|
||||||
RocketMQ: WeekStarRocketMQConfig{
|
|
||||||
Enabled: getEnvBoolAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_ENABLED"}, yumiGiftRocketMQEnabledDefault),
|
|
||||||
Endpoint: yumiGiftRocketMQEndpoint,
|
|
||||||
Namespace: getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_NAMESPACE", "LIKEI_ROCKETMQ_NAMESPACE"}, ""),
|
|
||||||
AccessKey: yumiGiftRocketMQAccessKey,
|
|
||||||
AccessSecret: yumiGiftRocketMQAccessSecret,
|
|
||||||
SecurityToken: getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_SECURITY_TOKEN"}, ""),
|
|
||||||
ConsumerGroup: getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_CONSUMER_GROUP"}, "YUMI_GIFT_CHALLENGE_GIFT"),
|
|
||||||
Topic: getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_TOPIC", "CHATAPP_WEEK_STAR_ROCKETMQ_TOPIC"}, "RC_DEFAULT_APP_ORDINARY"),
|
|
||||||
Tag: getEnvAny([]string{"CHATAPP_YUMI_GIFT_CHALLENGE_ROCKETMQ_TAG", "CHATAPP_WEEK_STAR_ROCKETMQ_TAG"}, "give_gift_v3"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
RoomTurnoverReward: RoomTurnoverRewardConfig{
|
RoomTurnoverReward: RoomTurnoverRewardConfig{
|
||||||
DefaultSysOrigin: strings.ToUpper(getEnvAny(
|
DefaultSysOrigin: strings.ToUpper(getEnvAny(
|
||||||
[]string{"CHATAPP_ROOM_TURNOVER_REWARD_DEFAULT_SYS_ORIGIN", "ROOM_TURNOVER_REWARD_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"},
|
[]string{"CHATAPP_ROOM_TURNOVER_REWARD_DEFAULT_SYS_ORIGIN", "ROOM_TURNOVER_REWARD_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"},
|
||||||
@ -665,10 +617,6 @@ func Load() Config {
|
|||||||
GameListURL: getEnvAny([]string{"CHATAPP_LINGXIAN_GAME_LIST_URL", "LINGXIAN_GAME_LIST_URL"}, "https://sg-test.leadercc.com/yumichat_games/test_game_list.json"),
|
GameListURL: getEnvAny([]string{"CHATAPP_LINGXIAN_GAME_LIST_URL", "LINGXIAN_GAME_LIST_URL"}, "https://sg-test.leadercc.com/yumichat_games/test_game_list.json"),
|
||||||
AppKey: getEnvAny([]string{"CHATAPP_LINGXIAN_APP_KEY", "LINGXIAN_APP_KEY", "GAME_HKYS_SIGN_KEY", "LIKEI_GAME_HKYS_SIGN_KEY"}, ""),
|
AppKey: getEnvAny([]string{"CHATAPP_LINGXIAN_APP_KEY", "LINGXIAN_APP_KEY", "GAME_HKYS_SIGN_KEY", "LIKEI_GAME_HKYS_SIGN_KEY"}, ""),
|
||||||
},
|
},
|
||||||
Hotgame: HotgameConfig{
|
|
||||||
AppKey: getEnvAny([]string{"CHATAPP_HOTGAME_APP_KEY", "HOTGAME_APP_KEY", "CHATAPP_REYOU_APP_KEY", "REYOU_APP_KEY"}, ""),
|
|
||||||
CallbackBaseURL: getEnvAny([]string{"CHATAPP_HOTGAME_CALLBACK_BASE_URL", "HOTGAME_CALLBACK_BASE_URL", "CHATAPP_REYOU_CALLBACK_BASE_URL", "REYOU_CALLBACK_BASE_URL"}, ""),
|
|
||||||
},
|
|
||||||
GameOpen: GameOpenConfig{
|
GameOpen: GameOpenConfig{
|
||||||
PublicBaseURL: getEnvAny([]string{"CHATAPP_GAME_OPEN_PUBLIC_BASE_URL", "GAME_OPEN_PUBLIC_BASE_URL"}, ""),
|
PublicBaseURL: getEnvAny([]string{"CHATAPP_GAME_OPEN_PUBLIC_BASE_URL", "GAME_OPEN_PUBLIC_BASE_URL"}, ""),
|
||||||
AppKey: getEnvAny([]string{"CHATAPP_GAME_OPEN_APP_KEY", "GAME_OPEN_APP_KEY"}, "game-open-test-key"),
|
AppKey: getEnvAny([]string{"CHATAPP_GAME_OPEN_APP_KEY", "GAME_OPEN_APP_KEY"}, "game-open-test-key"),
|
||||||
|
|||||||
@ -56,16 +56,6 @@ func (g *Gateways) AuthenticateConsoleToken(ctx context.Context, authorization s
|
|||||||
return g.Auth.AuthenticateConsoleToken(ctx, authorization)
|
return g.Auth.AuthenticateConsoleToken(ctx, authorization)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListConsoleButtonAliases 透传管理员按钮权限查询。
|
|
||||||
func (g *Gateways) ListConsoleButtonAliases(ctx context.Context, authorization string) (map[string]struct{}, error) {
|
|
||||||
return g.Auth.ListConsoleButtonAliases(ctx, authorization)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasConsoleMenuAlias 查询管理员是否拥有指定页面菜单。
|
|
||||||
func (g *Gateways) HasConsoleMenuAlias(ctx context.Context, authorization, requiredAlias string) (bool, error) {
|
|
||||||
return g.Auth.HasConsoleMenuAlias(ctx, authorization, requiredAlias)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDeviceFingerprint 透传到设备网关。
|
// GetDeviceFingerprint 透传到设备网关。
|
||||||
func (g *Gateways) GetDeviceFingerprint(ctx context.Context, userID int64) (string, error) {
|
func (g *Gateways) GetDeviceFingerprint(ctx context.Context, userID int64) (string, error) {
|
||||||
return g.Device.GetDeviceFingerprint(ctx, userID)
|
return g.Device.GetDeviceFingerprint(ctx, userID)
|
||||||
@ -136,51 +126,21 @@ func (g *Gateways) SendActivityReward(ctx context.Context, req SendActivityRewar
|
|||||||
return g.RewardDispatch.SendActivityReward(ctx, req)
|
return g.RewardDispatch.SendActivityReward(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendFrozenActivityReward 透传活动冻结奖励项发放网关。
|
|
||||||
func (g *Gateways) SendFrozenActivityReward(ctx context.Context, req SendFrozenActivityRewardRequest) error {
|
|
||||||
return g.RewardDispatch.SendFrozenActivityReward(ctx, req)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRewardGroupDetail 透传到奖励查询网关。
|
// GetRewardGroupDetail 透传到奖励查询网关。
|
||||||
func (g *Gateways) GetRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) {
|
func (g *Gateways) GetRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) {
|
||||||
return g.RewardQuery.GetRewardGroupDetail(ctx, groupID)
|
return g.RewardQuery.GetRewardGroupDetail(ctx, groupID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindRewardGroupDetail 透传到奖励查询网关,按名称查找奖励组详情。
|
|
||||||
func (g *Gateways) FindRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (RewardGroupDetail, error) {
|
|
||||||
return g.RewardQuery.FindRewardGroupDetail(ctx, sysOrigin, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUserProfile 透传到资料网关。
|
// GetUserProfile 透传到资料网关。
|
||||||
func (g *Gateways) GetUserProfile(ctx context.Context, userID int64) (UserProfile, error) {
|
func (g *Gateways) GetUserProfile(ctx context.Context, userID int64) (UserProfile, error) {
|
||||||
return g.Profile.GetUserProfile(ctx, userID)
|
return g.Profile.GetUserProfile(ctx, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MapUserProfiles 透传批量用户资料查询。
|
|
||||||
func (g *Gateways) MapUserProfiles(ctx context.Context, userIDs []int64) (map[int64]UserProfile, error) {
|
|
||||||
return g.Profile.MapUserProfiles(ctx, userIDs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUserMysteriousInvisibility 查询用户是否开启排行榜隐身能力。
|
|
||||||
func (g *Gateways) GetUserMysteriousInvisibility(ctx context.Context, sysOrigin string, userID int64) (bool, error) {
|
|
||||||
return g.Profile.GetUserMysteriousInvisibility(ctx, sysOrigin, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateUserCountry 透传到 Java 用户国家更新接口。
|
|
||||||
func (g *Gateways) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error {
|
|
||||||
return g.Profile.UpdateUserCountry(ctx, userID, countryID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUserRegion 透传到用户地区网关。
|
// GetUserRegion 透传到用户地区网关。
|
||||||
func (g *Gateways) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) {
|
func (g *Gateways) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) {
|
||||||
return g.Profile.GetUserRegion(ctx, userID, authorization)
|
return g.Profile.GetUserRegion(ctx, userID, authorization)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserLevel 透传到用户等级网关。
|
|
||||||
func (g *Gateways) GetUserLevel(ctx context.Context, sysOrigin string, userID int64) (UserLevel, error) {
|
|
||||||
return g.Profile.GetUserLevel(ctx, sysOrigin, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveRegionCodeByCountryCode 透传到 Java 区域配置网关。
|
// ResolveRegionCodeByCountryCode 透传到 Java 区域配置网关。
|
||||||
func (g *Gateways) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
|
func (g *Gateways) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
|
||||||
return g.Profile.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode)
|
return g.Profile.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode)
|
||||||
@ -306,16 +266,6 @@ func (g *AuthGateway) AuthenticateConsoleToken(ctx context.Context, authorizatio
|
|||||||
return g.client.AuthenticateConsoleToken(ctx, authorization)
|
return g.client.AuthenticateConsoleToken(ctx, authorization)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListConsoleButtonAliases 查询管理员按钮权限码。
|
|
||||||
func (g *AuthGateway) ListConsoleButtonAliases(ctx context.Context, authorization string) (map[string]struct{}, error) {
|
|
||||||
return g.client.ListConsoleButtonAliases(ctx, authorization)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasConsoleMenuAlias 查询管理员页面菜单权限。
|
|
||||||
func (g *AuthGateway) HasConsoleMenuAlias(ctx context.Context, authorization, requiredAlias string) (bool, error) {
|
|
||||||
return g.client.HasConsoleMenuAlias(ctx, authorization, requiredAlias)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeviceGateway 负责设备指纹相关接口。
|
// DeviceGateway 负责设备指纹相关接口。
|
||||||
type DeviceGateway struct {
|
type DeviceGateway struct {
|
||||||
client *Client
|
client *Client
|
||||||
@ -405,11 +355,6 @@ func (g *RewardDispatchGateway) SendActivityReward(ctx context.Context, req Send
|
|||||||
return g.client.SendActivityReward(ctx, req)
|
return g.client.SendActivityReward(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendFrozenActivityReward 发放一个已经冻结的活动奖励项。
|
|
||||||
func (g *RewardDispatchGateway) SendFrozenActivityReward(ctx context.Context, req SendFrozenActivityRewardRequest) error {
|
|
||||||
return g.client.SendFrozenActivityReward(ctx, req)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RewardQueryGateway 负责奖励配置查询接口。
|
// RewardQueryGateway 负责奖励配置查询接口。
|
||||||
type RewardQueryGateway struct {
|
type RewardQueryGateway struct {
|
||||||
client *Client
|
client *Client
|
||||||
@ -420,11 +365,6 @@ func (g *RewardQueryGateway) GetRewardGroupDetail(ctx context.Context, groupID i
|
|||||||
return g.client.GetRewardGroupDetail(ctx, groupID)
|
return g.client.GetRewardGroupDetail(ctx, groupID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindRewardGroupDetail 按名称查询奖励组详情。
|
|
||||||
func (g *RewardQueryGateway) FindRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (RewardGroupDetail, error) {
|
|
||||||
return g.client.FindRewardGroupDetail(ctx, sysOrigin, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetNobleVIPAbility 查询贵族 VIP 能力配置。
|
// GetNobleVIPAbility 查询贵族 VIP 能力配置。
|
||||||
func (g *RewardQueryGateway) GetNobleVIPAbility(ctx context.Context, sourceID int64) (PropsNobleVIPAbility, error) {
|
func (g *RewardQueryGateway) GetNobleVIPAbility(ctx context.Context, sourceID int64) (PropsNobleVIPAbility, error) {
|
||||||
return g.client.GetNobleVIPAbility(ctx, sourceID)
|
return g.client.GetNobleVIPAbility(ctx, sourceID)
|
||||||
@ -445,31 +385,11 @@ func (g *ProfileGateway) GetUserProfile(ctx context.Context, userID int64) (User
|
|||||||
return g.client.GetUserProfile(ctx, userID)
|
return g.client.GetUserProfile(ctx, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MapUserProfiles 批量查询用户资料。
|
|
||||||
func (g *ProfileGateway) MapUserProfiles(ctx context.Context, userIDs []int64) (map[int64]UserProfile, error) {
|
|
||||||
return g.client.MapUserProfiles(ctx, userIDs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUserMysteriousInvisibility 查询单个用户的排行榜隐身能力。
|
|
||||||
func (g *ProfileGateway) GetUserMysteriousInvisibility(ctx context.Context, sysOrigin string, userID int64) (bool, error) {
|
|
||||||
return g.client.GetUserMysteriousInvisibility(ctx, sysOrigin, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateUserCountry 通过 Java other 更新用户国家。
|
|
||||||
func (g *ProfileGateway) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error {
|
|
||||||
return g.client.UpdateUserCountry(ctx, userID, countryID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUserRegion 查询用户当前地区。
|
// GetUserRegion 查询用户当前地区。
|
||||||
func (g *ProfileGateway) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) {
|
func (g *ProfileGateway) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) {
|
||||||
return g.client.GetUserRegion(ctx, userID, authorization)
|
return g.client.GetUserRegion(ctx, userID, authorization)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserLevel 查询用户财富/魅力等级。
|
|
||||||
func (g *ProfileGateway) GetUserLevel(ctx context.Context, sysOrigin string, userID int64) (UserLevel, error) {
|
|
||||||
return g.client.GetUserLevel(ctx, sysOrigin, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveRegionCodeByCountryCode 根据 Java 区域配置把国家码解析成区域编码。
|
// ResolveRegionCodeByCountryCode 根据 Java 区域配置把国家码解析成区域编码。
|
||||||
func (g *ProfileGateway) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
|
func (g *ProfileGateway) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
|
||||||
return g.client.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode)
|
return g.client.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode)
|
||||||
|
|||||||
@ -41,11 +41,6 @@ type ConsoleAccount struct {
|
|||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConsoleMenu struct {
|
|
||||||
Alias string `json:"alias"`
|
|
||||||
Childrens []ConsoleMenu `json:"childrens"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserProfile struct {
|
type UserProfile struct {
|
||||||
ID Int64Value `json:"id"`
|
ID Int64Value `json:"id"`
|
||||||
Account string `json:"account"`
|
Account string `json:"account"`
|
||||||
@ -64,12 +59,6 @@ type UserRegion struct {
|
|||||||
RegionCode string `json:"regionCode"`
|
RegionCode string `json:"regionCode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserLevel struct {
|
|
||||||
UserID Int64Value `json:"userId"`
|
|
||||||
WealthLevel int `json:"wealthLevel"`
|
|
||||||
CharmLevel int `json:"charmLevel"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RegionConfig struct {
|
type RegionConfig struct {
|
||||||
RegionCode string `json:"regionCode"`
|
RegionCode string `json:"regionCode"`
|
||||||
SysOrigin string `json:"sysOrigin"`
|
SysOrigin string `json:"sysOrigin"`
|
||||||
@ -204,30 +193,6 @@ type SendActivityRewardRequest struct {
|
|||||||
AcceptUserID int64 `json:"acceptUserId"`
|
AcceptUserID int64 `json:"acceptUserId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendFrozenActivityRewardRequest 直接携带启用时冻结的单个奖励项,避免活动发奖时重新读取已变化的共享奖励组。
|
|
||||||
type SendFrozenActivityRewardRequest struct {
|
|
||||||
TrackID int64 `json:"trackId"`
|
|
||||||
AcceptUserID int64 `json:"acceptUserId"`
|
|
||||||
Origin string `json:"origin"`
|
|
||||||
SysOrigin string `json:"sysOrigin"`
|
|
||||||
Prizes []FrozenActivityReward `json:"prizes"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// FrozenActivityReward 对齐 Java PrizeDescribe;内层 TrackID 是资源组商品 ID,外层请求 TrackID 才是 delivery item 幂等号。
|
|
||||||
type FrozenActivityReward struct {
|
|
||||||
TrackID int64 `json:"trackId"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
DetailType string `json:"detailType,omitempty"`
|
|
||||||
Content string `json:"content,omitempty"`
|
|
||||||
Quantity int64 `json:"quantity"`
|
|
||||||
Remark string `json:"remark,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserVipAbility struct {
|
|
||||||
UserID Int64Value `json:"userId"`
|
|
||||||
MysteriousInvisibility bool `json:"mysteriousInvisibility"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TeamPolicyReleaseQuery struct {
|
type TeamPolicyReleaseQuery struct {
|
||||||
SysOrigin string
|
SysOrigin string
|
||||||
Region string
|
Region string
|
||||||
@ -274,30 +239,20 @@ type RewardGroupItem struct {
|
|||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
DetailType string `json:"detailType"`
|
DetailType string `json:"detailType"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
BadgeName string `json:"badgeName"`
|
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Quantity Int64Value `json:"quantity"`
|
Quantity Int64Value `json:"quantity"`
|
||||||
Cover string `json:"cover"`
|
Cover string `json:"cover"`
|
||||||
SourceURL string `json:"sourceUrl"`
|
SourceURL string `json:"sourceUrl"`
|
||||||
Remark string `json:"remark"`
|
Remark string `json:"remark"`
|
||||||
Sort int `json:"sort"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type RewardGroupDetail struct {
|
type RewardGroupDetail struct {
|
||||||
ID Int64Value `json:"id"`
|
ID Int64Value `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
SysOrigin string `json:"sysOrigin"`
|
SysOrigin string `json:"sysOrigin"`
|
||||||
ShelfStatus *bool `json:"shelfStatus,omitempty"`
|
|
||||||
RewardConfigList []RewardGroupItem `json:"rewardConfigList"`
|
RewardConfigList []RewardGroupItem `json:"rewardConfigList"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type rewardGroupPage struct {
|
|
||||||
Records []RewardGroupDetail `json:"records"`
|
|
||||||
Total int64 `json:"total"`
|
|
||||||
Current int `json:"current"`
|
|
||||||
Size int `json:"size"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserTotalRecharge struct {
|
type UserTotalRecharge struct {
|
||||||
ID Int64Value `json:"id"`
|
ID Int64Value `json:"id"`
|
||||||
UserID Int64Value `json:"userId"`
|
UserID Int64Value `json:"userId"`
|
||||||
@ -380,11 +335,8 @@ type freightSellerPageResponse struct {
|
|||||||
type resultResponse[T any] struct {
|
type resultResponse[T any] struct {
|
||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
ErrorMsg string `json:"errorMsg"`
|
|
||||||
Body T `json:"body"`
|
Body T `json:"body"`
|
||||||
Data T `json:"data"`
|
|
||||||
Success *bool `json:"success"`
|
Success *bool `json:"success"`
|
||||||
Status *bool `json:"status"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Int64Value int64
|
type Int64Value int64
|
||||||
@ -547,63 +499,6 @@ func (c *Client) AuthenticateConsoleToken(ctx context.Context, authorization str
|
|||||||
return resp.Body, nil
|
return resp.Body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListConsoleButtonAliases 返回当前管理员真实拥有的按钮权限码,供 Go 写接口做服务端授权。
|
|
||||||
func (c *Client) ListConsoleButtonAliases(ctx context.Context, authorization string) (map[string]struct{}, error) {
|
|
||||||
authorization = strings.TrimSpace(authorization)
|
|
||||||
if authorization == "" {
|
|
||||||
return nil, fmt.Errorf("authorization is blank")
|
|
||||||
}
|
|
||||||
endpoint := strings.TrimRight(c.cfg.Java.ConsoleBaseURL, "/") + "/account/buttons/aliases"
|
|
||||||
var resp resultResponse[[]string]
|
|
||||||
if err := c.getJSON(ctx, endpoint, javaConsoleHeaders(authorization), &resp); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
|
|
||||||
return nil, errors.New(firstNonEmpty(resp.ErrorMsg, resp.Message, "list console button aliases failed"))
|
|
||||||
}
|
|
||||||
aliases := resp.Body
|
|
||||||
if len(aliases) == 0 {
|
|
||||||
aliases = resp.Data
|
|
||||||
}
|
|
||||||
result := make(map[string]struct{}, len(aliases))
|
|
||||||
for _, alias := range aliases {
|
|
||||||
if alias = strings.TrimSpace(alias); alias != "" {
|
|
||||||
result[alias] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasConsoleMenuAlias 递归检查当前管理员菜单树,作为 Go 管理端读接口的服务端页面授权。
|
|
||||||
func (c *Client) HasConsoleMenuAlias(ctx context.Context, authorization, requiredAlias string) (bool, error) {
|
|
||||||
authorization = strings.TrimSpace(authorization)
|
|
||||||
if authorization == "" {
|
|
||||||
return false, fmt.Errorf("authorization is blank")
|
|
||||||
}
|
|
||||||
endpoint := strings.TrimRight(c.cfg.Java.ConsoleBaseURL, "/") + "/account/menus"
|
|
||||||
var resp resultResponse[[]ConsoleMenu]
|
|
||||||
if err := c.getJSON(ctx, endpoint, javaConsoleHeaders(authorization), &resp); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
|
|
||||||
return false, errors.New(firstNonEmpty(resp.ErrorMsg, resp.Message, "list console menus failed"))
|
|
||||||
}
|
|
||||||
menus := resp.Body
|
|
||||||
if len(menus) == 0 {
|
|
||||||
menus = resp.Data
|
|
||||||
}
|
|
||||||
var contains func([]ConsoleMenu) bool
|
|
||||||
contains = func(items []ConsoleMenu) bool {
|
|
||||||
for _, item := range items {
|
|
||||||
if strings.TrimSpace(item.Alias) == requiredAlias || contains(item.Childrens) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return contains(menus), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func javaConsoleHeaders(authorization string) http.Header {
|
func javaConsoleHeaders(authorization string) http.Header {
|
||||||
headers := http.Header{}
|
headers := http.Header{}
|
||||||
headers.Set("Authorization", strings.TrimSpace(authorization))
|
headers.Set("Authorization", strings.TrimSpace(authorization))
|
||||||
@ -723,22 +618,6 @@ func javaAppHeadersFromAuthorization(authorization string) http.Header {
|
|||||||
return headers
|
return headers
|
||||||
}
|
}
|
||||||
|
|
||||||
// javaInternalAppHeaders 为没有终端 token 的服务端查询注入可信用户上下文,例如榜单隐身能力检查。
|
|
||||||
func javaInternalAppHeaders(sysOrigin string, userID int64) http.Header {
|
|
||||||
sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin))
|
|
||||||
if sysOrigin == "" {
|
|
||||||
sysOrigin = "LIKEI"
|
|
||||||
}
|
|
||||||
headers := http.Header{}
|
|
||||||
headers.Set("Req-Sys-Origin", "origin="+sysOrigin+";originChild="+sysOrigin)
|
|
||||||
headers.Set("Req-Client", "Android")
|
|
||||||
headers.Set("Req-App-Intel", "version=1.0.0;build=100;channel=internal;model=go-service;sysVersion=go")
|
|
||||||
headers.Set("Req-Zone", "Asia/Shanghai")
|
|
||||||
headers.Set("Req-Imei", "go-service")
|
|
||||||
headers.Set("Req-Inner-Internal", fmt.Sprintf("time=%d;uid=%d;", time.Now().UnixMilli(), userID))
|
|
||||||
return headers
|
|
||||||
}
|
|
||||||
|
|
||||||
func trimBearerToken(authorization string) string {
|
func trimBearerToken(authorization string) string {
|
||||||
token := strings.TrimSpace(authorization)
|
token := strings.TrimSpace(authorization)
|
||||||
if len(token) >= 7 && strings.EqualFold(token[:7], "Bearer ") {
|
if len(token) >= 7 && strings.EqualFold(token[:7], "Bearer ") {
|
||||||
@ -756,40 +635,7 @@ func (c *Client) GrantProps(ctx context.Context, req GrantPropsRequest) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) SendActivityReward(ctx context.Context, req SendActivityRewardRequest) error {
|
func (c *Client) SendActivityReward(ctx context.Context, req SendActivityRewardRequest) error {
|
||||||
var resp resultResponse[any]
|
return c.postJSON(ctx, c.cfg.Java.OtherBaseURL+"/props-activity-cnf/client/sendActivityReward", req, nil, nil)
|
||||||
if err := c.postJSON(ctx, c.cfg.Java.OtherBaseURL+"/props-activity-cnf/client/sendActivityReward", req, nil, &resp); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
|
|
||||||
message := strings.TrimSpace(resp.Message)
|
|
||||||
if message == "" {
|
|
||||||
message = "send activity reward failed"
|
|
||||||
}
|
|
||||||
return errors.New(message)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendFrozenActivityReward 通过 Java 已有的 PrizeDescribe 接口发放一个冻结奖励项。
|
|
||||||
func (c *Client) SendFrozenActivityReward(ctx context.Context, req SendFrozenActivityRewardRequest) error {
|
|
||||||
if req.TrackID <= 0 || req.AcceptUserID <= 0 || len(req.Prizes) != 1 {
|
|
||||||
return fmt.Errorf("invalid frozen activity reward request")
|
|
||||||
}
|
|
||||||
var resp resultResponse[any]
|
|
||||||
if err := c.postJSON(ctx, strings.TrimRight(c.cfg.Java.OtherBaseURL, "/")+"/props/tool/client/sendProps", req, nil, &resp); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
|
|
||||||
message := strings.TrimSpace(resp.ErrorMsg)
|
|
||||||
if message == "" {
|
|
||||||
message = strings.TrimSpace(resp.Message)
|
|
||||||
}
|
|
||||||
if message == "" {
|
|
||||||
message = "send frozen activity reward failed"
|
|
||||||
}
|
|
||||||
return errors.New(message)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GivePropsBackpack(ctx context.Context, req GivePropsBackpackRequest) error {
|
func (c *Client) GivePropsBackpack(ctx context.Context, req GivePropsBackpackRequest) error {
|
||||||
@ -852,154 +698,15 @@ func (c *Client) SendOfficialNoticeCustomize(ctx context.Context, req OfficialNo
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) {
|
func (c *Client) GetRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) {
|
||||||
detail, err := c.getInnerRewardGroupDetail(ctx, groupID)
|
endpoint := c.cfg.Java.OtherBaseURL + "/props-activity/reward/group/client/getByGroupId?id=" + url.QueryEscape(strconv.FormatInt(groupID, 10))
|
||||||
if err == nil && rewardGroupDetailHasContent(detail) {
|
|
||||||
return detail, nil
|
|
||||||
}
|
|
||||||
if fallback, fallbackErr := c.getConsoleRewardGroupDetail(ctx, groupID); fallbackErr == nil && rewardGroupDetailHasContent(fallback) {
|
|
||||||
return fallback, nil
|
|
||||||
} else if err == nil && fallbackErr != nil {
|
|
||||||
return detail, nil
|
|
||||||
}
|
|
||||||
return detail, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) getInnerRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) {
|
|
||||||
endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/props-activity/reward/group/client/getByGroupId?id=" + url.QueryEscape(strconv.FormatInt(groupID, 10))
|
|
||||||
var resp resultResponse[RewardGroupDetail]
|
var resp resultResponse[RewardGroupDetail]
|
||||||
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
|
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
|
||||||
return RewardGroupDetail{}, err
|
return RewardGroupDetail{}, err
|
||||||
}
|
}
|
||||||
detail := resp.Body
|
if int64(resp.Body.ID) == 0 {
|
||||||
if int64(detail.ID) == 0 {
|
|
||||||
detail = resp.Data
|
|
||||||
}
|
|
||||||
if int64(detail.ID) == 0 {
|
|
||||||
return RewardGroupDetail{}, fmt.Errorf("empty reward group response")
|
return RewardGroupDetail{}, fmt.Errorf("empty reward group response")
|
||||||
}
|
}
|
||||||
return detail, nil
|
return resp.Body, nil
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) getConsoleRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) {
|
|
||||||
baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.Java.ConsoleBaseURL), "/")
|
|
||||||
if baseURL == "" {
|
|
||||||
return RewardGroupDetail{}, fmt.Errorf("console base url is empty")
|
|
||||||
}
|
|
||||||
endpoint := baseURL + "/props/activity/reward/group/" + url.PathEscape(strconv.FormatInt(groupID, 10))
|
|
||||||
var detail RewardGroupDetail
|
|
||||||
if err := c.getJSON(ctx, endpoint, nil, &detail); err != nil {
|
|
||||||
return RewardGroupDetail{}, err
|
|
||||||
}
|
|
||||||
if int64(detail.ID) == 0 {
|
|
||||||
return RewardGroupDetail{}, fmt.Errorf("empty reward group response")
|
|
||||||
}
|
|
||||||
return detail, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rewardGroupDetailHasContent(detail RewardGroupDetail) bool {
|
|
||||||
return int64(detail.ID) > 0 && len(detail.RewardConfigList) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) FindRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (RewardGroupDetail, error) {
|
|
||||||
groupName := strings.TrimSpace(name)
|
|
||||||
if groupName == "" {
|
|
||||||
return RewardGroupDetail{}, fmt.Errorf("reward group name is empty")
|
|
||||||
}
|
|
||||||
if detail, err := c.findInnerRewardGroupDetail(ctx, sysOrigin, groupName); err == nil {
|
|
||||||
if hydrated, hydrateErr := c.hydrateRewardGroupDetail(ctx, detail); hydrateErr == nil && rewardGroupDetailHasContent(hydrated) {
|
|
||||||
return hydrated, nil
|
|
||||||
}
|
|
||||||
if rewardGroupDetailHasContent(detail) {
|
|
||||||
return detail, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
detail, err := c.findConsoleRewardGroupDetail(ctx, sysOrigin, groupName)
|
|
||||||
if err != nil {
|
|
||||||
return RewardGroupDetail{}, err
|
|
||||||
}
|
|
||||||
if hydrated, hydrateErr := c.hydrateRewardGroupDetail(ctx, detail); hydrateErr == nil && rewardGroupDetailHasContent(hydrated) {
|
|
||||||
return hydrated, nil
|
|
||||||
}
|
|
||||||
return detail, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) hydrateRewardGroupDetail(ctx context.Context, detail RewardGroupDetail) (RewardGroupDetail, error) {
|
|
||||||
if rewardGroupDetailHasContent(detail) {
|
|
||||||
return detail, nil
|
|
||||||
}
|
|
||||||
groupID := int64(detail.ID)
|
|
||||||
if groupID <= 0 {
|
|
||||||
return detail, fmt.Errorf("empty reward group id")
|
|
||||||
}
|
|
||||||
if hydrated, err := c.getInnerRewardGroupDetail(ctx, groupID); err == nil && rewardGroupDetailHasContent(hydrated) {
|
|
||||||
return hydrated, nil
|
|
||||||
}
|
|
||||||
return c.getConsoleRewardGroupDetail(ctx, groupID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) findInnerRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (RewardGroupDetail, error) {
|
|
||||||
endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/props-activity/reward/group/client/page"
|
|
||||||
payload := map[string]any{
|
|
||||||
"current": 1,
|
|
||||||
"cursor": 1,
|
|
||||||
"limit": 10,
|
|
||||||
"name": name,
|
|
||||||
"shelfStatus": true,
|
|
||||||
"size": 10,
|
|
||||||
}
|
|
||||||
if origin := strings.TrimSpace(sysOrigin); origin != "" {
|
|
||||||
payload["sysOrigin"] = origin
|
|
||||||
}
|
|
||||||
var resp resultResponse[rewardGroupPage]
|
|
||||||
if err := c.postJSON(ctx, endpoint, payload, nil, &resp); err != nil {
|
|
||||||
return RewardGroupDetail{}, err
|
|
||||||
}
|
|
||||||
page := resp.Body
|
|
||||||
if len(page.Records) == 0 {
|
|
||||||
page = resp.Data
|
|
||||||
}
|
|
||||||
return pickRewardGroupDetailByName(page.Records, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) findConsoleRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (RewardGroupDetail, error) {
|
|
||||||
baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.Java.ConsoleBaseURL), "/")
|
|
||||||
if baseURL == "" {
|
|
||||||
return RewardGroupDetail{}, fmt.Errorf("console base url is empty")
|
|
||||||
}
|
|
||||||
values := url.Values{}
|
|
||||||
values.Set("current", "1")
|
|
||||||
values.Set("cursor", "1")
|
|
||||||
values.Set("limit", "10")
|
|
||||||
values.Set("name", name)
|
|
||||||
values.Set("shelfStatus", "true")
|
|
||||||
values.Set("size", "10")
|
|
||||||
if origin := strings.TrimSpace(sysOrigin); origin != "" {
|
|
||||||
values.Set("sysOrigin", origin)
|
|
||||||
}
|
|
||||||
endpoint := baseURL + "/props/activity/reward/group/page?" + values.Encode()
|
|
||||||
var page rewardGroupPage
|
|
||||||
if err := c.getJSON(ctx, endpoint, nil, &page); err != nil {
|
|
||||||
return RewardGroupDetail{}, err
|
|
||||||
}
|
|
||||||
return pickRewardGroupDetailByName(page.Records, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func pickRewardGroupDetailByName(records []RewardGroupDetail, name string) (RewardGroupDetail, error) {
|
|
||||||
if len(records) == 0 {
|
|
||||||
return RewardGroupDetail{}, fmt.Errorf("empty reward group page response")
|
|
||||||
}
|
|
||||||
target := strings.TrimSpace(name)
|
|
||||||
for _, record := range records {
|
|
||||||
if strings.TrimSpace(record.Name) == target {
|
|
||||||
return record, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, record := range records {
|
|
||||||
if rewardGroupDetailHasContent(record) {
|
|
||||||
return record, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return records[0], nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]UserTotalRecharge, error) {
|
func (c *Client) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]UserTotalRecharge, error) {
|
||||||
@ -1114,8 +821,8 @@ func (c *Client) ChangeFreightBalance(ctx context.Context, req FreightBalanceCha
|
|||||||
if err := c.postJSON(ctx, endpoint, req, nil, &resp); err != nil {
|
if err := c.postJSON(ctx, endpoint, req, nil, &resp); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
|
if resp.Success != nil && !*resp.Success {
|
||||||
message := strings.TrimSpace(firstNonEmpty(resp.ErrorMsg, resp.Message))
|
message := strings.TrimSpace(resp.Message)
|
||||||
if message == "" {
|
if message == "" {
|
||||||
message = "freight balance change failed"
|
message = "freight balance change failed"
|
||||||
}
|
}
|
||||||
@ -1174,90 +881,6 @@ func (c *Client) GetUserProfile(ctx context.Context, userID int64) (UserProfile,
|
|||||||
return resp.Body, nil
|
return resp.Body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MapUserProfiles 批量查询用户资料,避免排行榜逐用户回源造成请求瀑布。
|
|
||||||
func (c *Client) MapUserProfiles(ctx context.Context, userIDs []int64) (map[int64]UserProfile, error) {
|
|
||||||
result := make(map[int64]UserProfile, len(userIDs))
|
|
||||||
if len(userIDs) == 0 {
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/user-profile/client/mapByUserIds"
|
|
||||||
var resp resultResponse[map[string]UserProfile]
|
|
||||||
if err := c.postJSON(ctx, endpoint, userIDs, nil, &resp); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
|
|
||||||
message := strings.TrimSpace(firstNonEmpty(resp.Message, resp.ErrorMsg))
|
|
||||||
if message == "" {
|
|
||||||
message = "map user profiles failed"
|
|
||||||
}
|
|
||||||
return nil, errors.New(message)
|
|
||||||
}
|
|
||||||
values := resp.Body
|
|
||||||
if len(values) == 0 {
|
|
||||||
values = resp.Data
|
|
||||||
}
|
|
||||||
for key, profile := range values {
|
|
||||||
userID, err := strconv.ParseInt(strings.TrimSpace(key), 10, 64)
|
|
||||||
if err != nil || userID <= 0 {
|
|
||||||
userID = int64(profile.ID)
|
|
||||||
}
|
|
||||||
if userID > 0 {
|
|
||||||
result[userID] = profile
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUserMysteriousInvisibility 复用 Yumi Java 已有 VIP 能力接口;调用失败由榜单层按隐私优先策略处理。
|
|
||||||
func (c *Client) GetUserMysteriousInvisibility(ctx context.Context, sysOrigin string, userID int64) (bool, error) {
|
|
||||||
if userID <= 0 {
|
|
||||||
return false, fmt.Errorf("userId is required")
|
|
||||||
}
|
|
||||||
endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/user/vip/ability"
|
|
||||||
var resp resultResponse[UserVipAbility]
|
|
||||||
if err := c.getJSON(ctx, endpoint, javaInternalAppHeaders(sysOrigin, userID), &resp); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if (resp.Success != nil && !*resp.Success) || (resp.Status != nil && !*resp.Status) {
|
|
||||||
message := strings.TrimSpace(firstNonEmpty(resp.ErrorMsg, resp.Message))
|
|
||||||
if message == "" {
|
|
||||||
message = "get user vip ability failed"
|
|
||||||
}
|
|
||||||
return false, errors.New(message)
|
|
||||||
}
|
|
||||||
return resp.Body.MysteriousInvisibility, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateUserCountry 只提交稳定的用户 ID 和国家 ID,国家码、名称以及并发锁统一由 Java other 处理。
|
|
||||||
func (c *Client) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error {
|
|
||||||
if userID <= 0 {
|
|
||||||
return fmt.Errorf("userId is required")
|
|
||||||
}
|
|
||||||
if countryID <= 0 {
|
|
||||||
return fmt.Errorf("countryId is required")
|
|
||||||
}
|
|
||||||
endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/user-profile/client/updateSelectiveById"
|
|
||||||
payload := struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
CountryID int64 `json:"countryId"`
|
|
||||||
}{
|
|
||||||
ID: userID,
|
|
||||||
CountryID: countryID,
|
|
||||||
}
|
|
||||||
var resp resultResponse[any]
|
|
||||||
if err := c.postJSON(ctx, endpoint, payload, nil, &resp); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if resp.Success != nil && !*resp.Success {
|
|
||||||
message := strings.TrimSpace(resp.Message)
|
|
||||||
if message == "" {
|
|
||||||
message = "user country update failed"
|
|
||||||
}
|
|
||||||
return errors.New(message)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) {
|
func (c *Client) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) {
|
||||||
if userID <= 0 {
|
if userID <= 0 {
|
||||||
return UserRegion{}, fmt.Errorf("userId is required")
|
return UserRegion{}, fmt.Errorf("userId is required")
|
||||||
@ -1274,21 +897,6 @@ func (c *Client) GetUserRegion(ctx context.Context, userID int64, authorization
|
|||||||
return resp.Body, nil
|
return resp.Body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetUserLevel(ctx context.Context, sysOrigin string, userID int64) (UserLevel, error) {
|
|
||||||
if userID <= 0 {
|
|
||||||
return UserLevel{}, fmt.Errorf("userId is required")
|
|
||||||
}
|
|
||||||
values := url.Values{}
|
|
||||||
values.Set("sysOrigin", strings.TrimSpace(sysOrigin))
|
|
||||||
values.Set("userId", strconv.FormatInt(userID, 10))
|
|
||||||
endpoint := c.cfg.Java.OtherBaseURL + "/user-level/client/getLevelByUserId?" + values.Encode()
|
|
||||||
var resp resultResponse[UserLevel]
|
|
||||||
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
|
|
||||||
return UserLevel{}, err
|
|
||||||
}
|
|
||||||
return resp.Body, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
|
func (c *Client) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
|
||||||
countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
|
countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
|
||||||
if countryCode == "" {
|
if countryCode == "" {
|
||||||
|
|||||||
@ -77,131 +77,3 @@ func TestResolveRegionCodeByCountryCodeUsesRegionConfig(t *testing.T) {
|
|||||||
t.Fatalf("region config calls = %d, want cached 1", calls)
|
t.Fatalf("region config calls = %d, want cached 1", calls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateUserCountryPostsOnlyStableIDs(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost || r.URL.Path != "/user-profile/client/updateSelectiveById" {
|
|
||||||
t.Fatalf("request = %s %s, want POST /user-profile/client/updateSelectiveById", r.Method, r.URL.Path)
|
|
||||||
}
|
|
||||||
var payload map[string]json.RawMessage
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
||||||
t.Fatalf("decode request: %v", err)
|
|
||||||
}
|
|
||||||
if len(payload) != 2 || string(payload["id"]) != "2" || string(payload["countryId"]) != "11" {
|
|
||||||
t.Fatalf("payload = %s, want only id and countryId", payload)
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"success":true}`))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client := New(config.Config{
|
|
||||||
HTTP: config.HTTPConfig{Timeout: time.Second},
|
|
||||||
Java: config.JavaConfig{OtherBaseURL: server.URL},
|
|
||||||
})
|
|
||||||
if err := client.UpdateUserCountry(context.Background(), 2, 11); err != nil {
|
|
||||||
t.Fatalf("UpdateUserCountry() error = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetRewardGroupDetailAcceptsDataWrapper(t *testing.T) {
|
|
||||||
groupID := int64(2056981527522242600)
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet || r.URL.Path != "/props-activity/reward/group/client/getByGroupId" {
|
|
||||||
t.Fatalf("request = %s %s, want GET /props-activity/reward/group/client/getByGroupId", r.Method, r.URL.Path)
|
|
||||||
}
|
|
||||||
if r.URL.Query().Get("id") != "2056981527522242600" {
|
|
||||||
t.Fatalf("id query = %q", r.URL.Query().Get("id"))
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"data":{"id":"2056981527522242600","name":"累计充值10美金","rewardConfigList":[{"id":"1","type":"PROPS","detailType":"AVATAR_FRAME","name":"Frame","quantity":"1","sourceUrl":"https://example.com/frame.svga"}]}}`))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client := New(config.Config{
|
|
||||||
HTTP: config.HTTPConfig{Timeout: time.Second},
|
|
||||||
Java: config.JavaConfig{OtherBaseURL: server.URL},
|
|
||||||
})
|
|
||||||
got, err := client.GetRewardGroupDetail(context.Background(), groupID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetRewardGroupDetail() error = %v", err)
|
|
||||||
}
|
|
||||||
if int64(got.ID) != groupID || len(got.RewardConfigList) != 1 {
|
|
||||||
t.Fatalf("detail = %+v, want group with one reward item", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetRewardGroupDetailFallsBackToConsoleEndpoint(t *testing.T) {
|
|
||||||
groupID := int64(2056981527522242600)
|
|
||||||
var innerCalls, consoleCalls int
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/props-activity/reward/group/client/getByGroupId", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
innerCalls++
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"body":{"id":"2056981527522242600","name":"累计充值10美金","rewardConfigList":[]}}`))
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/console/props/activity/reward/group/2056981527522242600", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
consoleCalls++
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"id":"2056981527522242600","name":"累计充值10美金","rewardConfigList":[{"id":"1","type":"GIFT","name":"Gift","quantity":"1","cover":"https://example.com/gift.png"}]}`))
|
|
||||||
})
|
|
||||||
server := httptest.NewServer(mux)
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client := New(config.Config{
|
|
||||||
HTTP: config.HTTPConfig{Timeout: time.Second},
|
|
||||||
Java: config.JavaConfig{
|
|
||||||
OtherBaseURL: server.URL,
|
|
||||||
ConsoleBaseURL: server.URL + "/console",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
got, err := client.GetRewardGroupDetail(context.Background(), groupID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetRewardGroupDetail() error = %v", err)
|
|
||||||
}
|
|
||||||
if innerCalls != 1 || consoleCalls != 1 {
|
|
||||||
t.Fatalf("calls inner=%d console=%d, want 1/1", innerCalls, consoleCalls)
|
|
||||||
}
|
|
||||||
if len(got.RewardConfigList) != 1 || got.RewardConfigList[0].Name != "Gift" {
|
|
||||||
t.Fatalf("detail = %+v, want fallback reward item", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindRewardGroupDetailHydratesPageRecord(t *testing.T) {
|
|
||||||
groupID := int64(2056980937278816399)
|
|
||||||
var pageCalls, detailCalls int
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/props-activity/reward/group/client/page", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
pageCalls++
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
t.Fatalf("method = %s, want POST", r.Method)
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"body":{"records":[{"id":"2056980937278816399","name":"累充活动$100","rewardConfigList":[]}]}}`))
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/props-activity/reward/group/client/getByGroupId", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
detailCalls++
|
|
||||||
if r.URL.Query().Get("id") != "2056980937278816399" {
|
|
||||||
t.Fatalf("id query = %q", r.URL.Query().Get("id"))
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"body":{"id":"2056980937278816399","name":"累充活动$100","rewardConfigList":[{"id":"1","type":"BADGE","name":"Badge","quantity":"30","cover":"https://example.com/badge.png"}]}}`))
|
|
||||||
})
|
|
||||||
server := httptest.NewServer(mux)
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client := New(config.Config{
|
|
||||||
HTTP: config.HTTPConfig{Timeout: time.Second},
|
|
||||||
Java: config.JavaConfig{OtherBaseURL: server.URL},
|
|
||||||
})
|
|
||||||
got, err := client.FindRewardGroupDetail(context.Background(), "LIKEI", "累充活动$100")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("FindRewardGroupDetail() error = %v", err)
|
|
||||||
}
|
|
||||||
if pageCalls != 1 || detailCalls != 1 {
|
|
||||||
t.Fatalf("calls page=%d detail=%d, want 1/1", pageCalls, detailCalls)
|
|
||||||
}
|
|
||||||
if int64(got.ID) != groupID || len(got.RewardConfigList) != 1 {
|
|
||||||
t.Fatalf("detail = %+v, want hydrated reward group", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,169 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql/driver"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"math/big"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Decimal24_2 用规范十进制文本承载 MySQL DECIMAL(24,2),避免金额经过 float64 丢精度。
|
|
||||||
// JSON 输出字符串以保护 22 位整数精度;数据库读写通过 Scanner/Valuer 完成。
|
|
||||||
type Decimal24_2 string
|
|
||||||
|
|
||||||
func ParseDecimal24_2(value string) (Decimal24_2, error) {
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
if value == "" {
|
|
||||||
return "", fmt.Errorf("decimal is blank")
|
|
||||||
}
|
|
||||||
negative := false
|
|
||||||
if value[0] == '-' {
|
|
||||||
negative, value = true, value[1:]
|
|
||||||
} else if value[0] == '+' {
|
|
||||||
return "", fmt.Errorf("decimal plus sign is not supported")
|
|
||||||
}
|
|
||||||
if value == "" || strings.ContainsAny(value, "eE") {
|
|
||||||
return "", fmt.Errorf("invalid decimal")
|
|
||||||
}
|
|
||||||
parts := strings.Split(value, ".")
|
|
||||||
if len(parts) > 2 || parts[0] == "" {
|
|
||||||
return "", fmt.Errorf("invalid decimal")
|
|
||||||
}
|
|
||||||
for _, char := range parts[0] {
|
|
||||||
if char < '0' || char > '9' {
|
|
||||||
return "", fmt.Errorf("invalid decimal")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
integer := strings.TrimLeft(parts[0], "0")
|
|
||||||
if integer == "" {
|
|
||||||
integer = "0"
|
|
||||||
}
|
|
||||||
fraction := ""
|
|
||||||
if len(parts) == 2 {
|
|
||||||
fraction = parts[1]
|
|
||||||
for _, char := range fraction {
|
|
||||||
if char < '0' || char > '9' {
|
|
||||||
return "", fmt.Errorf("invalid decimal")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// BigDecimal.stripTrailingZeros 后 scale<=2 的值均可接受,例如 1.2300。
|
|
||||||
if len(fraction) > 2 && strings.Trim(fraction[2:], "0") != "" {
|
|
||||||
return "", fmt.Errorf("decimal scale exceeds 2")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(integer) > 22 {
|
|
||||||
return "", fmt.Errorf("decimal integer digits exceed 22")
|
|
||||||
}
|
|
||||||
if len(fraction) > 2 {
|
|
||||||
fraction = fraction[:2]
|
|
||||||
}
|
|
||||||
fraction += strings.Repeat("0", 2-len(fraction))
|
|
||||||
if integer == "0" && fraction == "00" {
|
|
||||||
negative = false
|
|
||||||
}
|
|
||||||
prefix := ""
|
|
||||||
if negative {
|
|
||||||
prefix = "-"
|
|
||||||
}
|
|
||||||
return Decimal24_2(prefix + integer + "." + fraction), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Decimal24_2) UnmarshalJSON(data []byte) error {
|
|
||||||
raw := strings.TrimSpace(string(data))
|
|
||||||
if raw == "" || raw == "null" || raw == `""` {
|
|
||||||
*d = DecimalZero()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) {
|
|
||||||
var value string
|
|
||||||
if err := json.Unmarshal(data, &value); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
raw = value
|
|
||||||
}
|
|
||||||
parsed, err := ParseDecimal24_2(raw)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
*d = parsed
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d Decimal24_2) MarshalJSON() ([]byte, error) { return json.Marshal(d.String()) }
|
|
||||||
|
|
||||||
func (d *Decimal24_2) Scan(value any) error {
|
|
||||||
var raw string
|
|
||||||
switch typed := value.(type) {
|
|
||||||
case nil:
|
|
||||||
raw = "0"
|
|
||||||
case []byte:
|
|
||||||
raw = string(typed)
|
|
||||||
case string:
|
|
||||||
raw = typed
|
|
||||||
case int64:
|
|
||||||
raw = fmt.Sprintf("%d", typed)
|
|
||||||
default:
|
|
||||||
raw = fmt.Sprint(typed)
|
|
||||||
}
|
|
||||||
parsed, err := ParseDecimal24_2(raw)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
*d = parsed
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d Decimal24_2) Value() (driver.Value, error) { return d.String(), nil }
|
|
||||||
|
|
||||||
func (d Decimal24_2) String() string {
|
|
||||||
if strings.TrimSpace(string(d)) == "" {
|
|
||||||
return "0.00"
|
|
||||||
}
|
|
||||||
return string(d)
|
|
||||||
}
|
|
||||||
|
|
||||||
func DecimalZero() Decimal24_2 { return Decimal24_2("0.00") }
|
|
||||||
|
|
||||||
func (d Decimal24_2) Positive() bool { return d.cents().Sign() > 0 }
|
|
||||||
|
|
||||||
func (d Decimal24_2) Compare(other Decimal24_2) int { return d.cents().Cmp(other.cents()) }
|
|
||||||
|
|
||||||
func (d Decimal24_2) MultiplyInt(multiplier int64) (Decimal24_2, error) {
|
|
||||||
if multiplier < 0 {
|
|
||||||
return "", fmt.Errorf("decimal multiplier must not be negative")
|
|
||||||
}
|
|
||||||
value := new(big.Int).Mul(d.cents(), big.NewInt(multiplier))
|
|
||||||
if len(new(big.Int).Abs(new(big.Int).Set(value)).String()) > 24 {
|
|
||||||
return "", fmt.Errorf("decimal exceeds DECIMAL(24,2)")
|
|
||||||
}
|
|
||||||
return decimalFromCents(value), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d Decimal24_2) cents() *big.Int {
|
|
||||||
raw := d.String()
|
|
||||||
negative := strings.HasPrefix(raw, "-")
|
|
||||||
raw = strings.TrimPrefix(raw, "-")
|
|
||||||
digits := strings.ReplaceAll(raw, ".", "")
|
|
||||||
value, ok := new(big.Int).SetString(digits, 10)
|
|
||||||
if !ok {
|
|
||||||
return new(big.Int)
|
|
||||||
}
|
|
||||||
if negative {
|
|
||||||
value.Neg(value)
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func decimalFromCents(value *big.Int) Decimal24_2 {
|
|
||||||
negative := value.Sign() < 0
|
|
||||||
abs := new(big.Int).Abs(new(big.Int).Set(value)).String()
|
|
||||||
if len(abs) < 3 {
|
|
||||||
abs = strings.Repeat("0", 3-len(abs)) + abs
|
|
||||||
}
|
|
||||||
result := abs[:len(abs)-2] + "." + abs[len(abs)-2:]
|
|
||||||
if negative && result != "0.00" {
|
|
||||||
result = "-" + result
|
|
||||||
}
|
|
||||||
return Decimal24_2(result)
|
|
||||||
}
|
|
||||||
@ -7,7 +7,6 @@ type FirstRechargeRewardConfig struct {
|
|||||||
ID int64 `gorm:"column:id;primaryKey"`
|
ID int64 `gorm:"column:id;primaryKey"`
|
||||||
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_first_recharge_reward_sys_origin"`
|
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_first_recharge_reward_sys_origin"`
|
||||||
Enabled bool `gorm:"column:enabled;index:idx_first_recharge_reward_enabled"`
|
Enabled bool `gorm:"column:enabled;index:idx_first_recharge_reward_enabled"`
|
||||||
MinAppVersion string `gorm:"column:min_app_version;size:32"`
|
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
UpdateTime time.Time `gorm:"column:update_time"`
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
}
|
}
|
||||||
@ -20,7 +19,6 @@ type FirstRechargeRewardLevel struct {
|
|||||||
ConfigID int64 `gorm:"column:config_id;uniqueIndex:uk_first_recharge_reward_level,priority:1;uniqueIndex:uk_first_recharge_reward_amount,priority:1;index:idx_first_recharge_reward_level_config"`
|
ConfigID int64 `gorm:"column:config_id;uniqueIndex:uk_first_recharge_reward_level,priority:1;uniqueIndex:uk_first_recharge_reward_amount,priority:1;index:idx_first_recharge_reward_level_config"`
|
||||||
Level int `gorm:"column:level;uniqueIndex:uk_first_recharge_reward_level,priority:2"`
|
Level int `gorm:"column:level;uniqueIndex:uk_first_recharge_reward_level,priority:2"`
|
||||||
RechargeAmountCents int64 `gorm:"column:recharge_amount_cents;uniqueIndex:uk_first_recharge_reward_amount,priority:2"`
|
RechargeAmountCents int64 `gorm:"column:recharge_amount_cents;uniqueIndex:uk_first_recharge_reward_amount,priority:2"`
|
||||||
GoogleProductID string `gorm:"column:google_product_id;size:128;index:idx_first_recharge_reward_level_google_product"`
|
|
||||||
RewardGroupID int64 `gorm:"column:reward_group_id"`
|
RewardGroupID int64 `gorm:"column:reward_group_id"`
|
||||||
RewardGroupName string `gorm:"column:reward_group_name;size:255"`
|
RewardGroupName string `gorm:"column:reward_group_name;size:255"`
|
||||||
Enabled bool `gorm:"column:enabled"`
|
Enabled bool `gorm:"column:enabled"`
|
||||||
@ -48,7 +46,6 @@ type FirstRechargeRewardGrantRecord struct {
|
|||||||
Level int `gorm:"column:level"`
|
Level int `gorm:"column:level"`
|
||||||
PayPlatform string `gorm:"column:pay_platform;size:64"`
|
PayPlatform string `gorm:"column:pay_platform;size:64"`
|
||||||
PaymentMethod string `gorm:"column:payment_method;size:32"`
|
PaymentMethod string `gorm:"column:payment_method;size:32"`
|
||||||
GoogleProductID string `gorm:"column:google_product_id;size:128;index:idx_first_recharge_reward_grant_google_product"`
|
|
||||||
SourceOrderID string `gorm:"column:source_order_id;size:128;index:idx_first_recharge_reward_order"`
|
SourceOrderID string `gorm:"column:source_order_id;size:128;index:idx_first_recharge_reward_order"`
|
||||||
RewardGroupID int64 `gorm:"column:reward_group_id"`
|
RewardGroupID int64 `gorm:"column:reward_group_id"`
|
||||||
RewardGroupName string `gorm:"column:reward_group_name;size:255"`
|
RewardGroupName string `gorm:"column:reward_group_name;size:255"`
|
||||||
|
|||||||
@ -1,41 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// HotgameProviderConfig 保存每个系统、每个环境的热游回调验签配置。
|
|
||||||
type HotgameProviderConfig struct {
|
|
||||||
ID int64 `gorm:"column:id;primaryKey"` // 主键
|
|
||||||
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_hotgame_provider_sys_origin_profile,priority:1;index:idx_hotgame_provider_active,priority:1"` // 系统标识
|
|
||||||
Profile string `gorm:"column:profile;size:32;default:PROD;uniqueIndex:uk_hotgame_provider_sys_origin_profile,priority:2"` // 配置环境:PROD/TEST
|
|
||||||
Active bool `gorm:"column:active;index:idx_hotgame_provider_active,priority:2"` // app 当前启用环境
|
|
||||||
AppKey string `gorm:"column:app_key;size:255"` // 热游回调 MD5 key
|
|
||||||
CallbackBaseURL string `gorm:"column:callback_base_url;size:1024"` // 提供给热游配置的回调域名
|
|
||||||
TestUID string `gorm:"column:test_uid;size:64"` // 联调用 UID 覆盖
|
|
||||||
TestToken string `gorm:"column:test_token;size:1024"` // 联调用 token 覆盖
|
|
||||||
CreateTime time.Time `gorm:"column:create_time"` // 创建时间
|
|
||||||
UpdateTime time.Time `gorm:"column:update_time;index:idx_hotgame_provider_active,priority:3"` // 更新时间
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableName 返回热游接入配置表名。
|
|
||||||
func (HotgameProviderConfig) TableName() string { return "hotgame_provider_config" }
|
|
||||||
|
|
||||||
// HotgameGameCatalog 保存从热游对接表导入的游戏目录。
|
|
||||||
type HotgameGameCatalog struct {
|
|
||||||
ID int64 `gorm:"column:id;primaryKey"` // 主键
|
|
||||||
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_hotgame_catalog_sys_profile_vendor_game,priority:1;index:idx_hotgame_catalog_profile_internal_game,priority:1"` // 系统标识
|
|
||||||
Profile string `gorm:"column:profile;size:32;default:PROD;uniqueIndex:uk_hotgame_catalog_sys_profile_vendor_game,priority:2;index:idx_hotgame_catalog_profile_internal_game,priority:2"` // 配置环境:PROD/TEST
|
|
||||||
InternalGameID string `gorm:"column:internal_game_id;size:64;index:idx_hotgame_catalog_profile_internal_game,priority:3"` // app 内部游戏 ID
|
|
||||||
VendorGameID string `gorm:"column:vendor_game_id;size:64;uniqueIndex:uk_hotgame_catalog_sys_profile_vendor_game,priority:3"` // 热游游戏 ID
|
|
||||||
Name string `gorm:"column:name;size:128"` // 游戏名称
|
|
||||||
Cover string `gorm:"column:cover;size:1024"` // 封面地址
|
|
||||||
PreviewURL string `gorm:"column:preview_url;size:1024"` // 七分屏/预览入口
|
|
||||||
DownloadURL string `gorm:"column:download_url;size:1024"` // 实际启动入口
|
|
||||||
PackageVersion string `gorm:"column:package_version;size:32"` // 版本号
|
|
||||||
RawJSON string `gorm:"column:raw_json;type:longtext"` // 原始目录 JSON
|
|
||||||
Status string `gorm:"column:status;size:32;index:idx_hotgame_catalog_profile_internal_game,priority:4"` // 目录状态
|
|
||||||
CreateTime time.Time `gorm:"column:create_time"` // 创建时间
|
|
||||||
UpdateTime time.Time `gorm:"column:update_time"` // 更新时间
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableName 返回热游目录表名。
|
|
||||||
func (HotgameGameCatalog) TableName() string { return "hotgame_game_catalog" }
|
|
||||||
@ -116,19 +116,19 @@ func (TaskCenterEventDedup) TableName() string { return "task_center_event_dedup
|
|||||||
|
|
||||||
// TaskCenterEventArchiveOutbox 保存待归档到 COS 的原始事件。
|
// TaskCenterEventArchiveOutbox 保存待归档到 COS 的原始事件。
|
||||||
type TaskCenterEventArchiveOutbox struct {
|
type TaskCenterEventArchiveOutbox struct {
|
||||||
ID int64 `gorm:"column:id;primaryKey;index:idx_task_center_archive_claim_create,priority:3;index:idx_task_center_archive_claim_stale,priority:3;index:idx_task_center_game_king_backfill,priority:4"`
|
ID int64 `gorm:"column:id;primaryKey"`
|
||||||
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_task_center_archive_event,priority:1;index:idx_task_center_game_king_backfill,priority:1"`
|
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_task_center_archive_event,priority:1"`
|
||||||
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_task_center_archive_event,priority:2"`
|
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_task_center_archive_event,priority:2"`
|
||||||
EventType string `gorm:"column:event_type;size:64;index:idx_task_center_game_king_backfill,priority:2"`
|
EventType string `gorm:"column:event_type;size:64"`
|
||||||
UserID int64 `gorm:"column:user_id"`
|
UserID int64 `gorm:"column:user_id"`
|
||||||
OccurredAt time.Time `gorm:"column:occurred_at;index:idx_task_center_game_king_backfill,priority:3"`
|
OccurredAt time.Time `gorm:"column:occurred_at"`
|
||||||
RawJSON string `gorm:"column:raw_json;type:text"`
|
RawJSON string `gorm:"column:raw_json;type:text"`
|
||||||
Status string `gorm:"column:status;size:32;index:idx_task_center_archive_status_time,priority:1;index:idx_task_center_archive_claim_create,priority:1;index:idx_task_center_archive_claim_stale,priority:1"`
|
Status string `gorm:"column:status;size:32;index:idx_task_center_archive_status_time,priority:1"`
|
||||||
RetryCount int `gorm:"column:retry_count"`
|
RetryCount int `gorm:"column:retry_count"`
|
||||||
COSKey string `gorm:"column:cos_key;size:512"`
|
COSKey string `gorm:"column:cos_key;size:512"`
|
||||||
FailureReason string `gorm:"column:failure_reason;size:512"`
|
FailureReason string `gorm:"column:failure_reason;size:512"`
|
||||||
CreateTime time.Time `gorm:"column:create_time;index:idx_task_center_archive_status_time,priority:2;index:idx_task_center_archive_claim_create,priority:2"`
|
CreateTime time.Time `gorm:"column:create_time;index:idx_task_center_archive_status_time,priority:2"`
|
||||||
UpdateTime time.Time `gorm:"column:update_time;index:idx_task_center_archive_claim_stale,priority:2"`
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
ArchivedAt *time.Time `gorm:"column:archived_at"`
|
ArchivedAt *time.Time `gorm:"column:archived_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,54 +136,6 @@ func (TaskCenterEventArchiveOutbox) TableName() string {
|
|||||||
return "task_center_event_archive_outbox"
|
return "task_center_event_archive_outbox"
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskCenterArchiveManifest 保存 COS 对象的可验证清单。GC 只有在源对象和恢复包都达到
|
|
||||||
// VERIFIED 后才允许删行,避免把“PUT 返回成功”错误地当成可恢复承诺。
|
|
||||||
type TaskCenterArchiveManifest struct {
|
|
||||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
|
||||||
ObjectType string `gorm:"column:object_type;size:32;index:idx_task_center_archive_manifest_run,priority:2"`
|
|
||||||
RunID string `gorm:"column:run_id;size:64;index:idx_task_center_archive_manifest_run,priority:1"`
|
|
||||||
COSKey string `gorm:"column:cos_key;size:512;uniqueIndex:uk_task_center_archive_manifest_cos_key"`
|
|
||||||
ParentCOSKey string `gorm:"column:parent_cos_key;size:512"`
|
|
||||||
SHA256 string `gorm:"column:sha256;size:64"`
|
|
||||||
ETag string `gorm:"column:etag;size:128"`
|
|
||||||
CompressedSize int64 `gorm:"column:compressed_size"`
|
|
||||||
RowCount int `gorm:"column:row_count"`
|
|
||||||
MinOutboxID *int64 `gorm:"column:min_outbox_id"`
|
|
||||||
MaxOutboxID *int64 `gorm:"column:max_outbox_id"`
|
|
||||||
MinOccurredAt *time.Time `gorm:"column:min_occurred_at"`
|
|
||||||
MaxOccurredAt *time.Time `gorm:"column:max_occurred_at"`
|
|
||||||
VerificationStatus string `gorm:"column:verification_status;size:32"`
|
|
||||||
VerifiedAt *time.Time `gorm:"column:verified_at"`
|
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
|
||||||
UpdateTime time.Time `gorm:"column:update_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (TaskCenterArchiveManifest) TableName() string { return "task_center_archive_manifest" }
|
|
||||||
|
|
||||||
// TaskCenterArchiveGCRun 是固定 id=1 的全局控制行。管理命令、cron tick 和 Game King
|
|
||||||
// 启用门禁都先锁这一行,从数据库层串行化多节点状态变化。
|
|
||||||
type TaskCenterArchiveGCRun struct {
|
|
||||||
ID uint8 `gorm:"column:id;primaryKey"`
|
|
||||||
RunID string `gorm:"column:run_id;size:64"`
|
|
||||||
Status string `gorm:"column:status;size:32"`
|
|
||||||
CutoffTime *time.Time `gorm:"column:cutoff_time"`
|
|
||||||
BatchSize int `gorm:"column:batch_size"`
|
|
||||||
ScannedRows int64 `gorm:"column:scanned_rows"`
|
|
||||||
EligibleRows int64 `gorm:"column:eligible_rows"`
|
|
||||||
ProtectedRows int64 `gorm:"column:protected_rows"`
|
|
||||||
DeletedRows int64 `gorm:"column:deleted_rows"`
|
|
||||||
RecoveryObjectCount int64 `gorm:"column:recovery_object_count"`
|
|
||||||
CursorCreateTime *time.Time `gorm:"column:cursor_create_time"`
|
|
||||||
CursorID int64 `gorm:"column:cursor_id"`
|
|
||||||
LeaseOwner string `gorm:"column:lease_owner;size:160"`
|
|
||||||
LeaseUntil *time.Time `gorm:"column:lease_until"`
|
|
||||||
LastError string `gorm:"column:last_error;size:1000"`
|
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
|
||||||
UpdateTime time.Time `gorm:"column:update_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (TaskCenterArchiveGCRun) TableName() string { return "task_center_archive_gc_run" }
|
|
||||||
|
|
||||||
// TaskCenterClaimRecord 保存任务奖励领取记录。
|
// TaskCenterClaimRecord 保存任务奖励领取记录。
|
||||||
type TaskCenterClaimRecord struct {
|
type TaskCenterClaimRecord struct {
|
||||||
ID int64 `gorm:"column:id;primaryKey"`
|
ID int64 `gorm:"column:id;primaryKey"`
|
||||||
|
|||||||
@ -1,185 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// YumiGameKingActivity 保存一次游戏王活动及其不可变统计口径。
|
|
||||||
type YumiGameKingActivity struct {
|
|
||||||
ID int64 `gorm:"column:id;primaryKey"`
|
|
||||||
ActivityCode string `gorm:"column:activity_code;size:64;uniqueIndex:uk_yumi_gk_activity_code"`
|
|
||||||
ActivityName string `gorm:"column:activity_name;size:128"`
|
|
||||||
ActivityDesc string `gorm:"column:activity_desc;size:1000"`
|
|
||||||
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_yumi_gk_activity_window,priority:1"`
|
|
||||||
Timezone string `gorm:"column:time_zone;size:64"`
|
|
||||||
StartTime time.Time `gorm:"column:start_time;index:idx_yumi_gk_activity_window,priority:3"`
|
|
||||||
EndTime time.Time `gorm:"column:end_time;index:idx_yumi_gk_activity_window,priority:4;index:idx_yumi_gk_activity_settle,priority:3"`
|
|
||||||
SettlementDelayMinutes int `gorm:"column:settlement_delay_minutes"`
|
|
||||||
SettlementTime time.Time `gorm:"column:settlement_time;index:idx_yumi_gk_activity_settle,priority:2"`
|
|
||||||
CoinPerDraw int64 `gorm:"column:coin_per_draw"`
|
|
||||||
RankingType string `gorm:"column:ranking_type;size:32"`
|
|
||||||
RankingPeriod string `gorm:"column:ranking_period;size:32"`
|
|
||||||
Enabled bool `gorm:"column:enabled;index:idx_yumi_gk_activity_window,priority:2"`
|
|
||||||
SettlementStatus string `gorm:"column:settlement_status;size:32;index:idx_yumi_gk_activity_settle,priority:1"`
|
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
|
||||||
UpdateTime time.Time `gorm:"column:update_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (YumiGameKingActivity) TableName() string { return "yumi_game_king_activity" }
|
|
||||||
|
|
||||||
// YumiGameKingScopeLock 串行同租户启用窗口检查,避免并发创建不同活动时同时通过 overlap COUNT。
|
|
||||||
type YumiGameKingScopeLock struct {
|
|
||||||
SysOrigin string `gorm:"column:sys_origin;size:32;primaryKey"`
|
|
||||||
UpdateTime time.Time `gorm:"column:update_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (YumiGameKingScopeLock) TableName() string { return "yumi_game_king_scope_lock" }
|
|
||||||
|
|
||||||
// YumiGameKingPrize 保存固定七个抽奖槽位;RewardItemsJSON 是启用前校验得到的展示快照。
|
|
||||||
type YumiGameKingPrize struct {
|
|
||||||
ID int64 `gorm:"column:id;primaryKey"`
|
|
||||||
ActivityID int64 `gorm:"column:activity_id;uniqueIndex:uk_yumi_gk_prize_position,priority:1;index:idx_yumi_gk_prize_enabled,priority:1"`
|
|
||||||
PrizeName string `gorm:"column:prize_name;size:128"`
|
|
||||||
PrizeImage string `gorm:"column:prize_image;size:500"`
|
|
||||||
Weight int64 `gorm:"column:weight"`
|
|
||||||
Stock int64 `gorm:"column:stock"`
|
|
||||||
ResourceGroupID int64 `gorm:"column:resource_group_id"`
|
|
||||||
RewardGroupName string `gorm:"column:reward_group_name;size:128"`
|
|
||||||
RewardItemsJSON string `gorm:"column:reward_items_json;type:text"`
|
|
||||||
Enabled bool `gorm:"column:enabled;index:idx_yumi_gk_prize_enabled,priority:2"`
|
|
||||||
SortOrder int `gorm:"column:sort_order;uniqueIndex:uk_yumi_gk_prize_position,priority:2"`
|
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
|
||||||
UpdateTime time.Time `gorm:"column:update_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (YumiGameKingPrize) TableName() string { return "yumi_game_king_prize" }
|
|
||||||
|
|
||||||
// YumiGameKingRankReward 保存活动结束时固定六个连续名次奖励档。
|
|
||||||
type YumiGameKingRankReward struct {
|
|
||||||
ID int64 `gorm:"column:id;primaryKey"`
|
|
||||||
ActivityID int64 `gorm:"column:activity_id;uniqueIndex:uk_yumi_gk_rank_range,priority:1;index:idx_yumi_gk_rank_start,priority:1"`
|
|
||||||
StartRank int `gorm:"column:start_rank;uniqueIndex:uk_yumi_gk_rank_range,priority:2;index:idx_yumi_gk_rank_start,priority:2"`
|
|
||||||
EndRank int `gorm:"column:end_rank;uniqueIndex:uk_yumi_gk_rank_range,priority:3"`
|
|
||||||
ResourceGroupID int64 `gorm:"column:resource_group_id"`
|
|
||||||
RewardName string `gorm:"column:reward_name;size:128"`
|
|
||||||
RewardItemsJSON string `gorm:"column:reward_items_json;type:text"`
|
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
|
||||||
UpdateTime time.Time `gorm:"column:update_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (YumiGameKingRankReward) TableName() string { return "yumi_game_king_rank_reward" }
|
|
||||||
|
|
||||||
// YumiGameKingConsumeLedger 是活动维度的幂等消费账本,实时事件和后台补数共用此边界。
|
|
||||||
type YumiGameKingConsumeLedger struct {
|
|
||||||
ID int64 `gorm:"column:id;primaryKey"`
|
|
||||||
ActivityID int64 `gorm:"column:activity_id;uniqueIndex:uk_yumi_gk_ledger_event,priority:1;index:idx_yumi_gk_ledger_user,priority:1"`
|
|
||||||
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_yumi_gk_ledger_event,priority:2"`
|
|
||||||
UserID int64 `gorm:"column:user_id;index:idx_yumi_gk_ledger_user,priority:2"`
|
|
||||||
SysOrigin string `gorm:"column:sys_origin;size:32"`
|
|
||||||
Amount int64 `gorm:"column:amount"`
|
|
||||||
EventTime time.Time `gorm:"column:event_time;index:idx_yumi_gk_ledger_user,priority:3"`
|
|
||||||
PayloadJSON string `gorm:"column:payload_json;type:text"`
|
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (YumiGameKingConsumeLedger) TableName() string { return "yumi_game_king_consume_ledger" }
|
|
||||||
|
|
||||||
// YumiGameKingBackfillState 持久化复合游标。调用方重启后从该水位继续,GC 不依赖
|
|
||||||
// webconsole/chatapp-cron 的内存 lastId 来判断历史事件是否已经确认。
|
|
||||||
type YumiGameKingBackfillState struct {
|
|
||||||
ActivityID int64 `gorm:"column:activity_id;primaryKey"`
|
|
||||||
LastOutboxID int64 `gorm:"column:last_outbox_id"`
|
|
||||||
LastOccurredAt *time.Time `gorm:"column:last_occurred_at"`
|
|
||||||
ScannedRows int64 `gorm:"column:scanned_rows"`
|
|
||||||
AcceptedRows int64 `gorm:"column:accepted_rows"`
|
|
||||||
Completed bool `gorm:"column:completed"`
|
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
|
||||||
UpdateTime time.Time `gorm:"column:update_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (YumiGameKingBackfillState) TableName() string { return "yumi_game_king_backfill_state" }
|
|
||||||
|
|
||||||
// YumiGameKingUser 保存榜单聚合和已使用抽奖次数,避免排行榜扫描消费账本。
|
|
||||||
type YumiGameKingUser struct {
|
|
||||||
ActivityID int64 `gorm:"column:activity_id;primaryKey;index:idx_yumi_gk_user_rank,priority:1"`
|
|
||||||
UserID int64 `gorm:"column:user_id;primaryKey;index:idx_yumi_gk_user_rank,priority:4,sort:asc"`
|
|
||||||
TotalConsumed int64 `gorm:"column:total_consumed;index:idx_yumi_gk_user_rank,priority:2,sort:desc"`
|
|
||||||
UsedChances int64 `gorm:"column:used_chances"`
|
|
||||||
ConsumeReachedTime time.Time `gorm:"column:consume_reached_time;index:idx_yumi_gk_user_rank,priority:3,sort:asc"`
|
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
|
||||||
UpdateTime time.Time `gorm:"column:update_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (YumiGameKingUser) TableName() string { return "yumi_game_king_user" }
|
|
||||||
|
|
||||||
// YumiGameKingUserDaily 保存活动时区自然日聚合,DAILY 排行不扫描消费明细。
|
|
||||||
type YumiGameKingUserDaily struct {
|
|
||||||
ActivityID int64 `gorm:"column:activity_id;primaryKey;index:idx_yumi_gk_daily_rank,priority:1"`
|
|
||||||
StatDate time.Time `gorm:"column:stat_date;type:date;primaryKey;index:idx_yumi_gk_daily_rank,priority:2"`
|
|
||||||
UserID int64 `gorm:"column:user_id;primaryKey;index:idx_yumi_gk_daily_rank,priority:5,sort:asc"`
|
|
||||||
TotalConsumed int64 `gorm:"column:total_consumed;index:idx_yumi_gk_daily_rank,priority:3,sort:desc"`
|
|
||||||
ConsumeReachedTime time.Time `gorm:"column:consume_reached_time;index:idx_yumi_gk_daily_rank,priority:4,sort:asc"`
|
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
|
||||||
UpdateTime time.Time `gorm:"column:update_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (YumiGameKingUserDaily) TableName() string { return "yumi_game_king_user_daily" }
|
|
||||||
|
|
||||||
// YumiGameKingDrawRecord 保存一次请求幂等的抽奖结果和整组奖励发放状态。
|
|
||||||
type YumiGameKingDrawRecord struct {
|
|
||||||
ID int64 `gorm:"column:id;primaryKey"`
|
|
||||||
ActivityID int64 `gorm:"column:activity_id;uniqueIndex:uk_yumi_gk_draw_request,priority:1;index:idx_yumi_gk_draw_user,priority:1;index:idx_yumi_gk_draw_manage,priority:1"`
|
|
||||||
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_yumi_gk_draw_request,priority:2;index:idx_yumi_gk_draw_user,priority:2"`
|
|
||||||
RequestID string `gorm:"column:request_id;size:64;uniqueIndex:uk_yumi_gk_draw_request,priority:3"`
|
|
||||||
PrizeID int64 `gorm:"column:prize_id"`
|
|
||||||
PrizeName string `gorm:"column:prize_name;size:128"`
|
|
||||||
PrizeImage string `gorm:"column:prize_image;size:500"`
|
|
||||||
ResourceGroupID int64 `gorm:"column:resource_group_id"`
|
|
||||||
DeliveryStatus string `gorm:"column:delivery_status;size:32;index:idx_yumi_gk_draw_manage,priority:2"`
|
|
||||||
RetryCount int `gorm:"column:retry_count"`
|
|
||||||
FailureReason string `gorm:"column:failure_reason;size:500"`
|
|
||||||
DrawTime time.Time `gorm:"column:draw_time"`
|
|
||||||
DeliverTime *time.Time `gorm:"column:deliver_time"`
|
|
||||||
CreateTime time.Time `gorm:"column:create_time;index:idx_yumi_gk_draw_user,priority:3"`
|
|
||||||
UpdateTime time.Time `gorm:"column:update_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (YumiGameKingDrawRecord) TableName() string { return "yumi_game_king_draw_record" }
|
|
||||||
|
|
||||||
// YumiGameKingSettlementRecord 是活动结算时冻结的 Top30 排名和发奖状态。
|
|
||||||
type YumiGameKingSettlementRecord struct {
|
|
||||||
ID int64 `gorm:"column:id;primaryKey"`
|
|
||||||
ActivityID int64 `gorm:"column:activity_id;uniqueIndex:uk_yumi_gk_settle_user,priority:1;uniqueIndex:uk_yumi_gk_settle_rank,priority:1;index:idx_yumi_gk_settle_status,priority:1"`
|
|
||||||
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_yumi_gk_settle_user,priority:2"`
|
|
||||||
RankNo int `gorm:"column:rank_no;uniqueIndex:uk_yumi_gk_settle_rank,priority:2"`
|
|
||||||
TotalConsumed int64 `gorm:"column:total_consumed"`
|
|
||||||
RankRewardID int64 `gorm:"column:rank_reward_id"`
|
|
||||||
ResourceGroupID int64 `gorm:"column:resource_group_id"`
|
|
||||||
BusinessNo string `gorm:"column:business_no;size:128;uniqueIndex:uk_yumi_gk_settle_business"`
|
|
||||||
DeliveryStatus string `gorm:"column:delivery_status;size:32;index:idx_yumi_gk_settle_status,priority:2"`
|
|
||||||
RetryCount int `gorm:"column:retry_count"`
|
|
||||||
FailureReason string `gorm:"column:failure_reason;size:500"`
|
|
||||||
DeliverTime *time.Time `gorm:"column:deliver_time"`
|
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
|
||||||
UpdateTime time.Time `gorm:"column:update_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (YumiGameKingSettlementRecord) TableName() string {
|
|
||||||
return "yumi_game_king_settlement_record"
|
|
||||||
}
|
|
||||||
|
|
||||||
// YumiGameKingDeliveryItem 把一次奖励组调用作为最小核账单元;UNKNOWN 不允许自动重试。
|
|
||||||
type YumiGameKingDeliveryItem struct {
|
|
||||||
ID int64 `gorm:"column:id;primaryKey;index:idx_yumi_gk_delivery_status,priority:3"`
|
|
||||||
OwnerType string `gorm:"column:owner_type;size:20;uniqueIndex:uk_yumi_gk_delivery_owner,priority:1"`
|
|
||||||
OwnerID int64 `gorm:"column:owner_id;uniqueIndex:uk_yumi_gk_delivery_owner,priority:2"`
|
|
||||||
ActivityID int64 `gorm:"column:activity_id;index:idx_yumi_gk_delivery_activity,priority:1"`
|
|
||||||
UserID int64 `gorm:"column:user_id"`
|
|
||||||
ResourceGroupID int64 `gorm:"column:resource_group_id"`
|
|
||||||
DeliveryStatus string `gorm:"column:delivery_status;size:32;index:idx_yumi_gk_delivery_status,priority:1;index:idx_yumi_gk_delivery_activity,priority:2"`
|
|
||||||
RetryCount int `gorm:"column:retry_count"`
|
|
||||||
FailureReason string `gorm:"column:failure_reason;size:500"`
|
|
||||||
DeliverTime *time.Time `gorm:"column:deliver_time"`
|
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
|
||||||
UpdateTime time.Time `gorm:"column:update_time;index:idx_yumi_gk_delivery_status,priority:2"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (YumiGameKingDeliveryItem) TableName() string { return "yumi_game_king_delivery_item" }
|
|
||||||
@ -1,221 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// YumiGiftChallengeActivity 保存一次独立活动周期;活动开始后核心时间和奖励配置只读。
|
|
||||||
type YumiGiftChallengeActivity struct {
|
|
||||||
ID int64 `gorm:"column:id;primaryKey"`
|
|
||||||
ActivityCode string `gorm:"column:activity_code"`
|
|
||||||
ActivityName string `gorm:"column:activity_name"`
|
|
||||||
ActivityDesc string `gorm:"column:activity_desc"`
|
|
||||||
SysOrigin string `gorm:"column:sys_origin"`
|
|
||||||
Timezone string `gorm:"column:time_zone"`
|
|
||||||
StartTime time.Time `gorm:"column:start_time"`
|
|
||||||
EndTime time.Time `gorm:"column:end_time"`
|
|
||||||
DailySettlementDelayMinutes int `gorm:"column:daily_settlement_delay_minutes"`
|
|
||||||
OverallSettlementDelayMinutes int `gorm:"column:overall_settlement_delay_minutes"`
|
|
||||||
OverallSettlementTime time.Time `gorm:"column:overall_settlement_time"`
|
|
||||||
DisplayTopN int `gorm:"column:display_top_n"`
|
|
||||||
Enabled bool `gorm:"column:enabled"`
|
|
||||||
OverallSettlementStatus string `gorm:"column:overall_settlement_status"`
|
|
||||||
Version int `gorm:"column:version"`
|
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
|
||||||
UpdateTime time.Time `gorm:"column:update_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (YumiGiftChallengeActivity) TableName() string { return "yumi_gift_challenge_activity" }
|
|
||||||
|
|
||||||
// YumiGiftChallengeTaskConfig 是活动固定三个每日任务槽位的定义。
|
|
||||||
type YumiGiftChallengeTaskConfig struct {
|
|
||||||
ID int64 `gorm:"column:id;primaryKey"`
|
|
||||||
ActivityID int64 `gorm:"column:activity_id"`
|
|
||||||
TaskCode string `gorm:"column:task_code"`
|
|
||||||
TaskType string `gorm:"column:task_type"`
|
|
||||||
TaskTitle string `gorm:"column:task_title"`
|
|
||||||
TaskDesc string `gorm:"column:task_desc"`
|
|
||||||
TargetValue Decimal24_2 `gorm:"column:target_value;type:decimal(24,2)"`
|
|
||||||
ResourceGroupID *int64 `gorm:"column:resource_group_id"`
|
|
||||||
Enabled bool `gorm:"column:enabled"`
|
|
||||||
SortOrder int `gorm:"column:sort_order"`
|
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
|
||||||
UpdateTime time.Time `gorm:"column:update_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (YumiGiftChallengeTaskConfig) TableName() string { return "yumi_gift_challenge_task_config" }
|
|
||||||
|
|
||||||
// YumiGiftChallengeRankReward 把一个连续名次区间绑定到一个奖励组。
|
|
||||||
type YumiGiftChallengeRankReward struct {
|
|
||||||
ID int64 `gorm:"column:id;primaryKey"`
|
|
||||||
ActivityID int64 `gorm:"column:activity_id"`
|
|
||||||
PeriodType string `gorm:"column:period_type"`
|
|
||||||
StartRank int `gorm:"column:start_rank"`
|
|
||||||
EndRank int `gorm:"column:end_rank"`
|
|
||||||
ResourceGroupID int64 `gorm:"column:resource_group_id"`
|
|
||||||
RewardName string `gorm:"column:reward_name"`
|
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
|
||||||
UpdateTime time.Time `gorm:"column:update_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (YumiGiftChallengeRankReward) TableName() string { return "yumi_gift_challenge_rank_reward" }
|
|
||||||
|
|
||||||
// YumiGiftChallengeRewardSnapshot 是启用时冻结的完整奖励项,展示和实际发放均读取此表。
|
|
||||||
type YumiGiftChallengeRewardSnapshot struct {
|
|
||||||
ID int64 `gorm:"column:id;primaryKey"`
|
|
||||||
ActivityID int64 `gorm:"column:activity_id"`
|
|
||||||
ResourceGroupID int64 `gorm:"column:resource_group_id"`
|
|
||||||
RewardConfigID int64 `gorm:"column:reward_config_id"`
|
|
||||||
RewardType string `gorm:"column:reward_type"`
|
|
||||||
DetailType string `gorm:"column:detail_type"`
|
|
||||||
Content string `gorm:"column:content"`
|
|
||||||
Quantity int64 `gorm:"column:quantity"`
|
|
||||||
SortOrder int `gorm:"column:sort_order"`
|
|
||||||
Remark string `gorm:"column:remark"`
|
|
||||||
Cover string `gorm:"column:cover"`
|
|
||||||
SourceURL string `gorm:"column:source_url"`
|
|
||||||
DisplayName string `gorm:"column:display_name"`
|
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (YumiGiftChallengeRewardSnapshot) TableName() string {
|
|
||||||
return "yumi_gift_challenge_reward_snapshot"
|
|
||||||
}
|
|
||||||
|
|
||||||
// YumiGiftChallengeGiftLedger 是原始送礼事件的活动账本,也是消费幂等边界。
|
|
||||||
type YumiGiftChallengeGiftLedger struct {
|
|
||||||
ID int64 `gorm:"column:id;primaryKey"`
|
|
||||||
ActivityID int64 `gorm:"column:activity_id"`
|
|
||||||
SourceEventTrackID string `gorm:"column:source_event_track_id"`
|
|
||||||
UserID int64 `gorm:"column:user_id"`
|
|
||||||
GiftID int64 `gorm:"column:gift_id"`
|
|
||||||
GiftTab string `gorm:"column:gift_tab"`
|
|
||||||
Amount Decimal24_2 `gorm:"column:amount;type:decimal(24,2)"`
|
|
||||||
EventTime time.Time `gorm:"column:event_time"`
|
|
||||||
StatDate string `gorm:"column:stat_date;type:date"`
|
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (YumiGiftChallengeGiftLedger) TableName() string { return "yumi_gift_challenge_gift_ledger" }
|
|
||||||
|
|
||||||
// YumiGiftChallengeUserScore 保存活动总榜聚合,避免榜单查询扫描明细账本。
|
|
||||||
type YumiGiftChallengeUserScore struct {
|
|
||||||
ActivityID int64 `gorm:"column:activity_id;primaryKey"`
|
|
||||||
UserID int64 `gorm:"column:user_id;primaryKey"`
|
|
||||||
Score Decimal24_2 `gorm:"column:score;type:decimal(24,2)"`
|
|
||||||
ScoreReachedTime time.Time `gorm:"column:score_reached_time"`
|
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
|
||||||
UpdateTime time.Time `gorm:"column:update_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (YumiGiftChallengeUserScore) TableName() string { return "yumi_gift_challenge_user_score" }
|
|
||||||
|
|
||||||
// YumiGiftChallengeUserDailyScore 保存活动时区自然日榜聚合。
|
|
||||||
type YumiGiftChallengeUserDailyScore struct {
|
|
||||||
ActivityID int64 `gorm:"column:activity_id;primaryKey"`
|
|
||||||
StatDate string `gorm:"column:stat_date;type:date;primaryKey"`
|
|
||||||
UserID int64 `gorm:"column:user_id;primaryKey"`
|
|
||||||
Score Decimal24_2 `gorm:"column:score;type:decimal(24,2)"`
|
|
||||||
ScoreReachedTime time.Time `gorm:"column:score_reached_time"`
|
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
|
||||||
UpdateTime time.Time `gorm:"column:update_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (YumiGiftChallengeUserDailyScore) TableName() string {
|
|
||||||
return "yumi_gift_challenge_user_daily_score"
|
|
||||||
}
|
|
||||||
|
|
||||||
// YumiGiftChallengeUserTaskDaily 冻结用户当天任务目标和奖励归属,并记录首次领取 requestId。
|
|
||||||
type YumiGiftChallengeUserTaskDaily struct {
|
|
||||||
ID int64 `gorm:"column:id;primaryKey"`
|
|
||||||
ActivityID int64 `gorm:"column:activity_id"`
|
|
||||||
StatDate string `gorm:"column:stat_date;type:date"`
|
|
||||||
UserID int64 `gorm:"column:user_id"`
|
|
||||||
TaskConfigID int64 `gorm:"column:task_config_id"`
|
|
||||||
TaskCode string `gorm:"column:task_code"`
|
|
||||||
TaskType string `gorm:"column:task_type"`
|
|
||||||
TaskTitle string `gorm:"column:task_title"`
|
|
||||||
SortOrder int `gorm:"column:sort_order"`
|
|
||||||
TargetValue Decimal24_2 `gorm:"column:target_value;type:decimal(24,2)"`
|
|
||||||
ProgressValue Decimal24_2 `gorm:"column:progress_value;type:decimal(24,2)"`
|
|
||||||
CompletedTime *time.Time `gorm:"column:completed_time"`
|
|
||||||
ResourceGroupID *int64 `gorm:"column:resource_group_id"`
|
|
||||||
BusinessNo string `gorm:"column:business_no"`
|
|
||||||
ClaimRequestID string `gorm:"column:claim_request_id"`
|
|
||||||
DeliveryStatus string `gorm:"column:delivery_status"`
|
|
||||||
RetryCount int `gorm:"column:retry_count"`
|
|
||||||
FailureReason string `gorm:"column:failure_reason"`
|
|
||||||
ClaimedTime *time.Time `gorm:"column:claimed_time"`
|
|
||||||
DeliverTime *time.Time `gorm:"column:deliver_time"`
|
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
|
||||||
UpdateTime time.Time `gorm:"column:update_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (YumiGiftChallengeUserTaskDaily) TableName() string {
|
|
||||||
return "yumi_gift_challenge_user_task_daily"
|
|
||||||
}
|
|
||||||
|
|
||||||
// YumiGiftChallengePeriodSettlement 是包括空榜在内的周期门闩;PROCESSING 后禁止迟到写入。
|
|
||||||
type YumiGiftChallengePeriodSettlement struct {
|
|
||||||
ID int64 `gorm:"column:id;primaryKey"`
|
|
||||||
ActivityID int64 `gorm:"column:activity_id"`
|
|
||||||
PeriodType string `gorm:"column:period_type"`
|
|
||||||
PeriodKey string `gorm:"column:period_key"`
|
|
||||||
StatDate *string `gorm:"column:stat_date;type:date"`
|
|
||||||
SnapshotDueTime time.Time `gorm:"column:snapshot_due_time"`
|
|
||||||
Status string `gorm:"column:status"`
|
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
|
||||||
UpdateTime time.Time `gorm:"column:update_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (YumiGiftChallengePeriodSettlement) TableName() string {
|
|
||||||
return "yumi_gift_challenge_period_settlement"
|
|
||||||
}
|
|
||||||
|
|
||||||
// YumiGiftChallengeSettlement 是已经冻结的获奖 parent,先提交后再物化和发送奖励项。
|
|
||||||
type YumiGiftChallengeSettlement struct {
|
|
||||||
ID int64 `gorm:"column:id;primaryKey"`
|
|
||||||
ActivityID int64 `gorm:"column:activity_id"`
|
|
||||||
PeriodType string `gorm:"column:period_type"`
|
|
||||||
PeriodKey string `gorm:"column:period_key"`
|
|
||||||
StatDate *string `gorm:"column:stat_date;type:date"`
|
|
||||||
UserID int64 `gorm:"column:user_id"`
|
|
||||||
RankNo int `gorm:"column:rank_no"`
|
|
||||||
Score Decimal24_2 `gorm:"column:score;type:decimal(24,2)"`
|
|
||||||
RankRewardID int64 `gorm:"column:rank_reward_id"`
|
|
||||||
ResourceGroupID int64 `gorm:"column:resource_group_id"`
|
|
||||||
BusinessNo string `gorm:"column:business_no"`
|
|
||||||
DeliveryStatus string `gorm:"column:delivery_status"`
|
|
||||||
RetryCount int `gorm:"column:retry_count"`
|
|
||||||
FailureReason string `gorm:"column:failure_reason"`
|
|
||||||
DeliverTime *time.Time `gorm:"column:deliver_time"`
|
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
|
||||||
UpdateTime time.Time `gorm:"column:update_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (YumiGiftChallengeSettlement) TableName() string { return "yumi_gift_challenge_settlement" }
|
|
||||||
|
|
||||||
// YumiGiftChallengeDeliveryItem 是实际发奖最小幂等单元,UNKNOWN 只允许人工核账处理。
|
|
||||||
type YumiGiftChallengeDeliveryItem struct {
|
|
||||||
ID int64 `gorm:"column:id;primaryKey"`
|
|
||||||
OwnerType string `gorm:"column:owner_type"`
|
|
||||||
OwnerID int64 `gorm:"column:owner_id"`
|
|
||||||
ActivityID int64 `gorm:"column:activity_id"`
|
|
||||||
UserID int64 `gorm:"column:user_id"`
|
|
||||||
RewardConfigID int64 `gorm:"column:reward_config_id"`
|
|
||||||
ResourceGroupID int64 `gorm:"column:resource_group_id"`
|
|
||||||
RewardType string `gorm:"column:reward_type"`
|
|
||||||
DetailType string `gorm:"column:detail_type"`
|
|
||||||
Content string `gorm:"column:content"`
|
|
||||||
Quantity int64 `gorm:"column:quantity"`
|
|
||||||
SortOrder int `gorm:"column:sort_order"`
|
|
||||||
Remark string `gorm:"column:remark"`
|
|
||||||
DeliveryStatus string `gorm:"column:delivery_status"`
|
|
||||||
RetryCount int `gorm:"column:retry_count"`
|
|
||||||
FailureReason string `gorm:"column:failure_reason"`
|
|
||||||
DeliverTime *time.Time `gorm:"column:deliver_time"`
|
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
|
||||||
UpdateTime time.Time `gorm:"column:update_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (YumiGiftChallengeDeliveryItem) TableName() string {
|
|
||||||
return "yumi_gift_challenge_delivery_item"
|
|
||||||
}
|
|
||||||
@ -2,7 +2,6 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/service/firstrechargereward"
|
"chatapp3-golang/internal/service/firstrechargereward"
|
||||||
|
|
||||||
@ -61,7 +60,7 @@ func registerFirstRechargeRewardRoutes(engine *gin.Engine, javaClient authGatewa
|
|||||||
func registerFirstRechargeRewardAppGroup(group *gin.RouterGroup, javaClient authGateway, service *firstrechargereward.Service) {
|
func registerFirstRechargeRewardAppGroup(group *gin.RouterGroup, javaClient authGateway, service *firstrechargereward.Service) {
|
||||||
group.Use(authMiddleware(javaClient))
|
group.Use(authMiddleware(javaClient))
|
||||||
group.GET("/home", func(c *gin.Context) {
|
group.GET("/home", func(c *gin.Context) {
|
||||||
resp, err := service.GetHome(c.Request.Context(), mustAuthUser(c), firstRechargeRewardRequestVersion(c))
|
resp, err := service.GetHome(c.Request.Context(), mustAuthUser(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(c, err)
|
writeError(c, err)
|
||||||
return
|
return
|
||||||
@ -69,33 +68,3 @@ func registerFirstRechargeRewardAppGroup(group *gin.RouterGroup, javaClient auth
|
|||||||
writeOK(c, resp)
|
writeOK(c, resp)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func firstRechargeRewardRequestVersion(c *gin.Context) string {
|
|
||||||
return firstNonEmptyString(
|
|
||||||
c.Query("appVersion"),
|
|
||||||
c.Query("version"),
|
|
||||||
c.GetHeader("req-version"),
|
|
||||||
c.GetHeader("Req-Version"),
|
|
||||||
versionFromReqAppIntel(c.GetHeader("req-app-intel")),
|
|
||||||
versionFromReqAppIntel(c.GetHeader("Req-App-Intel")),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func versionFromReqAppIntel(value string) string {
|
|
||||||
for _, item := range strings.Split(value, ";") {
|
|
||||||
parts := strings.SplitN(item, "=", 2)
|
|
||||||
if len(parts) == 2 && strings.EqualFold(strings.TrimSpace(parts[0]), "version") {
|
|
||||||
return strings.TrimSpace(parts[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstNonEmptyString(values ...string) string {
|
|
||||||
for _, value := range values {
|
|
||||||
if text := strings.TrimSpace(value); text != "" {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,253 +0,0 @@
|
|||||||
package router
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/config"
|
|
||||||
"chatapp3-golang/internal/service/gameking"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// registerGameKingRoutes 注册 Yumi H5、运营后台和外部 cron 内部触发接口。
|
|
||||||
func registerGameKingRoutes(engine *gin.Engine, cfg config.Config, javaClient authGateway, service *gameking.Service) {
|
|
||||||
if service == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
h5 := engine.Group("/app/h5/game-king/yumi")
|
|
||||||
h5.Use(authMiddleware(javaClient))
|
|
||||||
h5.GET("/detail", func(c *gin.Context) {
|
|
||||||
resp, err := service.Detail(c.Request.Context(), mustAuthUser(c), parseGameKingID(c.Query("activityId")))
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, resp)
|
|
||||||
})
|
|
||||||
h5.GET("/me", func(c *gin.Context) {
|
|
||||||
resp, err := service.Me(c.Request.Context(), mustAuthUser(c), parseGameKingID(c.Query("activityId")))
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, resp)
|
|
||||||
})
|
|
||||||
h5.POST("/draw", func(c *gin.Context) {
|
|
||||||
var req gameking.DrawRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
writeError(c, gameking.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resp, err := service.Draw(c.Request.Context(), mustAuthUser(c), req)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, resp)
|
|
||||||
})
|
|
||||||
h5.GET("/ranking", func(c *gin.Context) {
|
|
||||||
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "30")))
|
|
||||||
resp, err := service.Ranking(c.Request.Context(), mustAuthUser(c), parseGameKingID(c.Query("activityId")), c.Query("rankingType"), c.Query("period"), limit)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, resp)
|
|
||||||
})
|
|
||||||
h5.GET("/draw-records", func(c *gin.Context) {
|
|
||||||
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
|
|
||||||
resp, err := service.DrawRecords(c.Request.Context(), mustAuthUser(c), parseGameKingID(c.Query("activityId")), limit)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, resp)
|
|
||||||
})
|
|
||||||
|
|
||||||
admin := engine.Group("/resident-activity/game-king")
|
|
||||||
admin.Use(consoleAuthMiddleware(javaClient), consoleMenuMiddleware(javaClient, "ResidentGameKingActivity"))
|
|
||||||
admin.GET("/list", func(c *gin.Context) {
|
|
||||||
resp, err := service.ListActivities(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, resp)
|
|
||||||
})
|
|
||||||
admin.GET("/detail/:id", func(c *gin.Context) {
|
|
||||||
resp, err := service.ManageDetail(c.Request.Context(), parseGameKingID(c.Param("id")))
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, resp)
|
|
||||||
})
|
|
||||||
admin.POST("/save", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:edit"), func(c *gin.Context) {
|
|
||||||
var req gameking.SaveActivityRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
writeError(c, gameking.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resp, err := service.SaveActivity(c.Request.Context(), req)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, resp)
|
|
||||||
})
|
|
||||||
admin.PUT("/enable/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:enable"), func(c *gin.Context) {
|
|
||||||
raw, exists := c.GetQuery("enabled")
|
|
||||||
if !exists {
|
|
||||||
writeError(c, gameking.NewAppError(http.StatusBadRequest, "enabled_required", "enabled query is required"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
enabled, err := parseGameKingBool(raw, "enabled")
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := service.EnableActivity(c.Request.Context(), parseGameKingID(c.Param("id")), enabled); err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, gin.H{"success": true})
|
|
||||||
})
|
|
||||||
admin.GET("/prizes/:id", func(c *gin.Context) {
|
|
||||||
resp, err := service.ListPrizes(c.Request.Context(), parseGameKingID(c.Param("id")))
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, resp)
|
|
||||||
})
|
|
||||||
admin.PUT("/prizes/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:edit"), func(c *gin.Context) {
|
|
||||||
var req gameking.SavePrizesRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
writeError(c, gameking.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := service.SavePrizes(c.Request.Context(), parseGameKingID(c.Param("id")), req); err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, gin.H{"success": true})
|
|
||||||
})
|
|
||||||
admin.GET("/rank-rewards/:id", func(c *gin.Context) {
|
|
||||||
resp, err := service.ListRankRewards(c.Request.Context(), parseGameKingID(c.Param("id")))
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, resp)
|
|
||||||
})
|
|
||||||
admin.PUT("/rank-rewards/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:edit"), func(c *gin.Context) {
|
|
||||||
var req gameking.SaveRankRewardsRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
writeError(c, gameking.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := service.SaveRankRewards(c.Request.Context(), parseGameKingID(c.Param("id")), req); err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, gin.H{"success": true})
|
|
||||||
})
|
|
||||||
admin.GET("/draw-records/:id", func(c *gin.Context) {
|
|
||||||
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "100")))
|
|
||||||
resp, err := service.ManageDrawRecords(c.Request.Context(), parseGameKingID(c.Param("id")), c.Query("deliveryStatus"), limit)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, resp)
|
|
||||||
})
|
|
||||||
admin.POST("/draw/retry/:recordId", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:reconcile"), func(c *gin.Context) {
|
|
||||||
if err := service.RetryDraw(c.Request.Context(), parseGameKingID(c.Param("recordId"))); err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, gin.H{"success": true})
|
|
||||||
})
|
|
||||||
admin.GET("/settlement-records/:id", func(c *gin.Context) {
|
|
||||||
resp, err := service.SettlementRecords(c.Request.Context(), parseGameKingID(c.Param("id")))
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, resp)
|
|
||||||
})
|
|
||||||
admin.POST("/settlement/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:settle"), func(c *gin.Context) {
|
|
||||||
if err := service.Settle(c.Request.Context(), parseGameKingID(c.Param("id"))); err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, gin.H{"success": true})
|
|
||||||
})
|
|
||||||
admin.POST("/settlement/retry/:recordId", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:reconcile"), func(c *gin.Context) {
|
|
||||||
if err := service.RetrySettlement(c.Request.Context(), parseGameKingID(c.Param("recordId"))); err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, gin.H{"success": true})
|
|
||||||
})
|
|
||||||
admin.POST("/delivery-item/resolve/:itemId", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:reconcile"), func(c *gin.Context) {
|
|
||||||
raw, exists := c.GetQuery("delivered")
|
|
||||||
if !exists {
|
|
||||||
writeError(c, gameking.NewAppError(http.StatusBadRequest, "delivered_required", "delivered query is required"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
delivered, err := parseGameKingBool(raw, "delivered")
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := service.ResolveUnknownDeliveryItem(c.Request.Context(), parseGameKingID(c.Param("itemId")), delivered); err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, gin.H{"success": true})
|
|
||||||
})
|
|
||||||
admin.POST("/backfill/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-game-king:backfill"), func(c *gin.Context) {
|
|
||||||
lastID := parseGameKingID(c.DefaultQuery("lastId", "0"))
|
|
||||||
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "200")))
|
|
||||||
resp, err := service.Backfill(c.Request.Context(), parseGameKingID(c.Param("id")), lastID, limit)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, resp)
|
|
||||||
})
|
|
||||||
|
|
||||||
internal := engine.Group("/internal/game-king")
|
|
||||||
// 结算入口会冻结榜单并产生真实奖励副作用,必须与 Yumi 礼物挑战保持相同的
|
|
||||||
// fail-closed 边界:服务端未配置密钥时直接拒绝,不能沿用历史内部接口的空密钥兼容行为。
|
|
||||||
internal.Use(requiredInternalSecretMiddleware(cfg.HTTP.InternalCallbackSecret))
|
|
||||||
internal.POST("/settle-due", func(c *gin.Context) {
|
|
||||||
resp, err := service.SettleDue(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, resp)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseGameKingID(value string) int64 {
|
|
||||||
parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseGameKingBool(value, field string) (bool, error) {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
||||||
case "true", "1":
|
|
||||||
return true, nil
|
|
||||||
case "false", "0":
|
|
||||||
return false, nil
|
|
||||||
default:
|
|
||||||
// resolve delivered=false 会立即重发,因此不能把拼写错误或空值默认为 false。
|
|
||||||
return false, gameking.NewAppError(http.StatusBadRequest, field+"_invalid", field+" must be true, false, 1 or 0")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -46,8 +46,6 @@ func registerGameOpenRoutes(engine *gin.Engine, cfg config.Config, service *game
|
|||||||
c.JSON(http.StatusOK, service.HandleSupplement(c.Request.Context(), req, string(raw)))
|
c.JSON(http.StatusOK, service.HandleSupplement(c.Request.Context(), req, string(raw)))
|
||||||
})
|
})
|
||||||
|
|
||||||
registerHotgameCallbackRoutes(engine, service)
|
|
||||||
|
|
||||||
internalGroup := engine.Group("/internal/game/open")
|
internalGroup := engine.Group("/internal/game/open")
|
||||||
internalGroup.Use(internalSecretMiddleware(cfg.HTTP.InternalCallbackSecret))
|
internalGroup.Use(internalSecretMiddleware(cfg.HTTP.InternalCallbackSecret))
|
||||||
internalGroup.GET("/info", func(c *gin.Context) {
|
internalGroup.GET("/info", func(c *gin.Context) {
|
||||||
@ -60,34 +58,6 @@ func registerGameOpenRoutes(engine *gin.Engine, cfg config.Config, service *game
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerHotgameCallbackRoutes(engine *gin.Engine, service *gameopen.GameOpenService) {
|
|
||||||
handleUserInfo := func(c *gin.Context) {
|
|
||||||
raw, _ := c.GetRawData()
|
|
||||||
var req gameopen.HotgameGetUserInfoRequest
|
|
||||||
if err := json.Unmarshal(raw, &req); err != nil {
|
|
||||||
c.JSON(http.StatusOK, gameopen.CallbackResponse{ErrorCode: 4001, ErrorMsg: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, service.HandleHotgameGetUserInfo(c.Request.Context(), req, string(raw)))
|
|
||||||
}
|
|
||||||
handleUpdateBalance := func(c *gin.Context) {
|
|
||||||
raw, _ := c.GetRawData()
|
|
||||||
var req gameopen.HotgameUpdateBalanceRequest
|
|
||||||
if err := json.Unmarshal(raw, &req); err != nil {
|
|
||||||
c.JSON(http.StatusOK, gameopen.CallbackResponse{ErrorCode: 4001, ErrorMsg: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, service.HandleHotgameUpdateBalance(c.Request.Context(), req, string(raw)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 热游文档要求平台直接提供 /getUserInfo 和 /updateBalance,同时保留命名空间路径便于网关灰度。
|
|
||||||
engine.POST("/getUserInfo", handleUserInfo)
|
|
||||||
engine.POST("/updateBalance", handleUpdateBalance)
|
|
||||||
hotgameGroup := engine.Group("/game/hotgame")
|
|
||||||
hotgameGroup.POST("/getUserInfo", handleUserInfo)
|
|
||||||
hotgameGroup.POST("/updateBalance", handleUpdateBalance)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolvePublicBaseURL(c *gin.Context, cfg config.Config) string {
|
func resolvePublicBaseURL(c *gin.Context, cfg config.Config) string {
|
||||||
if value := strings.TrimRight(strings.TrimSpace(cfg.GameOpen.PublicBaseURL), "/"); value != "" {
|
if value := strings.TrimRight(strings.TrimSpace(cfg.GameOpen.PublicBaseURL), "/"); value != "" {
|
||||||
return value
|
return value
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@ -12,7 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// registerGameProviderRoutes 注册统一游戏厂商 app 路由。
|
// registerGameProviderRoutes 注册统一游戏厂商 app 路由。
|
||||||
func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, registry *gameprovider.Registry, visibility *gameprovider.VisibilityPolicy) {
|
func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, registry *gameprovider.Registry) {
|
||||||
if registry == nil {
|
if registry == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -20,15 +19,7 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
|
|||||||
appGroup := engine.Group("/app/game")
|
appGroup := engine.Group("/app/game")
|
||||||
appGroup.Use(authMiddleware(javaClient))
|
appGroup.Use(authMiddleware(javaClient))
|
||||||
appGroup.GET("/room/shortcut", func(c *gin.Context) {
|
appGroup.GET("/room/shortcut", func(c *gin.Context) {
|
||||||
user, visible, ok := gameListAccess(c, visibility)
|
resp, err := registry.ListShortcutGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"))
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !visible {
|
|
||||||
writeOK(c, []publicRoomGameItem{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resp, err := registry.ListShortcutGames(c.Request.Context(), user, c.Query("roomId"))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(c, err)
|
writeError(c, err)
|
||||||
return
|
return
|
||||||
@ -36,15 +27,7 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
|
|||||||
writeOK(c, publicRoomGameItems(resp))
|
writeOK(c, publicRoomGameItems(resp))
|
||||||
})
|
})
|
||||||
appGroup.GET("/room/list", func(c *gin.Context) {
|
appGroup.GET("/room/list", func(c *gin.Context) {
|
||||||
user, visible, ok := gameListAccess(c, visibility)
|
resp, err := registry.ListRoomGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"), c.Query("category"))
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !visible {
|
|
||||||
writeOK(c, publicRoomGameListResponse(&gameprovider.RoomGameListResponse{Items: []gameprovider.RoomGameListItem{}}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resp, err := registry.ListRoomGames(c.Request.Context(), user, c.Query("roomId"), c.Query("category"))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(c, err)
|
writeError(c, err)
|
||||||
return
|
return
|
||||||
@ -89,30 +72,14 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
|
|||||||
providerGroup := engine.Group("/app/game/providers")
|
providerGroup := engine.Group("/app/game/providers")
|
||||||
providerGroup.Use(authMiddleware(javaClient))
|
providerGroup.Use(authMiddleware(javaClient))
|
||||||
providerGroup.GET("", func(c *gin.Context) {
|
providerGroup.GET("", func(c *gin.Context) {
|
||||||
_, visible, ok := gameListAccess(c, visibility)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !visible {
|
|
||||||
writeOK(c, gameprovider.ProviderListResponse{Items: []gameprovider.ProviderSummary{}})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, registry.List())
|
writeOK(c, registry.List())
|
||||||
})
|
})
|
||||||
providerGroup.GET("/:provider/room/shortcut", func(c *gin.Context) {
|
providerGroup.GET("/:provider/room/shortcut", func(c *gin.Context) {
|
||||||
user, visible, ok := gameListAccess(c, visibility)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !visible {
|
|
||||||
writeOK(c, gin.H{"items": []publicRoomGameItem{}})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
provider, ok := resolveGameProvider(c, registry)
|
provider, ok := resolveGameProvider(c, registry)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp, err := provider.ListShortcutGames(c.Request.Context(), user, c.Query("roomId"))
|
resp, err := provider.ListShortcutGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(c, err)
|
writeError(c, err)
|
||||||
return
|
return
|
||||||
@ -120,19 +87,11 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
|
|||||||
writeOK(c, gin.H{"items": publicRoomGameItems(resp)})
|
writeOK(c, gin.H{"items": publicRoomGameItems(resp)})
|
||||||
})
|
})
|
||||||
providerGroup.GET("/:provider/room/list", func(c *gin.Context) {
|
providerGroup.GET("/:provider/room/list", func(c *gin.Context) {
|
||||||
user, visible, ok := gameListAccess(c, visibility)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !visible {
|
|
||||||
writeOK(c, publicRoomGameListResponse(&gameprovider.RoomGameListResponse{Items: []gameprovider.RoomGameListItem{}}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
provider, ok := resolveGameProvider(c, registry)
|
provider, ok := resolveGameProvider(c, registry)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp, err := provider.ListRoomGames(c.Request.Context(), user, c.Query("roomId"), c.Query("category"))
|
resp, err := provider.ListRoomGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"), c.Query("category"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(c, err)
|
writeError(c, err)
|
||||||
return
|
return
|
||||||
@ -161,7 +120,7 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
|
|||||||
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp, err := launchProviderGame(c, registry, provider, req)
|
resp, err := provider.LaunchGame(c.Request.Context(), mustAuthUser(c), req, resolveClientIP(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(c, err)
|
writeError(c, err)
|
||||||
return
|
return
|
||||||
@ -187,33 +146,6 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func gameListAccess(c *gin.Context, visibility *gameprovider.VisibilityPolicy) (common.AuthUser, bool, bool) {
|
|
||||||
user := mustAuthUser(c)
|
|
||||||
// iOS 审核和上架场景需要隐藏游戏入口;这里放在统一列表门禁最前面,避免继续请求 Java 区域/等级接口或 provider SQL。
|
|
||||||
if isIOSGameListClient(c) {
|
|
||||||
return user, false, true
|
|
||||||
}
|
|
||||||
if gameprovider.IsEmptyGameListUser(user.UserID) {
|
|
||||||
return user, false, true
|
|
||||||
}
|
|
||||||
if visibility == nil {
|
|
||||||
return user, true, true
|
|
||||||
}
|
|
||||||
// 游戏列表只在列表入口做展示门禁,结果里的区域继续向下传给 provider SQL 做 regions 过滤。
|
|
||||||
result, err := visibility.Evaluate(c.Request.Context(), user)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return user, false, false
|
|
||||||
}
|
|
||||||
user.RegionID = result.RegionID
|
|
||||||
user.RegionCode = result.RegionCode
|
|
||||||
return user, result.Allow, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func isIOSGameListClient(c *gin.Context) bool {
|
|
||||||
return strings.EqualFold(strings.TrimSpace(c.GetHeader("Req-Client")), "ios")
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveGameProvider(c *gin.Context, registry *gameprovider.Registry) (gameprovider.Provider, bool) {
|
func resolveGameProvider(c *gin.Context, registry *gameprovider.Registry) (gameprovider.Provider, bool) {
|
||||||
provider, err := registry.Resolve(c.Param("provider"))
|
provider, err := registry.Resolve(c.Param("provider"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -223,25 +155,6 @@ func resolveGameProvider(c *gin.Context, registry *gameprovider.Registry) (gamep
|
|||||||
return provider, true
|
return provider, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func launchProviderGame(c *gin.Context, registry *gameprovider.Registry, provider gameprovider.Provider, req gameprovider.LaunchRequest) (*gameprovider.LaunchResponse, error) {
|
|
||||||
user := mustAuthUser(c)
|
|
||||||
clientIP := resolveClientIP(c)
|
|
||||||
resp, err := provider.LaunchGame(c.Request.Context(), user, req, clientIP)
|
|
||||||
if err == nil {
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
if !isGameNotFoundError(err) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// 旧客户端只能把热游列表项伪装成 LEADER 进入已有 WebView;这里再按 gameId 回到真实 provider,避免 LEADER 路由找不到 hg_*。
|
|
||||||
return registry.LaunchGame(c.Request.Context(), user, req, clientIP)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isGameNotFoundError(err error) bool {
|
|
||||||
var appErr *common.AppError
|
|
||||||
return errors.As(err, &appErr) && appErr.Code == "game_not_found"
|
|
||||||
}
|
|
||||||
|
|
||||||
type publicRoomGameItem struct {
|
type publicRoomGameItem struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
GameID string `json:"gameId"`
|
GameID string `json:"gameId"`
|
||||||
@ -398,14 +311,8 @@ func publicLaunch(resp *gameprovider.LaunchResponse) *publicLaunchResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func appCompatProviderName(provider string) string {
|
func appCompatProviderName(provider string) string {
|
||||||
// 客户端历史上只认识 LEADER,不认识内部厂商名 LINGXIAN,所以出参继续保持旧字段值。
|
|
||||||
if strings.EqualFold(strings.TrimSpace(provider), "LINGXIAN") {
|
if strings.EqualFold(strings.TrimSpace(provider), "LINGXIAN") {
|
||||||
return "LEADER"
|
return "LEADER"
|
||||||
}
|
}
|
||||||
// 旧客户端没有 HOTGAME 页面;热游先按 LEADER 展示并进入已有 WebView,launch 路由再按 hg_* 分发回真实热游 provider。
|
|
||||||
switch strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(provider), "_", "")) {
|
|
||||||
case "HOTGAME", "REYOU", "LALU":
|
|
||||||
return "LEADER"
|
|
||||||
}
|
|
||||||
return provider
|
return provider
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,9 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"chatapp3-golang/internal/common"
|
|
||||||
"chatapp3-golang/internal/integration"
|
|
||||||
"chatapp3-golang/internal/service/gameprovider"
|
"chatapp3-golang/internal/service/gameprovider"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPublicRoomGameListResponseKeepsLegacyProviderFields(t *testing.T) {
|
func TestPublicRoomGameListResponseKeepsLegacyProviderFields(t *testing.T) {
|
||||||
@ -83,41 +74,6 @@ func TestPublicRoomGameListResponseMapsLingxianToLeaderForOldClients(t *testing.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPublicRoomGameListResponseMapsHotgameAliasesToLeaderForOldClients(t *testing.T) {
|
|
||||||
for _, alias := range []string{"HOTGAME", "HOT_GAME", "REYOU", "LALU"} {
|
|
||||||
resp := publicRoomGameListResponse(&gameprovider.RoomGameListResponse{
|
|
||||||
Items: []gameprovider.RoomGameListItem{
|
|
||||||
{
|
|
||||||
ID: 9530,
|
|
||||||
GameID: "hg_1",
|
|
||||||
GameType: alias,
|
|
||||||
Provider: alias,
|
|
||||||
ProviderGameID: "1",
|
|
||||||
Name: "Hotgame",
|
|
||||||
LaunchParams: map[string]any{"gameType": alias, "screenMode": "seven"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if len(resp.Items) != 1 {
|
|
||||||
t.Fatalf("%s items = %d, want 1", alias, len(resp.Items))
|
|
||||||
}
|
|
||||||
item := resp.Items[0]
|
|
||||||
if item.Provider != "LEADER" {
|
|
||||||
t.Fatalf("%s provider = %q, want LEADER", alias, item.Provider)
|
|
||||||
}
|
|
||||||
if item.GameType != "LEADER" {
|
|
||||||
t.Fatalf("%s gameType = %q, want LEADER", alias, item.GameType)
|
|
||||||
}
|
|
||||||
if item.VendorType != "LEADER" {
|
|
||||||
t.Fatalf("%s vendorType = %q, want LEADER", alias, item.VendorType)
|
|
||||||
}
|
|
||||||
if got := item.LaunchParams["gameType"]; got != "LEADER" {
|
|
||||||
t.Fatalf("%s launchParams.gameType = %v, want LEADER", alias, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPublicLaunchKeepsLegacyBridgeAndProviderFields(t *testing.T) {
|
func TestPublicLaunchKeepsLegacyBridgeAndProviderFields(t *testing.T) {
|
||||||
launchConfig := map[string]any{"code": "launch-code"}
|
launchConfig := map[string]any{"code": "launch-code"}
|
||||||
resp := publicLaunch(&gameprovider.LaunchResponse{
|
resp := publicLaunch(&gameprovider.LaunchResponse{
|
||||||
@ -185,325 +141,3 @@ func TestPublicLaunchMapsLingxianToLeaderForOldClients(t *testing.T) {
|
|||||||
t.Fatalf("roomState.provider = %q, want LEADER", resp.RoomState.Provider)
|
t.Fatalf("roomState.provider = %q, want LEADER", resp.RoomState.Provider)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGameProviderRoutesReturnRoomListForTurkeyRegionUser(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
provider := &routeStubProvider{}
|
|
||||||
engine := gin.New()
|
|
||||||
registerGameProviderRoutes(
|
|
||||||
engine,
|
|
||||||
routeStubAuthGateway{},
|
|
||||||
gameprovider.NewRegistry(provider),
|
|
||||||
gameprovider.NewVisibilityPolicy(
|
|
||||||
routeStubVisibilityGateway{
|
|
||||||
region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/app/game/room/list?roomId=1", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer token")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
engine.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
|
||||||
}
|
|
||||||
var payload struct {
|
|
||||||
Body struct {
|
|
||||||
Items []publicRoomGameItem `json:"items"`
|
|
||||||
} `json:"body"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
|
||||||
t.Fatalf("response json error = %v: %s", err, rec.Body.String())
|
|
||||||
}
|
|
||||||
if len(payload.Body.Items) != 1 {
|
|
||||||
t.Fatalf("items = %d, want 1", len(payload.Body.Items))
|
|
||||||
}
|
|
||||||
if provider.listRoomCalls != 1 {
|
|
||||||
t.Fatalf("provider list calls = %d, want 1", provider.listRoomCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGameProviderRoutesReturnEmptyRoomListForIOSClient(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
provider := &routeStubProvider{}
|
|
||||||
engine := gin.New()
|
|
||||||
registerGameProviderRoutes(
|
|
||||||
engine,
|
|
||||||
routeStubAuthGateway{},
|
|
||||||
gameprovider.NewRegistry(provider),
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/app/game/room/list?roomId=1", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer token")
|
|
||||||
req.Header.Set("Req-Client", "ios")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
engine.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
|
||||||
}
|
|
||||||
var payload struct {
|
|
||||||
Body struct {
|
|
||||||
Items []publicRoomGameItem `json:"items"`
|
|
||||||
} `json:"body"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
|
||||||
t.Fatalf("response json error = %v: %s", err, rec.Body.String())
|
|
||||||
}
|
|
||||||
if len(payload.Body.Items) != 0 {
|
|
||||||
t.Fatalf("items = %d, want 0", len(payload.Body.Items))
|
|
||||||
}
|
|
||||||
if provider.listRoomCalls != 0 {
|
|
||||||
t.Fatalf("provider list calls = %d, want 0", provider.listRoomCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGameProviderRoutesReturnEmptyShortcutForIOSClient(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
provider := &routeStubProvider{}
|
|
||||||
engine := gin.New()
|
|
||||||
registerGameProviderRoutes(
|
|
||||||
engine,
|
|
||||||
routeStubAuthGateway{},
|
|
||||||
gameprovider.NewRegistry(provider),
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/app/game/room/shortcut?roomId=1", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer token")
|
|
||||||
req.Header.Set("Req-Client", " iOS ")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
engine.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
|
||||||
}
|
|
||||||
var payload struct {
|
|
||||||
Body []publicRoomGameItem `json:"body"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
|
||||||
t.Fatalf("response json error = %v: %s", err, rec.Body.String())
|
|
||||||
}
|
|
||||||
if len(payload.Body) != 0 {
|
|
||||||
t.Fatalf("items = %d, want 0", len(payload.Body))
|
|
||||||
}
|
|
||||||
if provider.shortcutCalls != 0 {
|
|
||||||
t.Fatalf("provider shortcut calls = %d, want 0", provider.shortcutCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGameProviderRoutesReturnEmptyProvidersForIOSClient(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
engine := gin.New()
|
|
||||||
registerGameProviderRoutes(
|
|
||||||
engine,
|
|
||||||
routeStubAuthGateway{},
|
|
||||||
gameprovider.NewRegistry(&routeStubProvider{}),
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/app/game/providers", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer token")
|
|
||||||
req.Header.Set("Req-Client", "IOS")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
engine.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
|
||||||
}
|
|
||||||
var payload struct {
|
|
||||||
Body struct {
|
|
||||||
Items []gameprovider.ProviderSummary `json:"items"`
|
|
||||||
} `json:"body"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
|
||||||
t.Fatalf("response json error = %v: %s", err, rec.Body.String())
|
|
||||||
}
|
|
||||||
if len(payload.Body.Items) != 0 {
|
|
||||||
t.Fatalf("providers = %d, want 0", len(payload.Body.Items))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGameProviderRoutesReturnProvidersWhenVisibilityPolicyAllows(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
engine := gin.New()
|
|
||||||
registerGameProviderRoutes(
|
|
||||||
engine,
|
|
||||||
routeStubAuthGateway{},
|
|
||||||
gameprovider.NewRegistry(&routeStubProvider{}),
|
|
||||||
gameprovider.NewVisibilityPolicy(
|
|
||||||
routeStubVisibilityGateway{
|
|
||||||
region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/app/game/providers", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer token")
|
|
||||||
req.Header.Set("X-Real-IP", "88.255.1.1")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
engine.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
|
||||||
}
|
|
||||||
var payload struct {
|
|
||||||
Body struct {
|
|
||||||
Items []gameprovider.ProviderSummary `json:"items"`
|
|
||||||
} `json:"body"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
|
||||||
t.Fatalf("response json error = %v: %s", err, rec.Body.String())
|
|
||||||
}
|
|
||||||
if len(payload.Body.Items) != 1 {
|
|
||||||
t.Fatalf("providers = %d, want 1", len(payload.Body.Items))
|
|
||||||
}
|
|
||||||
if payload.Body.Items[0].Key != "TEST" {
|
|
||||||
t.Fatalf("provider key = %q, want TEST", payload.Body.Items[0].Key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProviderLaunchFallsBackToRegistryWhenLegacyProviderCannotFindGame(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
engine := gin.New()
|
|
||||||
registerGameProviderRoutes(
|
|
||||||
engine,
|
|
||||||
routeStubAuthGateway{},
|
|
||||||
gameprovider.NewRegistry(
|
|
||||||
&routeLaunchStubProvider{key: "LINGXIAN"},
|
|
||||||
&routeLaunchStubProvider{key: "HOTGAME", matchGameID: "hg_1"},
|
|
||||||
),
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/app/game/providers/LEADER/launch", strings.NewReader(`{"roomId":"room-1","gameId":"hg_1"}`))
|
|
||||||
req.Header.Set("Authorization", "Bearer token")
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
engine.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
|
||||||
}
|
|
||||||
var payload struct {
|
|
||||||
Body publicLaunchResponse `json:"body"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
|
||||||
t.Fatalf("response json error = %v: %s", err, rec.Body.String())
|
|
||||||
}
|
|
||||||
if payload.Body.GameID != "hg_1" {
|
|
||||||
t.Fatalf("gameId = %q, want hg_1", payload.Body.GameID)
|
|
||||||
}
|
|
||||||
if payload.Body.Provider != "LEADER" {
|
|
||||||
t.Fatalf("provider = %q, want LEADER for old-client compat", payload.Body.Provider)
|
|
||||||
}
|
|
||||||
if payload.Body.GameSessionID != "hg-session" {
|
|
||||||
t.Fatalf("gameSessionId = %q, want hg-session", payload.Body.GameSessionID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type routeStubAuthGateway struct{}
|
|
||||||
|
|
||||||
func (routeStubAuthGateway) AuthenticateToken(context.Context, string) (integration.UserCredential, error) {
|
|
||||||
return integration.UserCredential{UserID: integration.Int64Value(1001), SysOrigin: "LIKEI"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (routeStubAuthGateway) AuthenticateConsoleToken(context.Context, string) (integration.ConsoleAccount, error) {
|
|
||||||
return integration.ConsoleAccount{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type routeStubVisibilityGateway struct {
|
|
||||||
region integration.UserRegion
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s routeStubVisibilityGateway) GetUserRegion(context.Context, int64, string) (integration.UserRegion, error) {
|
|
||||||
return s.region, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type routeStubProvider struct {
|
|
||||||
listRoomCalls int
|
|
||||||
shortcutCalls int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *routeStubProvider) Key() string { return "TEST" }
|
|
||||||
|
|
||||||
func (p *routeStubProvider) DisplayName() string { return "Test Provider" }
|
|
||||||
|
|
||||||
func (p *routeStubProvider) SupportsLaunch(context.Context, gameprovider.AuthUser, gameprovider.LaunchRequest) (bool, error) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *routeStubProvider) ListShortcutGames(context.Context, gameprovider.AuthUser, string) ([]gameprovider.RoomGameListItem, error) {
|
|
||||||
p.shortcutCalls++
|
|
||||||
return []gameprovider.RoomGameListItem{{ID: 1, GameID: "test_1", Provider: "TEST", ProviderGameID: "1", Name: "Test Game"}}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *routeStubProvider) ListRoomGames(context.Context, gameprovider.AuthUser, string, string) (*gameprovider.RoomGameListResponse, error) {
|
|
||||||
p.listRoomCalls++
|
|
||||||
return &gameprovider.RoomGameListResponse{
|
|
||||||
Items: []gameprovider.RoomGameListItem{{ID: 1, GameID: "test_1", Provider: "TEST", ProviderGameID: "1", Name: "Test Game"}},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *routeStubProvider) GetRoomState(context.Context, gameprovider.AuthUser, string) (*gameprovider.RoomStateResponse, error) {
|
|
||||||
return &gameprovider.RoomStateResponse{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *routeStubProvider) LaunchGame(context.Context, gameprovider.AuthUser, gameprovider.LaunchRequest, string) (*gameprovider.LaunchResponse, error) {
|
|
||||||
return nil, common.NewAppError(http.StatusBadRequest, "unsupported", "unsupported")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *routeStubProvider) CloseGame(context.Context, gameprovider.AuthUser, gameprovider.CloseRequest) (*gameprovider.RoomStateResponse, error) {
|
|
||||||
return &gameprovider.RoomStateResponse{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type routeLaunchStubProvider struct {
|
|
||||||
key string
|
|
||||||
matchGameID string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *routeLaunchStubProvider) Key() string { return p.key }
|
|
||||||
|
|
||||||
func (p *routeLaunchStubProvider) DisplayName() string { return p.key }
|
|
||||||
|
|
||||||
func (p *routeLaunchStubProvider) SupportsLaunch(_ context.Context, _ gameprovider.AuthUser, req gameprovider.LaunchRequest) (bool, error) {
|
|
||||||
return p.matchGameID != "" && req.GameID == p.matchGameID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *routeLaunchStubProvider) ListShortcutGames(context.Context, gameprovider.AuthUser, string) ([]gameprovider.RoomGameListItem, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *routeLaunchStubProvider) ListRoomGames(context.Context, gameprovider.AuthUser, string, string) (*gameprovider.RoomGameListResponse, error) {
|
|
||||||
return &gameprovider.RoomGameListResponse{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *routeLaunchStubProvider) GetRoomState(context.Context, gameprovider.AuthUser, string) (*gameprovider.RoomStateResponse, error) {
|
|
||||||
return &gameprovider.RoomStateResponse{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *routeLaunchStubProvider) LaunchGame(_ context.Context, _ gameprovider.AuthUser, req gameprovider.LaunchRequest, _ string) (*gameprovider.LaunchResponse, error) {
|
|
||||||
if p.matchGameID == "" || req.GameID != p.matchGameID {
|
|
||||||
return nil, common.NewAppError(http.StatusNotFound, "game_not_found", "game not found")
|
|
||||||
}
|
|
||||||
return &gameprovider.LaunchResponse{
|
|
||||||
GameSessionID: "hg-session",
|
|
||||||
Provider: p.key,
|
|
||||||
GameID: req.GameID,
|
|
||||||
ProviderGameID: "1",
|
|
||||||
Entry: gameprovider.LaunchEntry{LaunchMode: "H5_REMOTE"},
|
|
||||||
RoomState: gameprovider.RoomStateResponse{
|
|
||||||
RoomID: req.RoomID,
|
|
||||||
State: "PLAYING",
|
|
||||||
Provider: p.key,
|
|
||||||
GameSessionID: "hg-session",
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *routeLaunchStubProvider) CloseGame(context.Context, gameprovider.AuthUser, gameprovider.CloseRequest) (*gameprovider.RoomStateResponse, error) {
|
|
||||||
return &gameprovider.RoomStateResponse{}, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,115 +0,0 @@
|
|||||||
package router
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/common"
|
|
||||||
"chatapp3-golang/internal/service/hotgame"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// registerHotgameRoutes 注册热游后台管理类 Go 接口。
|
|
||||||
func registerHotgameRoutes(engine *gin.Engine, javaClient authGateway, service *hotgame.Service) {
|
|
||||||
if service == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
consoleGroup := engine.Group("/operate/hotgame-game")
|
|
||||||
consoleGroup.Use(consoleAuthMiddleware(javaClient))
|
|
||||||
consoleGroup.GET("/config", func(c *gin.Context) {
|
|
||||||
resp, err := service.GetProviderConfig(c.Request.Context(), c.Query("sysOrigin"), c.Query("profile"))
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, resp)
|
|
||||||
})
|
|
||||||
consoleGroup.POST("/config", func(c *gin.Context) {
|
|
||||||
var req hotgame.SaveProviderConfigRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resp, err := service.SaveProviderConfig(c.Request.Context(), req)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, resp)
|
|
||||||
})
|
|
||||||
consoleGroup.POST("/config/activate", func(c *gin.Context) {
|
|
||||||
var req hotgame.ActivateProviderProfileRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resp, err := service.ActivateProviderProfile(c.Request.Context(), req)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, resp)
|
|
||||||
})
|
|
||||||
consoleGroup.GET("/catalog/page", func(c *gin.Context) {
|
|
||||||
cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1")))
|
|
||||||
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
|
|
||||||
resp, err := service.PageCatalog(
|
|
||||||
c.Request.Context(),
|
|
||||||
c.Query("sysOrigin"),
|
|
||||||
c.Query("profile"),
|
|
||||||
c.Query("keyword"),
|
|
||||||
cursor,
|
|
||||||
limit,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, resp)
|
|
||||||
})
|
|
||||||
consoleGroup.POST("/catalog/fetch", func(c *gin.Context) {
|
|
||||||
var req hotgame.SyncCatalogRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resp, err := service.FetchAdminCatalog(c.Request.Context(), req)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, resp)
|
|
||||||
})
|
|
||||||
consoleGroup.POST("/import-catalog", func(c *gin.Context) {
|
|
||||||
var req hotgame.SyncCatalogRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defaultShowcase := true
|
|
||||||
if req.DefaultShowcase != nil {
|
|
||||||
defaultShowcase = *req.DefaultShowcase
|
|
||||||
}
|
|
||||||
resp, err := service.ImportCatalogGames(c.Request.Context(), req.SysOrigin, req.Profile, req.VendorGameIDs, defaultShowcase)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, resp)
|
|
||||||
})
|
|
||||||
consoleGroup.POST("/sync", func(c *gin.Context) {
|
|
||||||
var req hotgame.SyncCatalogRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resp, err := service.SyncAdminGames(c.Request.Context(), req)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, resp)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -37,15 +37,6 @@ func registerManagerCenterRoutes(engine *gin.Engine, javaClient authGateway, ser
|
|||||||
writeOK(c, resp)
|
writeOK(c, resp)
|
||||||
})
|
})
|
||||||
|
|
||||||
group.GET("/countries", func(c *gin.Context) {
|
|
||||||
resp, err := service.ListCountries(c.Request.Context(), mustAuthUser(c))
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, resp)
|
|
||||||
})
|
|
||||||
|
|
||||||
group.GET("/props", func(c *gin.Context) {
|
group.GET("/props", func(c *gin.Context) {
|
||||||
resp, err := service.ListProps(c.Request.Context(), mustAuthUser(c), c.Query("type"))
|
resp, err := service.ListProps(c.Request.Context(), mustAuthUser(c), c.Query("type"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -119,18 +110,4 @@ func registerManagerCenterRoutes(engine *gin.Engine, javaClient authGateway, ser
|
|||||||
}
|
}
|
||||||
writeOK(c, resp)
|
writeOK(c, resp)
|
||||||
})
|
})
|
||||||
|
|
||||||
group.POST("/users/country", func(c *gin.Context) {
|
|
||||||
var req managercenter.ChangeUserCountryRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
writeError(c, managercenter.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resp, err := service.ChangeUserCountry(c.Request.Context(), mustAuthUser(c), req)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, resp)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/subtle"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -20,14 +19,6 @@ type authGateway interface {
|
|||||||
AuthenticateConsoleToken(ctx context.Context, authorization string) (integration.ConsoleAccount, error)
|
AuthenticateConsoleToken(ctx context.Context, authorization string) (integration.ConsoleAccount, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type consolePermissionGateway interface {
|
|
||||||
ListConsoleButtonAliases(ctx context.Context, authorization string) (map[string]struct{}, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type consoleMenuGateway interface {
|
|
||||||
HasConsoleMenuAlias(ctx context.Context, authorization, requiredAlias string) (bool, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// authMiddleware 校验用户 token,并把认证后的用户信息写入上下文。
|
// authMiddleware 校验用户 token,并把认证后的用户信息写入上下文。
|
||||||
func authMiddleware(javaClient authGateway) gin.HandlerFunc {
|
func authMiddleware(javaClient authGateway) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
@ -82,58 +73,6 @@ func consoleAuthMiddleware(javaClient authGateway) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// consolePermissionMiddleware 在写接口执行前读取 Java Console 的真实按钮权限,禁止只依赖前端隐藏按钮。
|
|
||||||
func consolePermissionMiddleware(javaClient authGateway, requiredAlias string) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
gateway, ok := javaClient.(consolePermissionGateway)
|
|
||||||
if !ok {
|
|
||||||
writeError(c, common.NewAppError(http.StatusServiceUnavailable, "permission_gateway_unavailable", "console permission gateway is unavailable"))
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
aliases, err := gateway.ListConsoleButtonAliases(ctx, strings.TrimSpace(c.GetHeader("Authorization")))
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, common.NewAppError(http.StatusForbidden, "permission_check_failed", err.Error()))
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, allowed := aliases[requiredAlias]; !allowed {
|
|
||||||
writeError(c, common.NewAppError(http.StatusForbidden, "permission_denied", "required permission: "+requiredAlias))
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// consoleMenuMiddleware 校验页面菜单权限,所有管理端 GET 都不能只依赖前端路由可见性。
|
|
||||||
func consoleMenuMiddleware(javaClient authGateway, requiredAlias string) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
gateway, ok := javaClient.(consoleMenuGateway)
|
|
||||||
if !ok {
|
|
||||||
writeError(c, common.NewAppError(http.StatusServiceUnavailable, "menu_gateway_unavailable", "console menu gateway is unavailable"))
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
allowed, err := gateway.HasConsoleMenuAlias(ctx, strings.TrimSpace(c.GetHeader("Authorization")), requiredAlias)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, common.NewAppError(http.StatusForbidden, "menu_permission_check_failed", err.Error()))
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !allowed {
|
|
||||||
writeError(c, common.NewAppError(http.StatusForbidden, "permission_denied", "required menu: "+requiredAlias))
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// internalSecretMiddleware 校验内部回调密钥。
|
// internalSecretMiddleware 校验内部回调密钥。
|
||||||
func internalSecretMiddleware(secret string) gin.HandlerFunc {
|
func internalSecretMiddleware(secret string) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
@ -153,29 +92,6 @@ func internalSecretMiddleware(secret string) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// requiredInternalSecretMiddleware 用于会触发资金/奖励副作用的内部入口。
|
|
||||||
// 与兼容历史调用的 internalSecretMiddleware 不同,服务端未配置密钥时必须 fail-closed,
|
|
||||||
// 否则错误的部署配置会把无鉴权请求直接放进结算状态机。
|
|
||||||
func requiredInternalSecretMiddleware(secret string) gin.HandlerFunc {
|
|
||||||
configuredSecret := strings.TrimSpace(secret)
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if configuredSecret == "" {
|
|
||||||
writeError(c, common.NewAppError(http.StatusServiceUnavailable, "internal_secret_not_configured", "internal callback secret is not configured"))
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token := strings.TrimSpace(c.GetHeader("X-Internal-Token"))
|
|
||||||
if subtle.ConstantTimeCompare([]byte(token), []byte(configuredSecret)) != 1 {
|
|
||||||
writeError(c, common.NewAppError(http.StatusUnauthorized, "invalid_internal_token", "internal token is invalid"))
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// mustAuthUser 从 Gin 上下文读取已经通过鉴权的用户。
|
// mustAuthUser 从 Gin 上下文读取已经通过鉴权的用户。
|
||||||
func mustAuthUser(c *gin.Context) common.AuthUser {
|
func mustAuthUser(c *gin.Context) common.AuthUser {
|
||||||
value, exists := c.Get(authUserContextKey)
|
value, exists := c.Get(authUserContextKey)
|
||||||
@ -196,9 +112,6 @@ func trimBearer(authorization string) string {
|
|||||||
|
|
||||||
// resolveClientIP 解析请求来源 IP。
|
// resolveClientIP 解析请求来源 IP。
|
||||||
func resolveClientIP(c *gin.Context) string {
|
func resolveClientIP(c *gin.Context) string {
|
||||||
if realIP := strings.TrimSpace(c.GetHeader("X-Real-IP")); realIP != "" {
|
|
||||||
return realIP
|
|
||||||
}
|
|
||||||
if forwarded := strings.TrimSpace(c.GetHeader("X-Forwarded-For")); forwarded != "" {
|
if forwarded := strings.TrimSpace(c.GetHeader("X-Forwarded-For")); forwarded != "" {
|
||||||
parts := strings.Split(forwarded, ",")
|
parts := strings.Split(forwarded, ",")
|
||||||
if len(parts) > 0 {
|
if len(parts) > 0 {
|
||||||
|
|||||||
@ -11,11 +11,9 @@ import (
|
|||||||
"chatapp3-golang/internal/service/binancerecharge"
|
"chatapp3-golang/internal/service/binancerecharge"
|
||||||
"chatapp3-golang/internal/service/errorlog"
|
"chatapp3-golang/internal/service/errorlog"
|
||||||
"chatapp3-golang/internal/service/firstrechargereward"
|
"chatapp3-golang/internal/service/firstrechargereward"
|
||||||
"chatapp3-golang/internal/service/gameking"
|
|
||||||
"chatapp3-golang/internal/service/gameopen"
|
"chatapp3-golang/internal/service/gameopen"
|
||||||
"chatapp3-golang/internal/service/gameprovider"
|
"chatapp3-golang/internal/service/gameprovider"
|
||||||
"chatapp3-golang/internal/service/hostcenter"
|
"chatapp3-golang/internal/service/hostcenter"
|
||||||
"chatapp3-golang/internal/service/hotgame"
|
|
||||||
"chatapp3-golang/internal/service/invite"
|
"chatapp3-golang/internal/service/invite"
|
||||||
"chatapp3-golang/internal/service/lingxian"
|
"chatapp3-golang/internal/service/lingxian"
|
||||||
"chatapp3-golang/internal/service/luckygift"
|
"chatapp3-golang/internal/service/luckygift"
|
||||||
@ -35,7 +33,6 @@ import (
|
|||||||
"chatapp3-golang/internal/service/voiceroomrocket"
|
"chatapp3-golang/internal/service/voiceroomrocket"
|
||||||
"chatapp3-golang/internal/service/weekstar"
|
"chatapp3-golang/internal/service/weekstar"
|
||||||
"chatapp3-golang/internal/service/wheel"
|
"chatapp3-golang/internal/service/wheel"
|
||||||
"chatapp3-golang/internal/service/yumigiftchallenge"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -50,14 +47,11 @@ type Services struct {
|
|||||||
FirstRechargeReward *firstrechargereward.Service
|
FirstRechargeReward *firstrechargereward.Service
|
||||||
Lingxian *lingxian.Service
|
Lingxian *lingxian.Service
|
||||||
GameOpen *gameopen.GameOpenService
|
GameOpen *gameopen.GameOpenService
|
||||||
GameKing *gameking.Service
|
|
||||||
GameProviders *gameprovider.Registry
|
GameProviders *gameprovider.Registry
|
||||||
GameVisibility *gameprovider.VisibilityPolicy
|
|
||||||
LuckyGift *luckygift.LuckyGiftService
|
LuckyGift *luckygift.LuckyGiftService
|
||||||
ManagerCenter *managercenter.Service
|
ManagerCenter *managercenter.Service
|
||||||
PropsStore *propsstore.Service
|
PropsStore *propsstore.Service
|
||||||
HostCenter *hostcenter.Service
|
HostCenter *hostcenter.Service
|
||||||
Hotgame *hotgame.Service
|
|
||||||
RechargeAgency *rechargeagency.Service
|
RechargeAgency *rechargeagency.Service
|
||||||
RechargeReward *rechargereward.Service
|
RechargeReward *rechargereward.Service
|
||||||
RegionIMGroup *regionimgroup.Service
|
RegionIMGroup *regionimgroup.Service
|
||||||
@ -72,7 +66,6 @@ type Services struct {
|
|||||||
VoiceRoomRedPacket *voiceroomredpacket.Service
|
VoiceRoomRedPacket *voiceroomredpacket.Service
|
||||||
VoiceRoomRocket *voiceroomrocket.Service
|
VoiceRoomRocket *voiceroomrocket.Service
|
||||||
WeekStar *weekstar.WeekStarService
|
WeekStar *weekstar.WeekStarService
|
||||||
YumiGiftChallenge *yumigiftchallenge.Service
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRouter 创建 Gin 路由,并按模块注册所有接口。
|
// NewRouter 创建 Gin 路由,并按模块注册所有接口。
|
||||||
@ -96,8 +89,6 @@ func NewRouter(
|
|||||||
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
|
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
|
||||||
registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
|
registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
|
||||||
registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter)
|
registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter)
|
||||||
registerTaskCenterArchiveGCRoutes(engine, cfg, javaClient, services.TaskCenter)
|
|
||||||
registerGameKingRoutes(engine, cfg, javaClient, services.GameKing)
|
|
||||||
registerUserBadgeRoutes(engine, javaClient, services.UserBadge)
|
registerUserBadgeRoutes(engine, javaClient, services.UserBadge)
|
||||||
registerVipRoutes(engine, javaClient, services.VIP)
|
registerVipRoutes(engine, javaClient, services.VIP)
|
||||||
registerWheelRoutes(engine, cfg, javaClient, services.Wheel)
|
registerWheelRoutes(engine, cfg, javaClient, services.Wheel)
|
||||||
@ -106,14 +97,12 @@ func NewRouter(
|
|||||||
registerVoiceRoomRedPacketRoutes(engine, javaClient, services.VoiceRoomRedPacket)
|
registerVoiceRoomRedPacketRoutes(engine, javaClient, services.VoiceRoomRedPacket)
|
||||||
registerVoiceRoomRocketRoutes(engine, cfg, javaClient, services.VoiceRoomRocket)
|
registerVoiceRoomRocketRoutes(engine, cfg, javaClient, services.VoiceRoomRocket)
|
||||||
registerWeekStarRoutes(engine, javaClient, services.WeekStar)
|
registerWeekStarRoutes(engine, javaClient, services.WeekStar)
|
||||||
registerYumiGiftChallengeRoutes(engine, cfg, javaClient, services.YumiGiftChallenge)
|
|
||||||
registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward)
|
registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward)
|
||||||
registerHostCenterRoutes(engine, services.HostCenter)
|
registerHostCenterRoutes(engine, services.HostCenter)
|
||||||
registerGameOpenRoutes(engine, cfg, services.GameOpen)
|
registerGameOpenRoutes(engine, cfg, services.GameOpen)
|
||||||
registerGameProviderRoutes(engine, javaClient, services.GameProviders, services.GameVisibility)
|
registerGameProviderRoutes(engine, javaClient, services.GameProviders)
|
||||||
registerBaishunRoutes(engine, cfg, javaClient, services.Baishun)
|
registerBaishunRoutes(engine, cfg, javaClient, services.Baishun)
|
||||||
registerLingxianRoutes(engine, javaClient, services.Lingxian)
|
registerLingxianRoutes(engine, javaClient, services.Lingxian)
|
||||||
registerHotgameRoutes(engine, javaClient, services.Hotgame)
|
|
||||||
registerLuckyGiftRoutes(engine, cfg, services.LuckyGift)
|
registerLuckyGiftRoutes(engine, cfg, services.LuckyGift)
|
||||||
registerManagerCenterRoutes(engine, javaClient, services.ManagerCenter)
|
registerManagerCenterRoutes(engine, javaClient, services.ManagerCenter)
|
||||||
registerPropsStoreRoutes(engine, javaClient, services.PropsStore)
|
registerPropsStoreRoutes(engine, javaClient, services.PropsStore)
|
||||||
|
|||||||
@ -1,81 +0,0 @@
|
|||||||
package router
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/config"
|
|
||||||
"chatapp3-golang/internal/service/taskcenter"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
const taskCenterArchiveGCManagePermission = "resident-activity:task-center-archive-gc:manage"
|
|
||||||
const taskCenterArchiveGCMenuAlias = "ResidentTaskCenterArchiveGC"
|
|
||||||
|
|
||||||
// registerTaskCenterArchiveGCRoutes 只做管理命令/cron tick 的 HTTP 绑定;所有状态机、
|
|
||||||
// 租约、COS 校验和删除边界均由 taskcenter service 实现。
|
|
||||||
func registerTaskCenterArchiveGCRoutes(engine *gin.Engine, cfg config.Config, javaClient authGateway, service *taskcenter.Service) {
|
|
||||||
if service == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
admin := engine.Group("/resident-activity/task-center/archive-gc")
|
|
||||||
admin.Use(consoleAuthMiddleware(javaClient))
|
|
||||||
admin.GET("/status", consoleMenuMiddleware(javaClient, taskCenterArchiveGCMenuAlias), func(c *gin.Context) {
|
|
||||||
response, err := service.ArchiveGCStatus(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, response)
|
|
||||||
})
|
|
||||||
admin.POST("/dry-run", consolePermissionMiddleware(javaClient, taskCenterArchiveGCManagePermission), func(c *gin.Context) {
|
|
||||||
var request taskcenter.ArchiveGCDryRunRequest
|
|
||||||
if err := c.ShouldBindJSON(&request); err != nil {
|
|
||||||
writeError(c, taskcenter.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response, err := service.ArchiveGCDryRun(c.Request.Context(), request)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, response)
|
|
||||||
})
|
|
||||||
admin.POST("/start", consolePermissionMiddleware(javaClient, taskCenterArchiveGCManagePermission), func(c *gin.Context) {
|
|
||||||
response, err := service.StartArchiveGC(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, response)
|
|
||||||
})
|
|
||||||
admin.POST("/pause", consolePermissionMiddleware(javaClient, taskCenterArchiveGCManagePermission), func(c *gin.Context) {
|
|
||||||
response, err := service.PauseArchiveGC(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, response)
|
|
||||||
})
|
|
||||||
admin.POST("/resume", consolePermissionMiddleware(javaClient, taskCenterArchiveGCManagePermission), func(c *gin.Context) {
|
|
||||||
response, err := service.ResumeArchiveGC(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, response)
|
|
||||||
})
|
|
||||||
|
|
||||||
internal := engine.Group("/internal/task-center/archive-gc")
|
|
||||||
// tick 会最终删除生产数据,密钥缺失也必须 fail-closed,不能沿用历史空密钥兼容。
|
|
||||||
internal.Use(requiredInternalSecretMiddleware(cfg.HTTP.InternalCallbackSecret))
|
|
||||||
internal.POST("/tick", func(c *gin.Context) {
|
|
||||||
response, err := service.ArchiveGCTick(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, response)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -1,335 +0,0 @@
|
|||||||
package router
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/config"
|
|
||||||
"chatapp3-golang/internal/service/yumigiftchallenge"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
const yumiGiftChallengeMenuAlias = "ResidentYumiGiftChallenge"
|
|
||||||
|
|
||||||
// registerYumiGiftChallengeRoutes 注册 Yumi H5 用户接口与 Console 配置、结算、核账接口。
|
|
||||||
func registerYumiGiftChallengeRoutes(engine *gin.Engine, cfg config.Config, javaClient authGateway, service *yumigiftchallenge.Service) {
|
|
||||||
if service == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
internal := engine.Group("/internal/yumi-gift-challenge")
|
|
||||||
// 结算会产生真实奖励副作用;密钥缺失也必须拒绝,不能沿用历史内部接口的空密钥兼容行为。
|
|
||||||
internal.Use(requiredInternalSecretMiddleware(cfg.HTTP.InternalCallbackSecret))
|
|
||||||
internal.POST("/settle-due", func(c *gin.Context) {
|
|
||||||
batch, err := parseOptionalInt(c.Query("batch"))
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response, err := service.SettleDue(c.Request.Context(), batch)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, response)
|
|
||||||
})
|
|
||||||
h5 := engine.Group("/app/h5/gift-challenge/yumi")
|
|
||||||
h5.Use(authMiddleware(javaClient))
|
|
||||||
h5.GET("/detail", func(c *gin.Context) {
|
|
||||||
activityID, err := parseOptionalInt64(c.Query("activityId"))
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response, err := service.GetDetail(c.Request.Context(), mustAuthUser(c), activityID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, response)
|
|
||||||
})
|
|
||||||
h5.POST("/enter", func(c *gin.Context) {
|
|
||||||
var request yumigiftchallenge.EnterRequest
|
|
||||||
if err := c.ShouldBindJSON(&request); err != nil {
|
|
||||||
writeBadRequest(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response, err := service.Enter(c.Request.Context(), mustAuthUser(c), request)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, response)
|
|
||||||
})
|
|
||||||
h5.GET("/ranking", func(c *gin.Context) {
|
|
||||||
activityID, err := parseOptionalInt64(c.Query("activityId"))
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
limit, err := parseOptionalInt(c.Query("limit"))
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
period := firstNonEmptyString(c.Query("periodType"), c.Query("period"))
|
|
||||||
response, err := service.GetRanking(c.Request.Context(), mustAuthUser(c), activityID, period, c.Query("statDate"), limit)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, response)
|
|
||||||
})
|
|
||||||
h5.POST("/tasks/:taskCode/claim", func(c *gin.Context) {
|
|
||||||
var request yumigiftchallenge.ClaimRequest
|
|
||||||
if err := c.ShouldBindJSON(&request); err != nil {
|
|
||||||
writeBadRequest(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response, err := service.ClaimTask(c.Request.Context(), mustAuthUser(c), c.Param("taskCode"), request)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, response)
|
|
||||||
})
|
|
||||||
|
|
||||||
admin := engine.Group("/resident-activity/yumi-gift-challenge")
|
|
||||||
admin.Use(consoleAuthMiddleware(javaClient))
|
|
||||||
read := admin.Group("")
|
|
||||||
read.Use(consoleMenuMiddleware(javaClient, yumiGiftChallengeMenuAlias))
|
|
||||||
read.GET("/list", func(c *gin.Context) {
|
|
||||||
page, pageErr := parseOptionalInt(c.Query("page"))
|
|
||||||
size, sizeErr := parseOptionalInt(c.Query("size"))
|
|
||||||
if pageErr != nil || sizeErr != nil {
|
|
||||||
writeError(c, firstParseError(pageErr, sizeErr))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response, err := service.ListActivities(c.Request.Context(), c.Query("sysOrigin"), page, size)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, response)
|
|
||||||
})
|
|
||||||
read.GET("/detail/:id", func(c *gin.Context) {
|
|
||||||
activityID, ok := requirePathInt64(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response, err := service.GetAdminDetail(c.Request.Context(), activityID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, response)
|
|
||||||
})
|
|
||||||
read.GET("/tasks/:id", func(c *gin.Context) {
|
|
||||||
activityID, ok := requirePathInt64(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response, err := service.GetAdminDetail(c.Request.Context(), activityID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, response.Tasks)
|
|
||||||
})
|
|
||||||
read.GET("/rank-rewards/:id", func(c *gin.Context) {
|
|
||||||
activityID, ok := requirePathInt64(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response, err := service.GetAdminDetail(c.Request.Context(), activityID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, response.RankRewards)
|
|
||||||
})
|
|
||||||
read.GET("/ranking/:id", func(c *gin.Context) {
|
|
||||||
activityID, ok := requirePathInt64(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
limit, err := parseOptionalInt(c.Query("limit"))
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
period := firstNonEmptyString(c.Query("periodType"), c.Query("period"))
|
|
||||||
response, err := service.GetAdminRanking(c.Request.Context(), activityID, period, c.Query("statDate"), limit)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, response)
|
|
||||||
})
|
|
||||||
read.GET("/settlement-records/:id", func(c *gin.Context) {
|
|
||||||
activityID, ok := requirePathInt64(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
page, pageErr := parseOptionalInt(c.Query("page"))
|
|
||||||
size, sizeErr := parseOptionalInt(c.Query("size"))
|
|
||||||
if pageErr != nil || sizeErr != nil {
|
|
||||||
writeError(c, firstParseError(pageErr, sizeErr))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response, err := service.PageSettlementRecords(c.Request.Context(), activityID, c.Query("periodType"), c.Query("statDate"), c.Query("deliveryStatus"), page, size)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, response)
|
|
||||||
})
|
|
||||||
|
|
||||||
admin.POST("/save", consolePermissionMiddleware(javaClient, "resident-activity:yumi-gift-challenge:edit"), func(c *gin.Context) {
|
|
||||||
var request yumigiftchallenge.SaveRequest
|
|
||||||
if err := c.ShouldBindJSON(&request); err != nil {
|
|
||||||
writeBadRequest(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response, err := service.SaveActivity(c.Request.Context(), request)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, response)
|
|
||||||
})
|
|
||||||
admin.PUT("/enable/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-gift-challenge:enable"), func(c *gin.Context) {
|
|
||||||
activityID, ok := requirePathInt64(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
enabled, err := strconv.ParseBool(strings.TrimSpace(c.Query("enabled")))
|
|
||||||
if err != nil {
|
|
||||||
writeBadRequest(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := service.SetEnabled(c.Request.Context(), activityID, enabled); err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, gin.H{"id": strconv.FormatInt(activityID, 10), "enabled": enabled})
|
|
||||||
})
|
|
||||||
admin.PUT("/tasks/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-gift-challenge:edit"), func(c *gin.Context) {
|
|
||||||
activityID, ok := requirePathInt64(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var request yumigiftchallenge.TaskSaveRequest
|
|
||||||
if err := c.ShouldBindJSON(&request); err != nil {
|
|
||||||
writeBadRequest(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
request.ActivityID = yumigiftchallenge.FlexibleInt64(activityID)
|
|
||||||
response, err := service.SaveTasks(c.Request.Context(), activityID, request)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, response)
|
|
||||||
})
|
|
||||||
admin.PUT("/rank-rewards/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-gift-challenge:edit"), func(c *gin.Context) {
|
|
||||||
activityID, ok := requirePathInt64(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var request yumigiftchallenge.RankRewardSaveRequest
|
|
||||||
if err := c.ShouldBindJSON(&request); err != nil {
|
|
||||||
writeBadRequest(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
request.ActivityID = yumigiftchallenge.FlexibleInt64(activityID)
|
|
||||||
response, err := service.SaveRankRewards(c.Request.Context(), activityID, request)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, response)
|
|
||||||
})
|
|
||||||
admin.POST("/settlement/:id", consolePermissionMiddleware(javaClient, "resident-activity:yumi-gift-challenge:settle"), func(c *gin.Context) {
|
|
||||||
activityID, ok := requirePathInt64(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var request yumigiftchallenge.SettlementRequest
|
|
||||||
if err := c.ShouldBindJSON(&request); err != nil {
|
|
||||||
writeBadRequest(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := service.Settle(c.Request.Context(), activityID, request); err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, gin.H{"settled": true})
|
|
||||||
})
|
|
||||||
admin.POST("/delivery-item/resolve/:itemId", consolePermissionMiddleware(javaClient, "resident-activity:yumi-gift-challenge:reconcile"), func(c *gin.Context) {
|
|
||||||
itemID, ok := requirePathInt64(c, "itemId")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var request yumigiftchallenge.ResolveDeliveryRequest
|
|
||||||
if err := c.ShouldBindJSON(&request); err != nil {
|
|
||||||
writeBadRequest(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := service.ResolveUnknownDeliveryItem(c.Request.Context(), itemID, request.Delivered); err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, gin.H{"id": strconv.FormatInt(itemID, 10), "delivered": request.Delivered})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseOptionalInt64(value string) (int64, error) {
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
if value == "" {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
|
||||||
if err != nil || parsed <= 0 {
|
|
||||||
return 0, yumigiftchallenge.NewAppError(http.StatusBadRequest, "invalid_id", "id must be a positive int64 string")
|
|
||||||
}
|
|
||||||
return parsed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseOptionalInt(value string) (int, error) {
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
if value == "" {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
parsed, err := strconv.Atoi(value)
|
|
||||||
if err != nil || parsed < 0 {
|
|
||||||
return 0, yumigiftchallenge.NewAppError(http.StatusBadRequest, "invalid_number", "query number must be a non-negative integer")
|
|
||||||
}
|
|
||||||
return parsed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func requirePathInt64(c *gin.Context, name string) (int64, bool) {
|
|
||||||
value, err := parseOptionalInt64(c.Param(name))
|
|
||||||
if err != nil || value <= 0 {
|
|
||||||
if err == nil {
|
|
||||||
err = yumigiftchallenge.NewAppError(http.StatusBadRequest, "invalid_id", name+" must be a positive int64 string")
|
|
||||||
}
|
|
||||||
writeError(c, err)
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return value, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstParseError(values ...error) error {
|
|
||||||
for _, err := range values {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return yumigiftchallenge.NewAppError(http.StatusBadRequest, "bad_request", "invalid query")
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeBadRequest(c *gin.Context, err error) {
|
|
||||||
writeError(c, yumigiftchallenge.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
|
||||||
}
|
|
||||||
@ -13,7 +13,7 @@ import (
|
|||||||
|
|
||||||
// ListShortcutGames 返回房间里的快捷游戏列表,数量上限为 5 个。
|
// ListShortcutGames 返回房间里的快捷游戏列表,数量上限为 5 个。
|
||||||
func (s *BaishunService) ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) {
|
func (s *BaishunService) ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) {
|
||||||
items, err := s.listRoomGames(ctx, user.SysOrigin, user.RegionID, roomID, "")
|
items, err := s.listRoomGames(ctx, user.SysOrigin, roomID, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -25,7 +25,7 @@ func (s *BaishunService) ListShortcutGames(ctx context.Context, user AuthUser, r
|
|||||||
|
|
||||||
// ListRoomGames 返回房间可启动的完整游戏列表。
|
// ListRoomGames 返回房间可启动的完整游戏列表。
|
||||||
func (s *BaishunService) ListRoomGames(ctx context.Context, user AuthUser, roomID string, category string) (*RoomGameListResponse, error) {
|
func (s *BaishunService) ListRoomGames(ctx context.Context, user AuthUser, roomID string, category string) (*RoomGameListResponse, error) {
|
||||||
items, err := s.listRoomGames(ctx, user.SysOrigin, user.RegionID, roomID, category)
|
items, err := s.listRoomGames(ctx, user.SysOrigin, roomID, category)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -59,7 +59,7 @@ func (s *BaishunService) GetRoomState(ctx context.Context, user AuthUser, roomID
|
|||||||
}
|
}
|
||||||
|
|
||||||
// listRoomGames 联表读取房间可启动游戏,并按展示规则排序。
|
// listRoomGames 联表读取房间可启动游戏,并按展示规则排序。
|
||||||
func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, regionID, roomID, category string) ([]RoomGameListItem, error) {
|
func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, roomID, category string) ([]RoomGameListItem, error) {
|
||||||
sysOrigin = normalizeAdminSysOrigin(sysOrigin)
|
sysOrigin = normalizeAdminSysOrigin(sysOrigin)
|
||||||
profile, err := s.activeProfile(ctx, sysOrigin)
|
profile, err := s.activeProfile(ctx, sysOrigin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -102,10 +102,6 @@ func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, regionID,
|
|||||||
Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1 AND ext.profile = ?", profile).
|
Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1 AND ext.profile = ?", profile).
|
||||||
Joins("LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND cat.profile = ext.profile AND CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id").
|
Joins("LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND cat.profile = ext.profile AND CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id").
|
||||||
Where("cfg.sys_origin = ? AND cfg.is_showcase = 1 AND ext.vendor_type = ?", sysOrigin, baishunVendorType)
|
Where("cfg.sys_origin = ? AND cfg.is_showcase = 1 AND ext.vendor_type = ?", sysOrigin, baishunVendorType)
|
||||||
if strings.TrimSpace(regionID) != "" {
|
|
||||||
// regions 为空表示全区域;有值时按用户区域 ID 精确匹配,避免土耳其用户看到其他区域游戏。
|
|
||||||
query = query.Where("(COALESCE(TRIM(cfg.regions), '') = '' OR FIND_IN_SET(?, REPLACE(COALESCE(cfg.regions, ''), ' ', '')) > 0)", strings.TrimSpace(regionID))
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(category) != "" && !strings.EqualFold(strings.TrimSpace(category), "CHAT_ROOM") {
|
if strings.TrimSpace(category) != "" && !strings.EqualFold(strings.TrimSpace(category), "CHAT_ROOM") {
|
||||||
query = query.Where("cfg.category = ?", category)
|
query = query.Where("cfg.category = ?", category)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,7 +41,6 @@ func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*ConfigRespo
|
|||||||
ID: configRow.ID,
|
ID: configRow.ID,
|
||||||
SysOrigin: configRow.SysOrigin,
|
SysOrigin: configRow.SysOrigin,
|
||||||
Enabled: configRow.Enabled,
|
Enabled: configRow.Enabled,
|
||||||
MinAppVersion: strings.TrimSpace(configRow.MinAppVersion),
|
|
||||||
UpdateTime: formatDateTime(configRow.UpdateTime),
|
UpdateTime: formatDateTime(configRow.UpdateTime),
|
||||||
LevelConfigs: buildLevelPayloads(levels, rewardItems, 0, 0, true),
|
LevelConfigs: buildLevelPayloads(levels, rewardItems, 0, 0, true),
|
||||||
}, nil
|
}, nil
|
||||||
@ -57,10 +56,6 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*Confi
|
|||||||
if req.Enabled && !hasEnabledLevel(levels) {
|
if req.Enabled && !hasEnabledLevel(levels) {
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_level_configs", "enabled config must contain at least one enabled level")
|
return nil, NewAppError(http.StatusBadRequest, "invalid_level_configs", "enabled config must contain at least one enabled level")
|
||||||
}
|
}
|
||||||
minAppVersion := strings.TrimSpace(req.MinAppVersion)
|
|
||||||
if req.Enabled && minAppVersion == "" {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_min_app_version", "minAppVersion is required when enabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
var savedConfigID int64
|
var savedConfigID int64
|
||||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
@ -96,7 +91,6 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*Confi
|
|||||||
|
|
||||||
configRow.SysOrigin = sysOrigin
|
configRow.SysOrigin = sysOrigin
|
||||||
configRow.Enabled = req.Enabled
|
configRow.Enabled = req.Enabled
|
||||||
configRow.MinAppVersion = minAppVersion
|
|
||||||
configRow.UpdateTime = now
|
configRow.UpdateTime = now
|
||||||
if configRow.CreateTime.IsZero() {
|
if configRow.CreateTime.IsZero() {
|
||||||
configRow.CreateTime = now
|
configRow.CreateTime = now
|
||||||
@ -132,7 +126,6 @@ func (s *Service) normalizeLevelInputs(inputs []LevelInput) ([]model.FirstRechar
|
|||||||
}
|
}
|
||||||
seenLevels := make(map[int]struct{}, len(inputs))
|
seenLevels := make(map[int]struct{}, len(inputs))
|
||||||
seenAmounts := make(map[int64]struct{}, len(inputs))
|
seenAmounts := make(map[int64]struct{}, len(inputs))
|
||||||
seenGoogleProductIDs := make(map[string]struct{}, len(inputs))
|
|
||||||
rows := make([]model.FirstRechargeRewardLevel, 0, len(inputs))
|
rows := make([]model.FirstRechargeRewardLevel, 0, len(inputs))
|
||||||
for _, item := range inputs {
|
for _, item := range inputs {
|
||||||
if item.Level <= 0 {
|
if item.Level <= 0 {
|
||||||
@ -155,13 +148,6 @@ func (s *Service) normalizeLevelInputs(inputs []LevelInput) ([]model.FirstRechar
|
|||||||
if _, exists := seenAmounts[amountCents]; exists {
|
if _, exists := seenAmounts[amountCents]; exists {
|
||||||
return nil, NewAppError(http.StatusBadRequest, "duplicate_recharge_amount", "levelConfigs must not contain duplicate rechargeAmount")
|
return nil, NewAppError(http.StatusBadRequest, "duplicate_recharge_amount", "levelConfigs must not contain duplicate rechargeAmount")
|
||||||
}
|
}
|
||||||
googleProductID := normalizeGoogleProductID(item.GoogleProductID)
|
|
||||||
if googleProductID != "" {
|
|
||||||
if _, exists := seenGoogleProductIDs[googleProductID]; exists {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "duplicate_google_product_id", "levelConfigs must not contain duplicate googleProductId")
|
|
||||||
}
|
|
||||||
seenGoogleProductIDs[googleProductID] = struct{}{}
|
|
||||||
}
|
|
||||||
seenLevels[item.Level] = struct{}{}
|
seenLevels[item.Level] = struct{}{}
|
||||||
seenAmounts[amountCents] = struct{}{}
|
seenAmounts[amountCents] = struct{}{}
|
||||||
id, err := utils.NextID()
|
id, err := utils.NextID()
|
||||||
@ -172,7 +158,6 @@ func (s *Service) normalizeLevelInputs(inputs []LevelInput) ([]model.FirstRechar
|
|||||||
ID: id,
|
ID: id,
|
||||||
Level: item.Level,
|
Level: item.Level,
|
||||||
RechargeAmountCents: amountCents,
|
RechargeAmountCents: amountCents,
|
||||||
GoogleProductID: googleProductID,
|
|
||||||
RewardGroupID: rewardGroupID,
|
RewardGroupID: rewardGroupID,
|
||||||
RewardGroupName: strings.TrimSpace(item.RewardGroupName),
|
RewardGroupName: strings.TrimSpace(item.RewardGroupName),
|
||||||
Enabled: item.Enabled,
|
Enabled: item.Enabled,
|
||||||
|
|||||||
@ -41,6 +41,9 @@ func (s *Service) ProcessRechargeEvent(ctx context.Context, event RechargeEvent)
|
|||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if !isFirstRechargeRewardAppVersionSupported(normalized.AppVersion) {
|
||||||
|
return &ProcessRechargeResponse{Processed: false, Reason: "app_version_not_supported"}, nil
|
||||||
|
}
|
||||||
if !isAcceptedPaymentMethod(normalized.PaymentMethod, normalized.PayPlatform) {
|
if !isAcceptedPaymentMethod(normalized.PaymentMethod, normalized.PayPlatform) {
|
||||||
return &ProcessRechargeResponse{Processed: false, Reason: "unsupported_payment_method"}, nil
|
return &ProcessRechargeResponse{Processed: false, Reason: "unsupported_payment_method"}, nil
|
||||||
}
|
}
|
||||||
@ -58,12 +61,9 @@ func (s *Service) ProcessRechargeEvent(ctx context.Context, event RechargeEvent)
|
|||||||
if !bundle.Config.Enabled {
|
if !bundle.Config.Enabled {
|
||||||
return &ProcessRechargeResponse{Processed: false, Reason: "config_disabled"}, nil
|
return &ProcessRechargeResponse{Processed: false, Reason: "config_disabled"}, nil
|
||||||
}
|
}
|
||||||
if !isFirstRechargeRewardAppVersionSupported(normalized.AppVersion, bundle.Config.MinAppVersion) {
|
matched, ok := pickMatchedLevel(bundle.Levels, normalized.AmountCents)
|
||||||
return &ProcessRechargeResponse{Processed: false, Reason: "app_version_not_supported"}, nil
|
|
||||||
}
|
|
||||||
matched, ok := pickMatchedLevel(bundle.Levels, normalized.AmountCents, normalized.GoogleProductID)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
return &ProcessRechargeResponse{Processed: false, Reason: "level_not_matched"}, nil
|
return &ProcessRechargeResponse{Processed: false, Reason: "amount_not_reached"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
record, terminal, err := s.getOrCreateGrantRecord(ctx, normalized, bundle.Config, matched)
|
record, terminal, err := s.getOrCreateGrantRecord(ctx, normalized, bundle.Config, matched)
|
||||||
@ -112,14 +112,13 @@ func (s *Service) normalizeRechargeEvent(event RechargeEvent) (normalizedRecharg
|
|||||||
if amountCents <= 0 {
|
if amountCents <= 0 {
|
||||||
amountCents = firstPositiveAmountCents(event.Amount.String(), event.AmountUSD.String(), event.USDAmount.String())
|
amountCents = firstPositiveAmountCents(event.Amount.String(), event.AmountUSD.String(), event.USDAmount.String())
|
||||||
}
|
}
|
||||||
|
if amountCents <= 0 {
|
||||||
|
return normalizedRechargeEvent{}, NewAppError(http.StatusBadRequest, "invalid_amount", "amountCents or amount is required")
|
||||||
|
}
|
||||||
|
|
||||||
payPlatform := strings.ToUpper(strings.TrimSpace(event.PayPlatform))
|
payPlatform := strings.ToUpper(strings.TrimSpace(event.PayPlatform))
|
||||||
paymentMethod := strings.ToUpper(strings.TrimSpace(event.PaymentMethod))
|
paymentMethod := strings.ToUpper(strings.TrimSpace(event.PaymentMethod))
|
||||||
appVersion := normalizeRechargeAppVersion(event)
|
appVersion := normalizeRechargeAppVersion(event)
|
||||||
googleProductID := normalizeRechargeGoogleProductID(event)
|
|
||||||
if amountCents <= 0 && googleProductID == "" {
|
|
||||||
return normalizedRechargeEvent{}, NewAppError(http.StatusBadRequest, "invalid_recharge_identity", "amountCents, amount, or googleProductId is required")
|
|
||||||
}
|
|
||||||
sourceOrderID := firstNonEmpty(event.SourceOrderID.String(), event.OrderID.String())
|
sourceOrderID := firstNonEmpty(event.SourceOrderID.String(), event.OrderID.String())
|
||||||
eventID := strings.TrimSpace(event.EventID)
|
eventID := strings.TrimSpace(event.EventID)
|
||||||
if eventID == "" && sourceOrderID != "" {
|
if eventID == "" && sourceOrderID != "" {
|
||||||
@ -141,7 +140,6 @@ func (s *Service) normalizeRechargeEvent(event RechargeEvent) (normalizedRecharg
|
|||||||
PayPlatform: payPlatform,
|
PayPlatform: payPlatform,
|
||||||
PaymentMethod: paymentMethod,
|
PaymentMethod: paymentMethod,
|
||||||
AppVersion: appVersion,
|
AppVersion: appVersion,
|
||||||
GoogleProductID: googleProductID,
|
|
||||||
SourceOrderID: sourceOrderID,
|
SourceOrderID: sourceOrderID,
|
||||||
OccurredAt: occurredAt,
|
OccurredAt: occurredAt,
|
||||||
}, nil
|
}, nil
|
||||||
@ -189,7 +187,6 @@ func (s *Service) getOrCreateGrantRecord(
|
|||||||
Level: level.Level,
|
Level: level.Level,
|
||||||
PayPlatform: event.PayPlatform,
|
PayPlatform: event.PayPlatform,
|
||||||
PaymentMethod: event.PaymentMethod,
|
PaymentMethod: event.PaymentMethod,
|
||||||
GoogleProductID: event.GoogleProductID,
|
|
||||||
SourceOrderID: event.SourceOrderID,
|
SourceOrderID: event.SourceOrderID,
|
||||||
RewardGroupID: level.RewardGroupID,
|
RewardGroupID: level.RewardGroupID,
|
||||||
RewardGroupName: level.RewardGroupName,
|
RewardGroupName: level.RewardGroupName,
|
||||||
@ -293,7 +290,6 @@ func (s *Service) pushRewardNotice(ctx context.Context, record *model.FirstRecha
|
|||||||
"rewardGroupName": record.RewardGroupName,
|
"rewardGroupName": record.RewardGroupName,
|
||||||
"payPlatform": record.PayPlatform,
|
"payPlatform": record.PayPlatform,
|
||||||
"paymentMethod": record.PaymentMethod,
|
"paymentMethod": record.PaymentMethod,
|
||||||
"googleProductId": record.GoogleProductID,
|
|
||||||
"noticeType": firstRechargeRewardNoticeType,
|
"noticeType": firstRechargeRewardNoticeType,
|
||||||
}
|
}
|
||||||
return s.java.SendOfficialNoticeCustomize(ctx, integration.OfficialNoticeCustomizeRequest{
|
return s.java.SendOfficialNoticeCustomize(ctx, integration.OfficialNoticeCustomizeRequest{
|
||||||
@ -376,18 +372,6 @@ func normalizeRechargeAppVersion(event RechargeEvent) string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeRechargeGoogleProductID(event RechargeEvent) string {
|
|
||||||
return normalizeGoogleProductID(firstNonEmpty(
|
|
||||||
event.GoogleProductID,
|
|
||||||
event.ProductID.String(),
|
|
||||||
rechargePayloadString(event.Payload, "googleProductId", "productId", "product_id"),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeGoogleProductID(value string) string {
|
|
||||||
return strings.TrimSpace(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func rechargePayloadString(payload json.RawMessage, keys ...string) string {
|
func rechargePayloadString(payload json.RawMessage, keys ...string) string {
|
||||||
if len(payload) == 0 {
|
if len(payload) == 0 {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@ -20,7 +20,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// GetHome returns app-facing first recharge reward config and the user's grant state.
|
// GetHome returns app-facing first recharge reward config and the user's grant state.
|
||||||
func (s *Service) GetHome(ctx context.Context, user AuthUser, appVersion string) (*HomeResponse, error) {
|
func (s *Service) GetHome(ctx context.Context, user AuthUser) (*HomeResponse, error) {
|
||||||
sysOrigin, err := requireUser(user)
|
sysOrigin, err := requireUser(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -52,17 +52,12 @@ func (s *Service) GetHome(ctx context.Context, user AuthUser, appVersion string)
|
|||||||
ActivityStatus: activityStatusOngoing,
|
ActivityStatus: activityStatusOngoing,
|
||||||
UserID: user.UserID,
|
UserID: user.UserID,
|
||||||
SysOrigin: bundle.Config.SysOrigin,
|
SysOrigin: bundle.Config.SysOrigin,
|
||||||
MinAppVersion: bundle.Config.MinAppVersion,
|
|
||||||
LevelConfigs: buildLevelPayloads(bundle.Levels, rewardItems, 0, 0, false),
|
LevelConfigs: buildLevelPayloads(bundle.Levels, rewardItems, 0, 0, false),
|
||||||
HasRewardRecord: false,
|
HasRewardRecord: false,
|
||||||
}
|
}
|
||||||
if !bundle.Config.Enabled {
|
if !bundle.Config.Enabled {
|
||||||
resp.ActivityStatus = activityStatusDisabled
|
resp.ActivityStatus = activityStatusDisabled
|
||||||
}
|
}
|
||||||
if bundle.Config.Enabled && !isFirstRechargeRewardAppVersionSupported(appVersion, bundle.Config.MinAppVersion) {
|
|
||||||
resp.Enabled = false
|
|
||||||
resp.ActivityStatus = activityStatusDisabled
|
|
||||||
}
|
|
||||||
|
|
||||||
record, ok, err := s.loadUserGrantRecord(ctx, bundle.Config.SysOrigin, user.UserID)
|
record, ok, err := s.loadUserGrantRecord(ctx, bundle.Config.SysOrigin, user.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -15,7 +15,6 @@ func configSnapshotFromModel(row model.FirstRechargeRewardConfig) configSnapshot
|
|||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
SysOrigin: strings.ToUpper(strings.TrimSpace(row.SysOrigin)),
|
SysOrigin: strings.ToUpper(strings.TrimSpace(row.SysOrigin)),
|
||||||
Enabled: row.Enabled,
|
Enabled: row.Enabled,
|
||||||
MinAppVersion: strings.TrimSpace(row.MinAppVersion),
|
|
||||||
UpdateTime: row.UpdateTime,
|
UpdateTime: row.UpdateTime,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -25,7 +24,6 @@ func levelSnapshotFromModel(row model.FirstRechargeRewardLevel) levelSnapshot {
|
|||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
Level: row.Level,
|
Level: row.Level,
|
||||||
RechargeAmountCents: row.RechargeAmountCents,
|
RechargeAmountCents: row.RechargeAmountCents,
|
||||||
GoogleProductID: normalizeGoogleProductID(row.GoogleProductID),
|
|
||||||
RewardGroupID: row.RewardGroupID,
|
RewardGroupID: row.RewardGroupID,
|
||||||
RewardGroupName: strings.TrimSpace(row.RewardGroupName),
|
RewardGroupName: strings.TrimSpace(row.RewardGroupName),
|
||||||
Enabled: row.Enabled,
|
Enabled: row.Enabled,
|
||||||
@ -53,7 +51,6 @@ func buildLevelPayloads(
|
|||||||
Level: level.Level,
|
Level: level.Level,
|
||||||
RechargeAmount: formatAmountCents(level.RechargeAmountCents),
|
RechargeAmount: formatAmountCents(level.RechargeAmountCents),
|
||||||
RechargeAmountCents: level.RechargeAmountCents,
|
RechargeAmountCents: level.RechargeAmountCents,
|
||||||
GoogleProductID: level.GoogleProductID,
|
|
||||||
RewardGroupID: level.RewardGroupID,
|
RewardGroupID: level.RewardGroupID,
|
||||||
RewardGroupName: level.RewardGroupName,
|
RewardGroupName: level.RewardGroupName,
|
||||||
RewardItems: rewardItems[level.RewardGroupID],
|
RewardItems: rewardItems[level.RewardGroupID],
|
||||||
@ -65,27 +62,19 @@ func buildLevelPayloads(
|
|||||||
return payloads
|
return payloads
|
||||||
}
|
}
|
||||||
|
|
||||||
func pickMatchedLevel(levels []levelSnapshot, rechargeAmountCents int64, googleProductID string) (levelSnapshot, bool) {
|
func pickMatchedLevel(levels []levelSnapshot, rechargeAmountCents int64) (levelSnapshot, bool) {
|
||||||
googleProductID = normalizeGoogleProductID(googleProductID)
|
var matched levelSnapshot
|
||||||
if googleProductID != "" {
|
ok := false
|
||||||
for _, level := range levels {
|
|
||||||
if !level.Enabled || level.RewardGroupID <= 0 || level.GoogleProductID == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if level.GoogleProductID == googleProductID {
|
|
||||||
return level, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, level := range levels {
|
for _, level := range levels {
|
||||||
if !level.Enabled || level.RechargeAmountCents <= 0 || level.RewardGroupID <= 0 {
|
if !level.Enabled || level.RechargeAmountCents <= 0 || level.RewardGroupID <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if rechargeAmountCents == level.RechargeAmountCents {
|
if rechargeAmountCents >= level.RechargeAmountCents && (!ok || level.RechargeAmountCents > matched.RechargeAmountCents) {
|
||||||
return level, true
|
matched = level
|
||||||
|
ok = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return levelSnapshot{}, false
|
return matched, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func sortLevels(levels []levelSnapshot) {
|
func sortLevels(levels []levelSnapshot) {
|
||||||
|
|||||||
@ -92,7 +92,6 @@ func grantRecordViewFromModel(row model.FirstRechargeRewardGrantRecord, rewardIt
|
|||||||
Level: row.Level,
|
Level: row.Level,
|
||||||
PayPlatform: row.PayPlatform,
|
PayPlatform: row.PayPlatform,
|
||||||
PaymentMethod: row.PaymentMethod,
|
PaymentMethod: row.PaymentMethod,
|
||||||
GoogleProductID: row.GoogleProductID,
|
|
||||||
SourceOrderID: row.SourceOrderID,
|
SourceOrderID: row.SourceOrderID,
|
||||||
RewardGroupID: row.RewardGroupID,
|
RewardGroupID: row.RewardGroupID,
|
||||||
RewardGroupName: row.RewardGroupName,
|
RewardGroupName: row.RewardGroupName,
|
||||||
|
|||||||
@ -20,9 +20,8 @@ func TestSaveConfigReturnsRewardItems(t *testing.T) {
|
|||||||
req := mustDecodeSaveConfigRequest(t, `{
|
req := mustDecodeSaveConfigRequest(t, `{
|
||||||
"sysOrigin": "LIKEI",
|
"sysOrigin": "LIKEI",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"minAppVersion": "1.5.0",
|
|
||||||
"levelConfigs": [
|
"levelConfigs": [
|
||||||
{"level": 1, "rechargeAmount": "9.99", "googleProductId": "gold_999", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
|
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
|
||||||
]
|
]
|
||||||
}`)
|
}`)
|
||||||
resp, err := service.SaveConfig(context.Background(), req)
|
resp, err := service.SaveConfig(context.Background(), req)
|
||||||
@ -39,9 +38,6 @@ func TestSaveConfigReturnsRewardItems(t *testing.T) {
|
|||||||
if level.RechargeAmountCents != 999 || level.RewardGroupID != 1001 {
|
if level.RechargeAmountCents != 999 || level.RewardGroupID != 1001 {
|
||||||
t.Fatalf("level payload = %+v", level)
|
t.Fatalf("level payload = %+v", level)
|
||||||
}
|
}
|
||||||
if level.GoogleProductID != "gold_999" {
|
|
||||||
t.Fatalf("google product id = %q", level.GoogleProductID)
|
|
||||||
}
|
|
||||||
if len(level.RewardItems) != 1 || level.RewardItems[0].Name != "首冲礼包奖励" {
|
if len(level.RewardItems) != 1 || level.RewardItems[0].Name != "首冲礼包奖励" {
|
||||||
t.Fatalf("reward items = %+v", level.RewardItems)
|
t.Fatalf("reward items = %+v", level.RewardItems)
|
||||||
}
|
}
|
||||||
@ -70,7 +66,6 @@ func TestSaveConfigReturnsVipCoverFromSourceURL(t *testing.T) {
|
|||||||
resp, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
|
resp, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
|
||||||
"sysOrigin": "LIKEI",
|
"sysOrigin": "LIKEI",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"minAppVersion": "1.5.0",
|
|
||||||
"levelConfigs": [
|
"levelConfigs": [
|
||||||
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "2001", "rewardGroupName": "VIP礼包", "enabled": true}
|
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "2001", "rewardGroupName": "VIP礼包", "enabled": true}
|
||||||
]
|
]
|
||||||
@ -120,7 +115,6 @@ func TestRewardItemsReturnConcretePropsType(t *testing.T) {
|
|||||||
resp, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
|
resp, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
|
||||||
"sysOrigin": "LIKEI",
|
"sysOrigin": "LIKEI",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"minAppVersion": "1.5.0",
|
|
||||||
"levelConfigs": [
|
"levelConfigs": [
|
||||||
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "3001", "rewardGroupName": "道具礼包", "enabled": true}
|
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "3001", "rewardGroupName": "道具礼包", "enabled": true}
|
||||||
]
|
]
|
||||||
@ -153,9 +147,8 @@ func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
|
|||||||
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
|
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
|
||||||
"sysOrigin": "LIKEI",
|
"sysOrigin": "LIKEI",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"minAppVersion": "1.5.0",
|
|
||||||
"levelConfigs": [
|
"levelConfigs": [
|
||||||
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
|
{"level": 1, "rechargeAmount": "5.00", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
|
||||||
]
|
]
|
||||||
}`))
|
}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -192,7 +185,7 @@ func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("low amount ProcessRechargeEvent() error = %v", err)
|
t.Fatalf("low amount ProcessRechargeEvent() error = %v", err)
|
||||||
}
|
}
|
||||||
if lowAmount.Processed || lowAmount.Reason != "level_not_matched" {
|
if lowAmount.Processed || lowAmount.Reason != "amount_not_reached" {
|
||||||
t.Fatalf("low amount response = %+v", lowAmount)
|
t.Fatalf("low amount response = %+v", lowAmount)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -299,9 +292,8 @@ func TestProcessRechargeEventStartsCountingAtSupportedAppVersion(t *testing.T) {
|
|||||||
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
|
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
|
||||||
"sysOrigin": "LIKEI",
|
"sysOrigin": "LIKEI",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"minAppVersion": "1.5.0",
|
|
||||||
"levelConfigs": [
|
"levelConfigs": [
|
||||||
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
|
{"level": 1, "rechargeAmount": "5.00", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
|
||||||
]
|
]
|
||||||
}`))
|
}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -388,86 +380,13 @@ func TestFirstRechargeRewardAppVersionSupported(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if got := isFirstRechargeRewardAppVersionSupported(tt.appVersion, "1.5.0"); got != tt.want {
|
if got := isFirstRechargeRewardAppVersionSupported(tt.appVersion); got != tt.want {
|
||||||
t.Fatalf("isFirstRechargeRewardAppVersionSupported(%q) = %v, want %v", tt.appVersion, got, tt.want)
|
t.Fatalf("isFirstRechargeRewardAppVersionSupported(%q) = %v, want %v", tt.appVersion, got, tt.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHomeRequiresConfiguredMinAppVersion(t *testing.T) {
|
|
||||||
service, gateway, _ := newTestService(t)
|
|
||||||
gateway.groups[1001] = testRewardGroup(1001, "首冲礼包")
|
|
||||||
|
|
||||||
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
|
|
||||||
"sysOrigin": "LIKEI",
|
|
||||||
"enabled": true,
|
|
||||||
"minAppVersion": "2.0.0",
|
|
||||||
"levelConfigs": [
|
|
||||||
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
|
|
||||||
]
|
|
||||||
}`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SaveConfig() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
oldVersion, err := service.GetHome(context.Background(), AuthUser{UserID: 123, SysOrigin: "LIKEI"}, "1.9.9")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("old version GetHome() error = %v", err)
|
|
||||||
}
|
|
||||||
if oldVersion.Enabled || oldVersion.ActivityStatus != activityStatusDisabled {
|
|
||||||
t.Fatalf("old version home = %+v", oldVersion)
|
|
||||||
}
|
|
||||||
|
|
||||||
supported, err := service.GetHome(context.Background(), AuthUser{UserID: 123, SysOrigin: "LIKEI"}, "2.0.0")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("supported GetHome() error = %v", err)
|
|
||||||
}
|
|
||||||
if !supported.Enabled || supported.ActivityStatus != activityStatusOngoing {
|
|
||||||
t.Fatalf("supported home = %+v", supported)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessRechargeEventMatchesGoogleProductIDBeforeAmount(t *testing.T) {
|
|
||||||
service, gateway, _ := newTestService(t)
|
|
||||||
gateway.groups[1001] = testRewardGroup(1001, "首冲礼包")
|
|
||||||
gateway.groups[1002] = testRewardGroup(1002, "Google 首冲礼包")
|
|
||||||
|
|
||||||
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
|
|
||||||
"sysOrigin": "LIKEI",
|
|
||||||
"enabled": true,
|
|
||||||
"minAppVersion": "1.5.0",
|
|
||||||
"levelConfigs": [
|
|
||||||
{"level": 1, "rechargeAmount": "9.99", "googleProductId": "gold_999", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true},
|
|
||||||
{"level": 2, "rechargeAmount": "19.99", "googleProductId": "first_recharge_google_1999", "rewardGroupId": "1002", "rewardGroupName": "Google 首冲礼包", "enabled": true}
|
|
||||||
]
|
|
||||||
}`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SaveConfig() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
granted, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
|
|
||||||
"eventId": "RECHARGE_SUCCESS:GOOGLE:PRODUCT_ID",
|
|
||||||
"sysOrigin": "LIKEI",
|
|
||||||
"userId": "123",
|
|
||||||
"amountCents": "999",
|
|
||||||
"payPlatform": "GOOGLE",
|
|
||||||
"paymentMethod": "GOOGLE",
|
|
||||||
"googleProductId": "first_recharge_google_1999",
|
|
||||||
"appVersion": "1.5.0",
|
|
||||||
"sourceOrderId": "PRODUCT_ID"
|
|
||||||
}`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("product id ProcessRechargeEvent() error = %v", err)
|
|
||||||
}
|
|
||||||
if !granted.Processed || granted.Status != statusSuccess || granted.Level != 2 {
|
|
||||||
t.Fatalf("product id response = %+v", granted)
|
|
||||||
}
|
|
||||||
if len(gateway.rewardRequests) != 1 || gateway.rewardRequests[0].SourceGroupID != 1002 {
|
|
||||||
t.Fatalf("reward requests = %+v", gateway.rewardRequests)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestService(t *testing.T) (*Service, *fakeGateway, *gorm.DB) {
|
func newTestService(t *testing.T) (*Service, *fakeGateway, *gorm.DB) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||||
|
|||||||
@ -65,7 +65,6 @@ type LevelPayload struct {
|
|||||||
Level int `json:"level"`
|
Level int `json:"level"`
|
||||||
RechargeAmount string `json:"rechargeAmount"`
|
RechargeAmount string `json:"rechargeAmount"`
|
||||||
RechargeAmountCents int64 `json:"rechargeAmountCents"`
|
RechargeAmountCents int64 `json:"rechargeAmountCents"`
|
||||||
GoogleProductID string `json:"googleProductId,omitempty"`
|
|
||||||
RewardGroupID int64 `json:"rewardGroupId,string"`
|
RewardGroupID int64 `json:"rewardGroupId,string"`
|
||||||
RewardGroupName string `json:"rewardGroupName,omitempty"`
|
RewardGroupName string `json:"rewardGroupName,omitempty"`
|
||||||
RewardItems []RewardItem `json:"rewardItems"`
|
RewardItems []RewardItem `json:"rewardItems"`
|
||||||
@ -79,7 +78,6 @@ type LevelInput struct {
|
|||||||
Level int `json:"level"`
|
Level int `json:"level"`
|
||||||
RechargeAmount flexibleAmount `json:"rechargeAmount"`
|
RechargeAmount flexibleAmount `json:"rechargeAmount"`
|
||||||
RechargeAmountCents int64 `json:"rechargeAmountCents"`
|
RechargeAmountCents int64 `json:"rechargeAmountCents"`
|
||||||
GoogleProductID string `json:"googleProductId"`
|
|
||||||
RewardGroupID flexibleInt64 `json:"rewardGroupId"`
|
RewardGroupID flexibleInt64 `json:"rewardGroupId"`
|
||||||
RewardGroupName string `json:"rewardGroupName"`
|
RewardGroupName string `json:"rewardGroupName"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
@ -90,7 +88,6 @@ type ConfigResponse struct {
|
|||||||
ID int64 `json:"id,string"`
|
ID int64 `json:"id,string"`
|
||||||
SysOrigin string `json:"sysOrigin"`
|
SysOrigin string `json:"sysOrigin"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
|
||||||
UpdateTime string `json:"updateTime,omitempty"`
|
UpdateTime string `json:"updateTime,omitempty"`
|
||||||
LevelConfigs []LevelPayload `json:"levelConfigs"`
|
LevelConfigs []LevelPayload `json:"levelConfigs"`
|
||||||
}
|
}
|
||||||
@ -99,7 +96,6 @@ type SaveConfigRequest struct {
|
|||||||
ID flexibleInt64 `json:"id"`
|
ID flexibleInt64 `json:"id"`
|
||||||
SysOrigin string `json:"sysOrigin"`
|
SysOrigin string `json:"sysOrigin"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
MinAppVersion string `json:"minAppVersion"`
|
|
||||||
LevelConfigs []LevelInput `json:"levelConfigs"`
|
LevelConfigs []LevelInput `json:"levelConfigs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,7 +108,6 @@ type HomeResponse struct {
|
|||||||
HasRewardRecord bool `json:"hasRewardRecord"`
|
HasRewardRecord bool `json:"hasRewardRecord"`
|
||||||
RewardStatus string `json:"rewardStatus,omitempty"`
|
RewardStatus string `json:"rewardStatus,omitempty"`
|
||||||
Rewarded bool `json:"rewarded"`
|
Rewarded bool `json:"rewarded"`
|
||||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
|
||||||
MatchedLevel int `json:"matchedLevel,omitempty"`
|
MatchedLevel int `json:"matchedLevel,omitempty"`
|
||||||
RechargeAmount string `json:"rechargeAmount,omitempty"`
|
RechargeAmount string `json:"rechargeAmount,omitempty"`
|
||||||
RechargeAmountCents int64 `json:"rechargeAmountCents,omitempty"`
|
RechargeAmountCents int64 `json:"rechargeAmountCents,omitempty"`
|
||||||
@ -146,7 +141,6 @@ type GrantRecordView struct {
|
|||||||
Level int `json:"level"`
|
Level int `json:"level"`
|
||||||
PayPlatform string `json:"payPlatform"`
|
PayPlatform string `json:"payPlatform"`
|
||||||
PaymentMethod string `json:"paymentMethod"`
|
PaymentMethod string `json:"paymentMethod"`
|
||||||
GoogleProductID string `json:"googleProductId,omitempty"`
|
|
||||||
SourceOrderID string `json:"sourceOrderId,omitempty"`
|
SourceOrderID string `json:"sourceOrderId,omitempty"`
|
||||||
RewardGroupID int64 `json:"rewardGroupId,string"`
|
RewardGroupID int64 `json:"rewardGroupId,string"`
|
||||||
RewardGroupName string `json:"rewardGroupName,omitempty"`
|
RewardGroupName string `json:"rewardGroupName,omitempty"`
|
||||||
@ -182,8 +176,6 @@ type RechargeEvent struct {
|
|||||||
Currency string `json:"currency"`
|
Currency string `json:"currency"`
|
||||||
PayPlatform string `json:"payPlatform"`
|
PayPlatform string `json:"payPlatform"`
|
||||||
PaymentMethod string `json:"paymentMethod"`
|
PaymentMethod string `json:"paymentMethod"`
|
||||||
GoogleProductID string `json:"googleProductId"`
|
|
||||||
ProductID flexibleString `json:"productId"`
|
|
||||||
AppVersion flexibleString `json:"appVersion"`
|
AppVersion flexibleString `json:"appVersion"`
|
||||||
ClientVersion flexibleString `json:"clientVersion"`
|
ClientVersion flexibleString `json:"clientVersion"`
|
||||||
Version flexibleString `json:"version"`
|
Version flexibleString `json:"version"`
|
||||||
@ -211,7 +203,6 @@ type configSnapshot struct {
|
|||||||
ID int64
|
ID int64
|
||||||
SysOrigin string
|
SysOrigin string
|
||||||
Enabled bool
|
Enabled bool
|
||||||
MinAppVersion string
|
|
||||||
UpdateTime time.Time
|
UpdateTime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -219,7 +210,6 @@ type levelSnapshot struct {
|
|||||||
ID int64
|
ID int64
|
||||||
Level int
|
Level int
|
||||||
RechargeAmountCents int64
|
RechargeAmountCents int64
|
||||||
GoogleProductID string
|
|
||||||
RewardGroupID int64
|
RewardGroupID int64
|
||||||
RewardGroupName string
|
RewardGroupName string
|
||||||
Enabled bool
|
Enabled bool
|
||||||
@ -234,7 +224,6 @@ type normalizedRechargeEvent struct {
|
|||||||
PayPlatform string
|
PayPlatform string
|
||||||
PaymentMethod string
|
PaymentMethod string
|
||||||
AppVersion string
|
AppVersion string
|
||||||
GoogleProductID string
|
|
||||||
SourceOrderID string
|
SourceOrderID string
|
||||||
OccurredAt time.Time
|
OccurredAt time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,18 +5,14 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultFirstRechargeRewardMinAppVersion = "1.5.0"
|
const firstRechargeRewardMinAppVersion = "1.5.0"
|
||||||
|
|
||||||
func isFirstRechargeRewardAppVersionSupported(appVersion string, minAppVersion string) bool {
|
func isFirstRechargeRewardAppVersionSupported(appVersion string) bool {
|
||||||
appVersion = strings.TrimSpace(appVersion)
|
appVersion = strings.TrimSpace(appVersion)
|
||||||
minAppVersion = strings.TrimSpace(minAppVersion)
|
|
||||||
if minAppVersion == "" {
|
|
||||||
minAppVersion = defaultFirstRechargeRewardMinAppVersion
|
|
||||||
}
|
|
||||||
if appVersion == "" {
|
if appVersion == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return compareAppVersion(appVersion, minAppVersion) >= 0
|
return compareAppVersion(appVersion, firstRechargeRewardMinAppVersion) >= 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func compareAppVersion(left, right string) int {
|
func compareAppVersion(left, right string) int {
|
||||||
|
|||||||
@ -1,71 +0,0 @@
|
|||||||
package gameking
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ensureActivityEnableSafetyTx 把“索引已在线”和“GC 没在删除”纳入首次启用事务。
|
|
||||||
// 它与 GC 删除事务锁同一控制行,因此两个并发请求不会同时各自通过旧状态检查。
|
|
||||||
func ensureActivityEnableSafetyTx(tx *gorm.DB) error {
|
|
||||||
if err := requireBackfillIndexOnDB(tx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var run model.TaskCenterArchiveGCRun
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", uint8(1)).First(&run).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return NewAppError(http.StatusServiceUnavailable, "archive_gc_migration_required", "migration 060 archive GC safety state is required before enabling game king")
|
|
||||||
}
|
|
||||||
return NewAppError(http.StatusServiceUnavailable, "archive_gc_state_check_failed", "cannot verify archive GC state before enabling game king")
|
|
||||||
}
|
|
||||||
if strings.EqualFold(run.Status, "RUNNING") {
|
|
||||||
return NewAppError(http.StatusConflict, "archive_gc_running", "pause task center archive GC before enabling game king")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireBackfillIndexOnDB(db *gorm.DB) error {
|
|
||||||
var columns []backfillIndexColumn
|
|
||||||
if err := db.Raw(`
|
|
||||||
-- 与任务中心 GC 门禁保持相同映射规则;不加别名时 MySQL 的大写列标签不会填充
|
|
||||||
-- 显式小写 GORM tag,导致真实存在的 Game King 索引也被误判为缺失。
|
|
||||||
SELECT COLUMN_NAME AS column_name,
|
|
||||||
SEQ_IN_INDEX AS seq_in_index,
|
|
||||||
SUB_PART AS sub_part,
|
|
||||||
INDEX_TYPE AS index_type,
|
|
||||||
IS_VISIBLE AS is_visible
|
|
||||||
FROM information_schema.STATISTICS
|
|
||||||
WHERE TABLE_SCHEMA = DATABASE()
|
|
||||||
AND TABLE_NAME = 'task_center_event_archive_outbox'
|
|
||||||
AND INDEX_NAME = ?
|
|
||||||
ORDER BY SEQ_IN_INDEX ASC`, taskCenterGameKingBackfillIndex).Scan(&columns).Error; err != nil {
|
|
||||||
return NewAppError(http.StatusServiceUnavailable, "backfill_index_check_failed", "cannot verify the required task center backfill index")
|
|
||||||
}
|
|
||||||
|
|
||||||
required := [...]string{"sys_origin", "event_type", "occurred_at", "id"}
|
|
||||||
if len(columns) == len(required) {
|
|
||||||
valid := true
|
|
||||||
for index, requiredName := range required {
|
|
||||||
column := columns[index]
|
|
||||||
if column.SeqInIndex != index+1 || !strings.EqualFold(column.ColumnName, requiredName) || column.SubPart != nil ||
|
|
||||||
!strings.EqualFold(column.IndexType, "BTREE") || !strings.EqualFold(column.IsVisible, "YES") {
|
|
||||||
valid = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if valid {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return NewAppError(
|
|
||||||
http.StatusServiceUnavailable,
|
|
||||||
"backfill_index_required",
|
|
||||||
"migration 058 backfill index (sys_origin,event_type,occurred_at,id) must be online before enabling or backfill",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
package gameking
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
|
||||||
|
|
||||||
// loadOrCreateBackfillState 先建立活动级水位行,后续并发补数才能通过行锁检测游标竞争,
|
|
||||||
// 避免较慢请求把较快请求已经推进的复合游标覆盖回旧位置。
|
|
||||||
func (s *Service) loadOrCreateBackfillState(ctx context.Context, activity model.YumiGameKingActivity) (model.YumiGameKingBackfillState, error) {
|
|
||||||
now := time.Now()
|
|
||||||
start := activity.StartTime
|
|
||||||
initial := model.YumiGameKingBackfillState{
|
|
||||||
ActivityID: activity.ID, LastOccurredAt: &start, CreateTime: now, UpdateTime: now,
|
|
||||||
}
|
|
||||||
if err := s.db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&initial).Error; err != nil {
|
|
||||||
return initial, err
|
|
||||||
}
|
|
||||||
var row model.YumiGameKingBackfillState
|
|
||||||
if err := s.db.WithContext(ctx).Where("activity_id = ?", activity.ID).First(&row).Error; err != nil {
|
|
||||||
return row, err
|
|
||||||
}
|
|
||||||
return row, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// adoptBackfillCursor 只兼容 migration 060 上线前调用方已经保存的合法 lastId;水位一旦
|
|
||||||
// 非零,调用方就不能跳转或回退游标。
|
|
||||||
func (s *Service) adoptBackfillCursor(ctx context.Context, activityID int64, cursor model.TaskCenterEventArchiveOutbox) (model.YumiGameKingBackfillState, error) {
|
|
||||||
var state model.YumiGameKingBackfillState
|
|
||||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("activity_id = ?", activityID).First(&state).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if state.LastOutboxID != 0 {
|
|
||||||
return NewAppError(http.StatusConflict, "backfill_cursor_conflict", "persisted backfill cursor changed; reload and retry")
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
if err := tx.Model(&model.YumiGameKingBackfillState{}).Where("activity_id = ?", activityID).Updates(map[string]any{
|
|
||||||
"last_outbox_id": cursor.ID, "last_occurred_at": cursor.OccurredAt,
|
|
||||||
"completed": false, "update_time": now,
|
|
||||||
}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
state.LastOutboxID = cursor.ID
|
|
||||||
state.LastOccurredAt = &cursor.OccurredAt
|
|
||||||
state.UpdateTime = now
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return state, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) persistBackfillState(
|
|
||||||
ctx context.Context,
|
|
||||||
activityID, expectedLastID int64,
|
|
||||||
nextLastID int64,
|
|
||||||
nextOccurredAt time.Time,
|
|
||||||
scanned, accepted int,
|
|
||||||
completed bool,
|
|
||||||
) error {
|
|
||||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
var locked model.YumiGameKingBackfillState
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("activity_id = ?", activityID).First(&locked).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if locked.LastOutboxID != expectedLastID {
|
|
||||||
// 本页可能已经通过幂等 ledger 入账;不覆盖赢家水位,调用方重读后重放即可安全收敛。
|
|
||||||
return NewAppError(http.StatusConflict, "backfill_cursor_conflict", "another backfill request advanced the persisted cursor; reload and retry")
|
|
||||||
}
|
|
||||||
return tx.Model(&model.YumiGameKingBackfillState{}).Where("activity_id = ?", activityID).Updates(map[string]any{
|
|
||||||
"last_outbox_id": nextLastID, "last_occurred_at": nextOccurredAt,
|
|
||||||
"scanned_rows": gorm.Expr("scanned_rows + ?", scanned),
|
|
||||||
"accepted_rows": gorm.Expr("accepted_rows + ?", accepted),
|
|
||||||
"completed": completed, "update_time": time.Now(),
|
|
||||||
}).Error
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -1,544 +0,0 @@
|
|||||||
package gameking
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
"chatapp3-golang/internal/utils"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ListActivities 返回后台活动列表,活动数量很小,避免引入无意义分页状态。
|
|
||||||
func (s *Service) ListActivities(ctx context.Context) ([]ActivityView, error) {
|
|
||||||
var rows []model.YumiGameKingActivity
|
|
||||||
if err := s.db.WithContext(ctx).Order("id DESC").Find(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result := make([]ActivityView, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
result = append(result, activityView(row))
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ManageDetail 返回后台活动及其七奖项、六排名档完整配置。
|
|
||||||
func (s *Service) ManageDetail(ctx context.Context, activityID int64) (*DetailResponse, error) {
|
|
||||||
activity, err := s.loadActivity(ctx, activityID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s.buildDetail(ctx, activity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detail 返回当前登录用户所属平台可见的活动详情。
|
|
||||||
func (s *Service) Detail(ctx context.Context, user AuthUser, activityID int64) (*DetailResponse, error) {
|
|
||||||
activity, err := s.resolveAppActivity(ctx, normalizeSysOrigin(user.SysOrigin), activityID, time.Now())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s.buildDetail(ctx, activity)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) buildDetail(ctx context.Context, activity model.YumiGameKingActivity) (*DetailResponse, error) {
|
|
||||||
prizes, err := s.loadPrizes(ctx, activity.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
rankRewards, err := s.loadRankRewards(ctx, activity.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
prizeViews := make([]PrizeView, 0, len(prizes))
|
|
||||||
for _, row := range prizes {
|
|
||||||
prizeViews = append(prizeViews, prizeView(row))
|
|
||||||
}
|
|
||||||
rankViews := make([]RankRewardView, 0, len(rankRewards))
|
|
||||||
for _, row := range rankRewards {
|
|
||||||
rankViews = append(rankViews, rankRewardView(row))
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
return &DetailResponse{
|
|
||||||
Activity: activityView(activity),
|
|
||||||
Prizes: prizeViews,
|
|
||||||
RankRewards: rankViews,
|
|
||||||
SupportedRankingTypes: []string{rankingTypeTycoon},
|
|
||||||
SupportedRankingPeriods: []string{"DAILY", rankingPeriodOverall},
|
|
||||||
ActivityStatus: activityStatus(activity, now),
|
|
||||||
ServerTime: now.UnixMilli(),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SaveActivity 新增或修改活动。首次启用前会重新读取全部奖励组并冻结展示快照。
|
|
||||||
func (s *Service) SaveActivity(ctx context.Context, req SaveActivityRequest) (*ActivityView, error) {
|
|
||||||
normalized, err := normalizeActivityInput(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var existing *model.YumiGameKingActivity
|
|
||||||
if normalized.ID.Int64() > 0 {
|
|
||||||
row, loadErr := s.loadActivity(ctx, normalized.ID.Int64())
|
|
||||||
if loadErr != nil {
|
|
||||||
return nil, loadErr
|
|
||||||
}
|
|
||||||
existing = &row
|
|
||||||
}
|
|
||||||
|
|
||||||
var normalizedPrizes []normalizedPrize
|
|
||||||
if normalized.Prizes != nil {
|
|
||||||
normalizedPrizes, err = s.normalizePrizes(ctx, normalized.SysOrigin, normalized.Prizes)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var normalizedRanks []normalizedRankReward
|
|
||||||
if normalized.RankRewards != nil {
|
|
||||||
normalizedRanks, err = s.normalizeRankRewards(ctx, normalized.SysOrigin, normalized.RankRewards)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 只在“首次启用/重新启用”的开始前状态补齐并刷新子配置。已经开始且仍为 enabled
|
|
||||||
// 的元数据编辑绝不能走这条路径,否则会重建奖品 ID、刷新快照并把已消耗库存写回旧值。
|
|
||||||
needsEnableValidation := normalized.Enabled && (existing == nil || !existing.Enabled)
|
|
||||||
if needsEnableValidation {
|
|
||||||
if normalizedPrizes == nil {
|
|
||||||
if existing == nil {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "prizes_required", "seven prizes are required before enabling")
|
|
||||||
}
|
|
||||||
inputs, loadErr := s.currentPrizeInputs(ctx, existing.ID)
|
|
||||||
if loadErr != nil {
|
|
||||||
return nil, loadErr
|
|
||||||
}
|
|
||||||
normalizedPrizes, err = s.normalizePrizes(ctx, normalized.SysOrigin, inputs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if normalizedRanks == nil {
|
|
||||||
if existing == nil {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "rank_rewards_required", "six rank reward tiers are required before enabling")
|
|
||||||
}
|
|
||||||
inputs, loadErr := s.currentRankInputs(ctx, existing.ID)
|
|
||||||
if loadErr != nil {
|
|
||||||
return nil, loadErr
|
|
||||||
}
|
|
||||||
normalizedRanks, err = s.normalizeRankRewards(ctx, normalized.SysOrigin, inputs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
activityID := normalized.ID.Int64()
|
|
||||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
var row model.YumiGameKingActivity
|
|
||||||
created := activityID <= 0
|
|
||||||
writeTime := time.Now()
|
|
||||||
if !created {
|
|
||||||
if err := withWriteLock(tx).Where("id = ?", activityID).First(&row).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return NewAppError(http.StatusNotFound, "activity_not_found", "game king activity was not found")
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// 奖励组远程校验和数据库锁等待都可能跨过 startTime,必须在拿到排他锁后
|
|
||||||
// 重新取时钟,不能用事务外的旧 now 绕过开始锁。
|
|
||||||
writeTime = time.Now()
|
|
||||||
if row.Enabled && !writeTime.Before(row.StartTime) {
|
|
||||||
if err := validateStartedActivityUpdate(row, normalized); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if normalizedPrizes != nil || normalizedRanks != nil {
|
|
||||||
return NewAppError(http.StatusConflict, "activity_config_locked", "prizes and rank rewards cannot change after activity start")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
activityID, err = utils.NextID()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
row = model.YumiGameKingActivity{ID: activityID, SettlementStatus: SettlementNotStarted, CreateTime: writeTime}
|
|
||||||
}
|
|
||||||
// 事务外读取时可能仍为 enabled,但在等待锁期间被另一个管理员关闭。此时本请求
|
|
||||||
// 没有执行重新启用所需的奖励组校验,必须失败并让调用方重试,不能用旧快照启用。
|
|
||||||
if normalized.Enabled && !row.Enabled && (normalizedPrizes == nil || normalizedRanks == nil) {
|
|
||||||
return NewAppError(http.StatusConflict, "activity_state_changed", "activity state changed; reload and retry")
|
|
||||||
}
|
|
||||||
|
|
||||||
if normalized.Enabled && !row.Enabled && !time.UnixMilli(normalized.StartTime).After(writeTime) {
|
|
||||||
return NewAppError(http.StatusConflict, "activity_start_must_be_future", "the first enabled startTime must be in the future")
|
|
||||||
}
|
|
||||||
// 已启用但尚未开始的活动仍可能修改时间窗;只要本次保存结果为 enabled,
|
|
||||||
// 就与 GC 删除事务锁同一控制行,避免 GC 按旧窗口保护后活动扩展到已删除区间。
|
|
||||||
if normalized.Enabled {
|
|
||||||
if err := ensureActivityEnableSafetyTx(tx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if normalized.Enabled {
|
|
||||||
if err := lockActivityScopeTx(tx, normalized.SysOrigin, writeTime); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := ensureNoEnabledOverlap(tx, activityID, normalized.SysOrigin, time.UnixMilli(normalized.StartTime), time.UnixMilli(normalized.EndTime)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var codeCount int64
|
|
||||||
if err := tx.Model(&model.YumiGameKingActivity{}).
|
|
||||||
Where("activity_code = ? AND id <> ?", normalized.ActivityCode, activityID).
|
|
||||||
Count(&codeCount).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if codeCount > 0 {
|
|
||||||
return NewAppError(http.StatusConflict, "activity_code_exists", "activityCode already exists")
|
|
||||||
}
|
|
||||||
|
|
||||||
row.ActivityCode = normalized.ActivityCode
|
|
||||||
row.ActivityName = normalized.ActivityName
|
|
||||||
row.ActivityDesc = normalized.ActivityDesc
|
|
||||||
row.SysOrigin = normalized.SysOrigin
|
|
||||||
row.Timezone = normalized.Timezone
|
|
||||||
row.StartTime = time.UnixMilli(normalized.StartTime)
|
|
||||||
row.EndTime = time.UnixMilli(normalized.EndTime)
|
|
||||||
row.SettlementDelayMinutes = *normalized.SettlementDelayMinutes
|
|
||||||
row.SettlementTime = row.EndTime.Add(time.Duration(row.SettlementDelayMinutes) * time.Minute)
|
|
||||||
row.CoinPerDraw = normalized.CoinPerDraw
|
|
||||||
row.RankingType = normalized.RankingType
|
|
||||||
row.RankingPeriod = normalized.RankingPeriod
|
|
||||||
row.Enabled = normalized.Enabled
|
|
||||||
row.UpdateTime = writeTime
|
|
||||||
if row.SettlementStatus == "" {
|
|
||||||
row.SettlementStatus = SettlementNotStarted
|
|
||||||
}
|
|
||||||
if err := tx.Save(&row).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if normalizedPrizes != nil {
|
|
||||||
if err := replacePrizesTx(tx, activityID, normalizedPrizes, writeTime); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if normalizedRanks != nil {
|
|
||||||
if err := replaceRankRewardsTx(tx, activityID, normalizedRanks, writeTime); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
row, err := s.loadActivity(ctx, activityID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
view := activityView(row)
|
|
||||||
return &view, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnableActivity 在活动开始前启停活动;启用会重新验证并刷新全部奖励快照。
|
|
||||||
func (s *Service) EnableActivity(ctx context.Context, activityID int64, enabled bool) error {
|
|
||||||
activity, err := s.loadActivity(ctx, activityID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if activity.Enabled && !time.Now().Before(activity.StartTime) {
|
|
||||||
return NewAppError(http.StatusConflict, "activity_config_locked", "activity enable state cannot change after activity start")
|
|
||||||
}
|
|
||||||
var prizes []normalizedPrize
|
|
||||||
var ranks []normalizedRankReward
|
|
||||||
if enabled {
|
|
||||||
prizeInputs, loadErr := s.currentPrizeInputs(ctx, activityID)
|
|
||||||
if loadErr != nil {
|
|
||||||
return loadErr
|
|
||||||
}
|
|
||||||
prizes, err = s.normalizePrizes(ctx, activity.SysOrigin, prizeInputs)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
rankInputs, loadErr := s.currentRankInputs(ctx, activityID)
|
|
||||||
if loadErr != nil {
|
|
||||||
return loadErr
|
|
||||||
}
|
|
||||||
ranks, err = s.normalizeRankRewards(ctx, activity.SysOrigin, rankInputs)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
var locked model.YumiGameKingActivity
|
|
||||||
if err := withWriteLock(tx).Where("id = ?", activityID).First(&locked).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
lockedNow := time.Now()
|
|
||||||
if locked.Enabled && !lockedNow.Before(locked.StartTime) {
|
|
||||||
return NewAppError(http.StatusConflict, "activity_config_locked", "activity enable state cannot change after activity start")
|
|
||||||
}
|
|
||||||
if enabled {
|
|
||||||
if err := ensureActivityEnableSafetyTx(tx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !locked.StartTime.After(lockedNow) {
|
|
||||||
return NewAppError(http.StatusConflict, "activity_start_must_be_future", "the first enabled startTime must be in the future")
|
|
||||||
}
|
|
||||||
if err := lockActivityScopeTx(tx, locked.SysOrigin, lockedNow); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := ensureNoEnabledOverlap(tx, activityID, locked.SysOrigin, locked.StartTime, locked.EndTime); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := replacePrizesTx(tx, activityID, prizes, lockedNow); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := replaceRankRewardsTx(tx, activityID, ranks, lockedNow); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tx.Model(&model.YumiGameKingActivity{}).Where("id = ?", activityID).
|
|
||||||
Updates(map[string]any{"enabled": enabled, "update_time": lockedNow}).Error
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) ListPrizes(ctx context.Context, activityID int64) ([]PrizeView, error) {
|
|
||||||
if _, err := s.loadActivity(ctx, activityID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
rows, err := s.loadPrizes(ctx, activityID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result := make([]PrizeView, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
result = append(result, prizeView(row))
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) SavePrizes(ctx context.Context, activityID int64, req SavePrizesRequest) error {
|
|
||||||
if req.ActivityID.Int64() != activityID {
|
|
||||||
return NewAppError(http.StatusBadRequest, "activity_id_mismatch", "body activityId must match path id")
|
|
||||||
}
|
|
||||||
activity, err := s.loadActivity(ctx, activityID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if activity.Enabled && !time.Now().Before(activity.StartTime) {
|
|
||||||
return NewAppError(http.StatusConflict, "activity_config_locked", "prizes cannot change after activity start")
|
|
||||||
}
|
|
||||||
values, err := s.normalizePrizes(ctx, activity.SysOrigin, req.Prizes)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
var locked model.YumiGameKingActivity
|
|
||||||
if err := withWriteLock(tx).Where("id = ?", activityID).First(&locked).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
lockedNow := time.Now()
|
|
||||||
if locked.Enabled && !lockedNow.Before(locked.StartTime) {
|
|
||||||
return NewAppError(http.StatusConflict, "activity_config_locked", "prizes cannot change after activity start")
|
|
||||||
}
|
|
||||||
return replacePrizesTx(tx, activityID, values, lockedNow)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) ListRankRewards(ctx context.Context, activityID int64) ([]RankRewardView, error) {
|
|
||||||
if _, err := s.loadActivity(ctx, activityID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
rows, err := s.loadRankRewards(ctx, activityID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result := make([]RankRewardView, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
result = append(result, rankRewardView(row))
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) SaveRankRewards(ctx context.Context, activityID int64, req SaveRankRewardsRequest) error {
|
|
||||||
if req.ActivityID.Int64() != activityID {
|
|
||||||
return NewAppError(http.StatusBadRequest, "activity_id_mismatch", "body activityId must match path id")
|
|
||||||
}
|
|
||||||
activity, err := s.loadActivity(ctx, activityID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if activity.Enabled && !time.Now().Before(activity.StartTime) {
|
|
||||||
return NewAppError(http.StatusConflict, "activity_config_locked", "rank rewards cannot change after activity start")
|
|
||||||
}
|
|
||||||
values, err := s.normalizeRankRewards(ctx, activity.SysOrigin, req.RankRewards)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
var locked model.YumiGameKingActivity
|
|
||||||
if err := withWriteLock(tx).Where("id = ?", activityID).First(&locked).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
lockedNow := time.Now()
|
|
||||||
if locked.Enabled && !lockedNow.Before(locked.StartTime) {
|
|
||||||
return NewAppError(http.StatusConflict, "activity_config_locked", "rank rewards cannot change after activity start")
|
|
||||||
}
|
|
||||||
return replaceRankRewardsTx(tx, activityID, values, lockedNow)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) loadActivity(ctx context.Context, activityID int64) (model.YumiGameKingActivity, error) {
|
|
||||||
if activityID <= 0 {
|
|
||||||
return model.YumiGameKingActivity{}, NewAppError(http.StatusBadRequest, "invalid_activity_id", "activityId is required")
|
|
||||||
}
|
|
||||||
var row model.YumiGameKingActivity
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", activityID).First(&row).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return row, NewAppError(http.StatusNotFound, "activity_not_found", "game king activity was not found")
|
|
||||||
}
|
|
||||||
return row, err
|
|
||||||
}
|
|
||||||
return row, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) resolveAppActivity(ctx context.Context, sysOrigin string, activityID int64, now time.Time) (model.YumiGameKingActivity, error) {
|
|
||||||
var row model.YumiGameKingActivity
|
|
||||||
base := s.db.WithContext(ctx).Where("sys_origin = ? AND enabled = ?", sysOrigin, true)
|
|
||||||
if activityID > 0 {
|
|
||||||
if err := base.Where("id = ?", activityID).First(&row).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return row, NewAppError(http.StatusNotFound, "activity_not_found", "enabled game king activity was not found")
|
|
||||||
}
|
|
||||||
return row, err
|
|
||||||
}
|
|
||||||
return row, nil
|
|
||||||
}
|
|
||||||
for _, query := range []*gorm.DB{
|
|
||||||
base.Session(&gorm.Session{}).Where("start_time <= ? AND end_time > ?", now, now).Order("start_time DESC"),
|
|
||||||
base.Session(&gorm.Session{}).Where("start_time > ?", now).Order("start_time ASC"),
|
|
||||||
base.Session(&gorm.Session{}).Where("end_time <= ?", now).Order("end_time DESC"),
|
|
||||||
} {
|
|
||||||
if err := query.First(&row).Error; err == nil {
|
|
||||||
return row, nil
|
|
||||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return row, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return row, NewAppError(http.StatusNotFound, "activity_not_found", "enabled game king activity was not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) loadPrizes(ctx context.Context, activityID int64) ([]model.YumiGameKingPrize, error) {
|
|
||||||
var rows []model.YumiGameKingPrize
|
|
||||||
err := s.db.WithContext(ctx).Where("activity_id = ?", activityID).Order("sort_order ASC").Find(&rows).Error
|
|
||||||
return rows, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) loadRankRewards(ctx context.Context, activityID int64) ([]model.YumiGameKingRankReward, error) {
|
|
||||||
var rows []model.YumiGameKingRankReward
|
|
||||||
err := s.db.WithContext(ctx).Where("activity_id = ?", activityID).Order("start_rank ASC").Find(&rows).Error
|
|
||||||
return rows, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) currentPrizeInputs(ctx context.Context, activityID int64) ([]PrizeInput, error) {
|
|
||||||
rows, err := s.loadPrizes(ctx, activityID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
values := make([]PrizeInput, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
values = append(values, PrizeInput{ID: FlexibleInt64(row.ID), PrizeName: row.PrizeName, PrizeImage: row.PrizeImage, Weight: row.Weight, Stock: row.Stock, ResourceGroupID: FlexibleInt64(row.ResourceGroupID), Enabled: row.Enabled, SortOrder: row.SortOrder})
|
|
||||||
}
|
|
||||||
return values, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) currentRankInputs(ctx context.Context, activityID int64) ([]RankRewardInput, error) {
|
|
||||||
rows, err := s.loadRankRewards(ctx, activityID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
values := make([]RankRewardInput, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
values = append(values, RankRewardInput{ID: FlexibleInt64(row.ID), StartRank: row.StartRank, EndRank: row.EndRank, ResourceGroupID: FlexibleInt64(row.ResourceGroupID), RewardName: row.RewardName})
|
|
||||||
}
|
|
||||||
return values, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func replacePrizesTx(tx *gorm.DB, activityID int64, values []normalizedPrize, now time.Time) error {
|
|
||||||
if err := tx.Where("activity_id = ?", activityID).Delete(&model.YumiGameKingPrize{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
rows := make([]model.YumiGameKingPrize, 0, len(values))
|
|
||||||
for _, value := range values {
|
|
||||||
id, err := utils.NextID()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
rows = append(rows, model.YumiGameKingPrize{
|
|
||||||
ID: id, ActivityID: activityID, PrizeName: value.Input.PrizeName, PrizeImage: value.Input.PrizeImage,
|
|
||||||
Weight: value.Input.Weight, Stock: value.Input.Stock, ResourceGroupID: value.Snapshot.GroupID,
|
|
||||||
RewardGroupName: value.Snapshot.GroupName, RewardItemsJSON: value.Snapshot.ItemsJSON,
|
|
||||||
Enabled: true, SortOrder: value.Input.SortOrder, CreateTime: now, UpdateTime: now,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return tx.Create(&rows).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func replaceRankRewardsTx(tx *gorm.DB, activityID int64, values []normalizedRankReward, now time.Time) error {
|
|
||||||
if err := tx.Where("activity_id = ?", activityID).Delete(&model.YumiGameKingRankReward{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
rows := make([]model.YumiGameKingRankReward, 0, len(values))
|
|
||||||
for _, value := range values {
|
|
||||||
id, err := utils.NextID()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
rows = append(rows, model.YumiGameKingRankReward{
|
|
||||||
ID: id, ActivityID: activityID, StartRank: value.Input.StartRank, EndRank: value.Input.EndRank,
|
|
||||||
ResourceGroupID: value.Snapshot.GroupID, RewardName: value.Input.RewardName,
|
|
||||||
RewardItemsJSON: value.Snapshot.ItemsJSON, CreateTime: now, UpdateTime: now,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return tx.Create(&rows).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureNoEnabledOverlap(tx *gorm.DB, activityID int64, sysOrigin string, start, end time.Time) error {
|
|
||||||
var count int64
|
|
||||||
if err := tx.Model(&model.YumiGameKingActivity{}).
|
|
||||||
Where("sys_origin = ? AND enabled = ? AND id <> ? AND start_time < ? AND end_time > ?", sysOrigin, true, activityID, end, start).
|
|
||||||
Count(&count).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if count > 0 {
|
|
||||||
return NewAppError(http.StatusConflict, "activity_time_overlap", "enabled activities for the same sysOrigin cannot overlap")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func lockActivityScopeTx(tx *gorm.DB, sysOrigin string, now time.Time) error {
|
|
||||||
row := model.YumiGameKingScopeLock{SysOrigin: sysOrigin, UpdateTime: now}
|
|
||||||
if err := tx.Clauses(clause.OnConflict{
|
|
||||||
Columns: []clause.Column{{Name: "sys_origin"}},
|
|
||||||
DoNothing: true,
|
|
||||||
}).Create(&row).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// 插入竞争失败后,显式 FOR UPDATE 等待持锁事务提交;锁一直持有到本次 overlap
|
|
||||||
// 检查和 activity 保存一起提交,两个不同 activity 行也无法并发穿透。
|
|
||||||
return withWriteLock(tx).Where("sys_origin = ?", sysOrigin).First(&row).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateStartedActivityUpdate(row model.YumiGameKingActivity, req SaveActivityRequest) error {
|
|
||||||
if row.ActivityCode != req.ActivityCode || row.SysOrigin != req.SysOrigin || row.Timezone != req.Timezone ||
|
|
||||||
row.StartTime.UnixMilli() != req.StartTime || row.EndTime.UnixMilli() != req.EndTime ||
|
|
||||||
row.SettlementDelayMinutes != *req.SettlementDelayMinutes || row.CoinPerDraw != req.CoinPerDraw ||
|
|
||||||
row.RankingType != req.RankingType || row.RankingPeriod != req.RankingPeriod || row.Enabled != req.Enabled {
|
|
||||||
return NewAppError(http.StatusConflict, "activity_config_locked", "activity scope, time, chance rule and enable state cannot change after activity start")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -1,392 +0,0 @@
|
|||||||
package gameking
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/integration"
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
const deliveryProcessingLease = 10 * time.Minute
|
|
||||||
|
|
||||||
type frozenDeliveryContext struct {
|
|
||||||
SysOrigin string
|
|
||||||
BusinessNo string
|
|
||||||
RewardItemsJSON string
|
|
||||||
RewardItems []RewardItemView
|
|
||||||
}
|
|
||||||
|
|
||||||
// dispatchDeliveryItem 原子领取一个 PENDING/人工确认后的 FAILED 奖励组并调用 Java。
|
|
||||||
// 一旦 HTTP 调用被尝试,任何错误都进入 UNKNOWN;只有调用前校验失败才是可重试 FAILED。
|
|
||||||
func (s *Service) dispatchDeliveryItem(ctx context.Context, itemID int64, retry bool) error {
|
|
||||||
// 事务已提交后发奖不能继承 H5/Admin/cron 连接取消,否则会在真正调用 Java 前留下
|
|
||||||
// PROCESSING 并被误判 UNKNOWN。脱离上游取消但保留硬超时,避免无界后台工作。
|
|
||||||
dispatchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 20*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
ctx = dispatchCtx
|
|
||||||
var item model.YumiGameKingDeliveryItem
|
|
||||||
claimed := false
|
|
||||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := withWriteLock(tx).Where("id = ?", itemID).First(&item).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return NewAppError(http.StatusNotFound, "delivery_item_not_found", "delivery item was not found")
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
switch item.DeliveryStatus {
|
|
||||||
case DeliverySuccess:
|
|
||||||
return nil
|
|
||||||
case DeliveryUnknown:
|
|
||||||
return NewAppError(http.StatusConflict, "delivery_unknown_requires_resolution", "UNKNOWN delivery must be reconciled before retry")
|
|
||||||
case DeliveryProcessing:
|
|
||||||
return NewAppError(http.StatusConflict, "delivery_processing", "delivery is already processing")
|
|
||||||
case DeliveryPending:
|
|
||||||
if retry {
|
|
||||||
return NewAppError(http.StatusConflict, "delivery_not_retryable", "only FAILED delivery can be retried")
|
|
||||||
}
|
|
||||||
case DeliveryFailed:
|
|
||||||
if !retry {
|
|
||||||
return NewAppError(http.StatusConflict, "delivery_not_pending", "FAILED delivery requires an explicit retry")
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return NewAppError(http.StatusConflict, "delivery_status_invalid", "delivery status is invalid")
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
updates := map[string]any{"delivery_status": DeliveryProcessing, "failure_reason": "", "update_time": now}
|
|
||||||
if retry {
|
|
||||||
updates["retry_count"] = gorm.Expr("retry_count + 1")
|
|
||||||
item.RetryCount++
|
|
||||||
}
|
|
||||||
if err := tx.Model(&model.YumiGameKingDeliveryItem{}).Where("id = ?", item.ID).Updates(updates).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := updateDeliveryParentTx(tx, item, DeliveryProcessing, "", nil, retry); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
item.DeliveryStatus = DeliveryProcessing
|
|
||||||
item.UpdateTime = now
|
|
||||||
claimed = true
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !claimed {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
frozen, err := s.loadFrozenDeliveryContext(ctx, item)
|
|
||||||
if err != nil {
|
|
||||||
_ = s.finishDeliveryItem(ctx, item.ID, DeliveryFailed, err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
current, err := s.loadRewardGroupSnapshot(ctx, frozen.SysOrigin, item.ResourceGroupID)
|
|
||||||
if err != nil {
|
|
||||||
// 奖励组查询发生在真正发奖之前,因此网络失败、下架、跨租户或不存在都能明确
|
|
||||||
// 判定“未调用发奖”,记录 FAILED,待配置恢复后由运营显式 retry。
|
|
||||||
_ = s.finishDeliveryItem(ctx, item.ID, DeliveryFailed, err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if current.ItemsJSON != frozen.RewardItemsJSON {
|
|
||||||
err = NewAppError(http.StatusConflict, "reward_group_snapshot_changed", "reward group content changed after activity enable; restore it before retry")
|
|
||||||
_ = s.finishDeliveryItem(ctx, item.ID, DeliveryFailed, err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if s.gateway == nil {
|
|
||||||
err = NewAppError(http.StatusServiceUnavailable, "reward_gateway_unavailable", "reward gateway is unavailable")
|
|
||||||
_ = s.finishDeliveryItem(ctx, item.ID, DeliveryFailed, err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.gateway.SendActivityReward(ctx, integration.SendActivityRewardRequest{
|
|
||||||
TrackID: item.ID, Origin: rewardOriginGameKing, SysOrigin: frozen.SysOrigin,
|
|
||||||
SourceGroupID: item.ResourceGroupID, AcceptUserID: item.UserID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
// HTTP 已经发出后无法证明 Java 是否落账;包括 4xx/409 在内全部 UNKNOWN,
|
|
||||||
// 禁止自动或普通 retry,必须用 trackId/businessNo 核账后人工 resolve。
|
|
||||||
_ = s.finishDeliveryItem(ctx, item.ID, DeliveryUnknown, err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.finishDeliveryItem(ctx, item.ID, DeliverySuccess, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) finishDeliveryItem(ctx context.Context, itemID int64, status, reason string) error {
|
|
||||||
// Java 调用和前置检查共用 20 秒操作超时,但最终状态必须使用独立短租约落库;
|
|
||||||
// 否则操作刚好超时时,已取消的 ctx 会让 FAILED/UNKNOWN/SUCCESS 都留在 PROCESSING。
|
|
||||||
finalizeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
return s.db.WithContext(finalizeCtx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
var item model.YumiGameKingDeliveryItem
|
|
||||||
if err := withWriteLock(tx).Where("id = ?", itemID).First(&item).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if item.DeliveryStatus != DeliveryProcessing {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
var deliverTime *time.Time
|
|
||||||
if status == DeliverySuccess {
|
|
||||||
deliverTime = &now
|
|
||||||
}
|
|
||||||
if err := tx.Model(&model.YumiGameKingDeliveryItem{}).Where("id = ?", item.ID).Updates(map[string]any{
|
|
||||||
"delivery_status": status, "failure_reason": truncateFailure(reason), "deliver_time": deliverTime, "update_time": now,
|
|
||||||
}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := updateDeliveryParentTx(tx, item, status, reason, deliverTime, false); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if item.OwnerType == OwnerSettlement {
|
|
||||||
return refreshSettlementStatusTx(tx, item.ActivityID)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateDeliveryParentTx(tx *gorm.DB, item model.YumiGameKingDeliveryItem, status, reason string, deliverTime *time.Time, incrementRetry bool) error {
|
|
||||||
updates := map[string]any{
|
|
||||||
"delivery_status": status, "failure_reason": truncateFailure(reason), "deliver_time": deliverTime, "update_time": time.Now(),
|
|
||||||
}
|
|
||||||
if incrementRetry {
|
|
||||||
updates["retry_count"] = gorm.Expr("retry_count + 1")
|
|
||||||
}
|
|
||||||
switch item.OwnerType {
|
|
||||||
case OwnerDraw:
|
|
||||||
return tx.Model(&model.YumiGameKingDrawRecord{}).Where("id = ?", item.OwnerID).Updates(updates).Error
|
|
||||||
case OwnerSettlement:
|
|
||||||
return tx.Model(&model.YumiGameKingSettlementRecord{}).Where("id = ?", item.OwnerID).Updates(updates).Error
|
|
||||||
default:
|
|
||||||
return NewAppError(http.StatusConflict, "delivery_owner_invalid", "delivery owner type is invalid")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshSettlementStatusTx(tx *gorm.DB, activityID int64) error {
|
|
||||||
var total, succeeded, unfinished, failed int64
|
|
||||||
base := tx.Model(&model.YumiGameKingSettlementRecord{}).Where("activity_id = ?", activityID)
|
|
||||||
if err := base.Count(&total).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if total == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := tx.Model(&model.YumiGameKingSettlementRecord{}).Where("activity_id = ? AND delivery_status = ?", activityID, DeliverySuccess).Count(&succeeded).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Model(&model.YumiGameKingSettlementRecord{}).Where("activity_id = ? AND delivery_status IN ?", activityID, []string{DeliveryPending, DeliveryProcessing}).Count(&unfinished).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Model(&model.YumiGameKingSettlementRecord{}).Where("activity_id = ? AND delivery_status IN ?", activityID, []string{DeliveryFailed, DeliveryUnknown}).Count(&failed).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
status := SettlementProcessing
|
|
||||||
if succeeded == total {
|
|
||||||
status = SettlementCompleted
|
|
||||||
} else if failed > 0 {
|
|
||||||
status = SettlementPartialFailed
|
|
||||||
} else if unfinished == 0 {
|
|
||||||
status = SettlementPartialFailed
|
|
||||||
}
|
|
||||||
return tx.Model(&model.YumiGameKingActivity{}).Where("id = ?", activityID).
|
|
||||||
Updates(map[string]any{"settlement_status": status, "update_time": time.Now()}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) loadFrozenDeliveryContext(ctx context.Context, item model.YumiGameKingDeliveryItem) (frozenDeliveryContext, error) {
|
|
||||||
var activity model.YumiGameKingActivity
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", item.ActivityID).First(&activity).Error; err != nil {
|
|
||||||
return frozenDeliveryContext{}, err
|
|
||||||
}
|
|
||||||
result := frozenDeliveryContext{SysOrigin: activity.SysOrigin}
|
|
||||||
switch item.OwnerType {
|
|
||||||
case OwnerDraw:
|
|
||||||
var record model.YumiGameKingDrawRecord
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", item.OwnerID).First(&record).Error; err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
var prize model.YumiGameKingPrize
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ? AND activity_id = ?", record.PrizeID, item.ActivityID).First(&prize).Error; err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
if prize.ResourceGroupID != item.ResourceGroupID {
|
|
||||||
return result, NewAppError(http.StatusConflict, "delivery_snapshot_mismatch", "draw reward group does not match frozen prize")
|
|
||||||
}
|
|
||||||
result.BusinessNo = fmt.Sprintf("YUMI_GAME_KING_DRAW:%d", record.ID)
|
|
||||||
result.RewardItemsJSON = prize.RewardItemsJSON
|
|
||||||
result.RewardItems = rewardItems(prize.RewardItemsJSON)
|
|
||||||
case OwnerSettlement:
|
|
||||||
var record model.YumiGameKingSettlementRecord
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", item.OwnerID).First(&record).Error; err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
var rankReward model.YumiGameKingRankReward
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ? AND activity_id = ?", record.RankRewardID, item.ActivityID).First(&rankReward).Error; err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
if rankReward.ResourceGroupID != item.ResourceGroupID {
|
|
||||||
return result, NewAppError(http.StatusConflict, "delivery_snapshot_mismatch", "settlement reward group does not match frozen rank tier")
|
|
||||||
}
|
|
||||||
result.BusinessNo = record.BusinessNo
|
|
||||||
result.RewardItemsJSON = rankReward.RewardItemsJSON
|
|
||||||
result.RewardItems = rewardItems(rankReward.RewardItemsJSON)
|
|
||||||
default:
|
|
||||||
return result, NewAppError(http.StatusConflict, "delivery_owner_invalid", "delivery owner type is invalid")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(result.RewardItemsJSON) == "" || len(result.RewardItems) == 0 {
|
|
||||||
return result, NewAppError(http.StatusConflict, "delivery_snapshot_empty", "frozen reward snapshot is empty")
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) deliveryItems(ctx context.Context, ownerType string, ownerID int64) ([]DeliveryItemView, error) {
|
|
||||||
var rows []model.YumiGameKingDeliveryItem
|
|
||||||
if err := s.db.WithContext(ctx).Where("owner_type = ? AND owner_id = ?", ownerType, ownerID).Order("id ASC").Find(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result := make([]DeliveryItemView, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
frozen, err := s.loadFrozenDeliveryContext(ctx, row)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result = append(result, DeliveryItemView{
|
|
||||||
ID: row.ID, TrackID: row.ID, OwnerType: row.OwnerType, OwnerID: row.OwnerID,
|
|
||||||
BusinessNo: frozen.BusinessNo, UserID: row.UserID, ResourceGroupID: row.ResourceGroupID,
|
|
||||||
DeliveryStatus: row.DeliveryStatus, RetryCount: row.RetryCount, FailureReason: row.FailureReason,
|
|
||||||
DeliverTime: unixMilliPtr(row.DeliverTime), UpdateTime: unixMilli(row.UpdateTime), RewardItems: frozen.RewardItems,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) RetryDraw(ctx context.Context, recordID int64) error {
|
|
||||||
var record model.YumiGameKingDrawRecord
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", recordID).First(&record).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if record.DeliveryStatus != DeliveryFailed {
|
|
||||||
return NewAppError(http.StatusConflict, "draw_not_retryable", "only FAILED draw rewards can be retried")
|
|
||||||
}
|
|
||||||
var item model.YumiGameKingDeliveryItem
|
|
||||||
if err := s.db.WithContext(ctx).Where("owner_type = ? AND owner_id = ?", OwnerDraw, record.ID).First(&item).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.dispatchDeliveryItem(ctx, item.ID, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) RetrySettlement(ctx context.Context, recordID int64) error {
|
|
||||||
var record model.YumiGameKingSettlementRecord
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", recordID).First(&record).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if record.DeliveryStatus != DeliveryFailed {
|
|
||||||
return NewAppError(http.StatusConflict, "settlement_not_retryable", "only FAILED settlement rewards can be retried")
|
|
||||||
}
|
|
||||||
var item model.YumiGameKingDeliveryItem
|
|
||||||
if err := s.db.WithContext(ctx).Where("owner_type = ? AND owner_id = ?", OwnerSettlement, record.ID).First(&item).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.dispatchDeliveryItem(ctx, item.ID, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveUnknownDeliveryItem 只能由运营核账后调用。delivered=false 是明确未到账授权,
|
|
||||||
// 服务先把 UNKNOWN 原子转为 FAILED,再复用普通 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
|
|
||||||
}
|
|
||||||
@ -1,241 +0,0 @@
|
|||||||
package gameking
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
cryptorand "crypto/rand"
|
|
||||||
"errors"
|
|
||||||
"math"
|
|
||||||
"math/big"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
"chatapp3-golang/internal/utils"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Draw 消耗一次由游戏金币支出获得的机会,并在同一事务冻结奖项和奖励组。
|
|
||||||
func (s *Service) Draw(ctx context.Context, user AuthUser, req DrawRequest) (*DrawResult, error) {
|
|
||||||
if user.UserID <= 0 {
|
|
||||||
return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "authenticated user is required")
|
|
||||||
}
|
|
||||||
if req.ActivityID.Int64() <= 0 {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_activity_id", "activityId is required")
|
|
||||||
}
|
|
||||||
req.RequestID = strings.TrimSpace(req.RequestID)
|
|
||||||
if req.RequestID == "" || len(req.RequestID) > 64 {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_request_id", "requestId is required and must not exceed 64 characters")
|
|
||||||
}
|
|
||||||
// 网络超时后的同 requestId 重放必须先于 ACTIVE 校验;已提交的结果即使活动已经结束、
|
|
||||||
// 关闭或进入结算,也要原样返回,且绝不再次发奖。
|
|
||||||
if replay, err := s.findDrawReplay(ctx, user, req); err != nil || replay != nil {
|
|
||||||
return replay, err
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
activity, err := s.resolveAppActivity(ctx, normalizeSysOrigin(user.SysOrigin), req.ActivityID.Int64(), now)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if activityStatus(activity, now) != activityActive {
|
|
||||||
return nil, NewAppError(http.StatusConflict, "activity_not_active", "game king activity is not active")
|
|
||||||
}
|
|
||||||
|
|
||||||
var record model.YumiGameKingDrawRecord
|
|
||||||
var prize model.YumiGameKingPrize
|
|
||||||
var remaining int64
|
|
||||||
replayed := false
|
|
||||||
var deliveryItemID int64
|
|
||||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
var lockedActivity model.YumiGameKingActivity
|
|
||||||
// 与事件入账相同,抽奖使用共享活动锁;结算排他锁会等待已进入抽奖完成。
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where("id = ?", activity.ID).First(&lockedActivity).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
txNow := time.Now()
|
|
||||||
if !lockedActivity.Enabled || lockedActivity.SettlementStatus != SettlementNotStarted ||
|
|
||||||
txNow.Before(lockedActivity.StartTime) || !txNow.Before(lockedActivity.EndTime) {
|
|
||||||
return NewAppError(http.StatusConflict, "activity_not_active", "game king activity is not active")
|
|
||||||
}
|
|
||||||
|
|
||||||
var userRow model.YumiGameKingUser
|
|
||||||
if err := withWriteLock(tx).Where("activity_id = ? AND user_id = ?", activity.ID, user.UserID).First(&userRow).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return NewAppError(http.StatusConflict, "draw_chance_unavailable", "no draw chance is available")
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// 同一用户的请求都由聚合行串行化,因此并发复用 requestId 不会重复扣机会。
|
|
||||||
if err := tx.Where("activity_id = ? AND user_id = ? AND request_id = ?", activity.ID, user.UserID, req.RequestID).First(&record).Error; err == nil {
|
|
||||||
if err := tx.Where("id = ?", record.PrizeID).First(&prize).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
replayed = true
|
|
||||||
remaining = userRow.TotalConsumed/lockedActivity.CoinPerDraw - userRow.UsedChances
|
|
||||||
if remaining < 0 {
|
|
||||||
remaining = 0
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
earned := userRow.TotalConsumed / lockedActivity.CoinPerDraw
|
|
||||||
if userRow.UsedChances >= earned {
|
|
||||||
return NewAppError(http.StatusConflict, "draw_chance_unavailable", "no draw chance is available")
|
|
||||||
}
|
|
||||||
selected, err := selectAndReservePrizeTx(tx, activity.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
prize = selected
|
|
||||||
|
|
||||||
recordID, err := utils.NextID()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
deliveryItemID, err = utils.NextID()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
record = model.YumiGameKingDrawRecord{
|
|
||||||
ID: recordID, ActivityID: activity.ID, UserID: user.UserID, RequestID: req.RequestID,
|
|
||||||
PrizeID: prize.ID, PrizeName: prize.PrizeName, PrizeImage: prize.PrizeImage,
|
|
||||||
ResourceGroupID: prize.ResourceGroupID, DeliveryStatus: DeliveryPending,
|
|
||||||
DrawTime: txNow, CreateTime: txNow, UpdateTime: txNow,
|
|
||||||
}
|
|
||||||
if err := tx.Create(&record).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Create(&model.YumiGameKingDeliveryItem{
|
|
||||||
ID: deliveryItemID, OwnerType: OwnerDraw, OwnerID: record.ID, ActivityID: activity.ID,
|
|
||||||
UserID: user.UserID, ResourceGroupID: prize.ResourceGroupID, DeliveryStatus: DeliveryPending,
|
|
||||||
CreateTime: txNow, UpdateTime: txNow,
|
|
||||||
}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
userRow.UsedChances++
|
|
||||||
userRow.UpdateTime = txNow
|
|
||||||
if err := tx.Save(&userRow).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
remaining = earned - userRow.UsedChances
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !replayed {
|
|
||||||
// 发奖在提交后执行,外部超时不会回滚已经向 H5 展示的抽奖结果,也不会再次扣机会。
|
|
||||||
_ = s.dispatchDeliveryItem(ctx, deliveryItemID, false)
|
|
||||||
_ = s.db.WithContext(ctx).Where("id = ?", record.ID).First(&record).Error
|
|
||||||
}
|
|
||||||
return &DrawResult{
|
|
||||||
Record: drawRecordView(record), Prize: prizeView(prize), RemainingChances: remaining, IdempotentReplay: replayed,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) findDrawReplay(ctx context.Context, user AuthUser, req DrawRequest) (*DrawResult, error) {
|
|
||||||
var record model.YumiGameKingDrawRecord
|
|
||||||
query := s.db.WithContext(ctx).Table("yumi_game_king_draw_record AS draw").
|
|
||||||
Select("draw.*").
|
|
||||||
Joins("JOIN yumi_game_king_activity AS activity ON activity.id = draw.activity_id").
|
|
||||||
Where("draw.user_id = ? AND draw.request_id = ? AND activity.sys_origin = ?", user.UserID, req.RequestID, normalizeSysOrigin(user.SysOrigin))
|
|
||||||
if req.ActivityID.Int64() > 0 {
|
|
||||||
query = query.Where("draw.activity_id = ?", req.ActivityID.Int64())
|
|
||||||
}
|
|
||||||
result := query.Order("draw.id DESC").Limit(1).Scan(&record)
|
|
||||||
if result.Error != nil {
|
|
||||||
return nil, result.Error
|
|
||||||
}
|
|
||||||
if result.RowsAffected == 0 || record.ID <= 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
var prize model.YumiGameKingPrize
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ? AND activity_id = ?", record.PrizeID, record.ActivityID).First(&prize).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var activity model.YumiGameKingActivity
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", record.ActivityID).First(&activity).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var aggregate model.YumiGameKingUser
|
|
||||||
if err := s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", record.ActivityID, user.UserID).First(&aggregate).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
remaining := aggregate.TotalConsumed/activity.CoinPerDraw - aggregate.UsedChances
|
|
||||||
if remaining < 0 {
|
|
||||||
remaining = 0
|
|
||||||
}
|
|
||||||
return &DrawResult{Record: drawRecordView(record), Prize: prizeView(prize), RemainingChances: remaining, IdempotentReplay: true}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectAndReservePrizeTx(tx *gorm.DB, activityID int64) (model.YumiGameKingPrize, error) {
|
|
||||||
for attempt := 0; attempt < 20; attempt++ {
|
|
||||||
var rows []model.YumiGameKingPrize
|
|
||||||
if err := tx.Where("activity_id = ? AND enabled = ? AND weight > 0 AND stock <> 0", activityID, true).
|
|
||||||
Order("sort_order ASC").Find(&rows).Error; err != nil {
|
|
||||||
return model.YumiGameKingPrize{}, err
|
|
||||||
}
|
|
||||||
if len(rows) != 7 {
|
|
||||||
// 配置固定七个,但有限库存耗尽后允许从剩余奖项继续抽;只有配置本身缺失才报错。
|
|
||||||
var configured int64
|
|
||||||
if err := tx.Model(&model.YumiGameKingPrize{}).Where("activity_id = ? AND enabled = ? AND weight > 0", activityID, true).Count(&configured).Error; err != nil {
|
|
||||||
return model.YumiGameKingPrize{}, err
|
|
||||||
}
|
|
||||||
if configured != 7 {
|
|
||||||
return model.YumiGameKingPrize{}, NewAppError(http.StatusConflict, "prize_config_invalid", "seven enabled positive-weight prizes are required")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(rows) == 0 {
|
|
||||||
return model.YumiGameKingPrize{}, NewAppError(http.StatusConflict, "prize_stock_empty", "all prize stock is exhausted")
|
|
||||||
}
|
|
||||||
selected, err := weightedPrize(rows)
|
|
||||||
if err != nil {
|
|
||||||
return model.YumiGameKingPrize{}, err
|
|
||||||
}
|
|
||||||
if selected.Stock < 0 {
|
|
||||||
return selected, nil
|
|
||||||
}
|
|
||||||
result := tx.Model(&model.YumiGameKingPrize{}).
|
|
||||||
Where("id = ? AND stock > 0", selected.ID).
|
|
||||||
Updates(map[string]any{"stock": gorm.Expr("stock - 1"), "update_time": time.Now()})
|
|
||||||
if result.Error != nil {
|
|
||||||
return model.YumiGameKingPrize{}, result.Error
|
|
||||||
}
|
|
||||||
if result.RowsAffected == 1 {
|
|
||||||
selected.Stock--
|
|
||||||
return selected, nil
|
|
||||||
}
|
|
||||||
// 另一个抽奖刚好抢完有限库存,重新读取候选并按剩余权重抽取。
|
|
||||||
}
|
|
||||||
return model.YumiGameKingPrize{}, NewAppError(http.StatusConflict, "prize_stock_busy", "prize stock changed concurrently; please retry")
|
|
||||||
}
|
|
||||||
|
|
||||||
func weightedPrize(rows []model.YumiGameKingPrize) (model.YumiGameKingPrize, error) {
|
|
||||||
var total int64
|
|
||||||
for _, row := range rows {
|
|
||||||
if row.Weight <= 0 || total > math.MaxInt64-row.Weight {
|
|
||||||
return model.YumiGameKingPrize{}, NewAppError(http.StatusConflict, "prize_weight_invalid", "prize weights are invalid")
|
|
||||||
}
|
|
||||||
total += row.Weight
|
|
||||||
}
|
|
||||||
if total <= 0 {
|
|
||||||
return model.YumiGameKingPrize{}, NewAppError(http.StatusConflict, "prize_weight_invalid", "prize weights are invalid")
|
|
||||||
}
|
|
||||||
randomValue, err := cryptorand.Int(cryptorand.Reader, big.NewInt(total))
|
|
||||||
if err != nil {
|
|
||||||
return model.YumiGameKingPrize{}, err
|
|
||||||
}
|
|
||||||
needle := randomValue.Int64()
|
|
||||||
var cursor int64
|
|
||||||
for _, row := range rows {
|
|
||||||
cursor += row.Weight
|
|
||||||
if needle < cursor {
|
|
||||||
return row, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows[len(rows)-1], nil
|
|
||||||
}
|
|
||||||
@ -1,260 +0,0 @@
|
|||||||
package gameking
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
"chatapp3-golang/internal/service/taskcenter"
|
|
||||||
"chatapp3-golang/internal/utils"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ConsumeGameEvent 实现 taskcenter.GameConsumeSink。GAME_CONSUME_GOLD 是唯一统计入口,
|
|
||||||
// 不再允许后台配置钱包 eventType 或把非游戏支出混入活动。
|
|
||||||
func (s *Service) ConsumeGameEvent(ctx context.Context, event taskcenter.ValidatedEvent) error {
|
|
||||||
if !strings.EqualFold(event.EventType, taskcenter.EventTypeGameConsumeGold) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if event.UserID <= 0 || event.DeltaValue <= 0 || strings.TrimSpace(event.EventID) == "" {
|
|
||||||
return NewAppError(http.StatusBadRequest, "invalid_game_consume_event", "game consume event requires eventId, userId and positive deltaValue")
|
|
||||||
}
|
|
||||||
event.SysOrigin = normalizeSysOrigin(event.SysOrigin)
|
|
||||||
event.EventID = strings.TrimSpace(event.EventID)
|
|
||||||
|
|
||||||
var activities []model.YumiGameKingActivity
|
|
||||||
if err := s.db.WithContext(ctx).
|
|
||||||
Where("sys_origin = ? AND enabled = ? AND settlement_status = ? AND start_time <= ? AND end_time > ?",
|
|
||||||
event.SysOrigin, true, SettlementNotStarted, event.OccurredAt, event.OccurredAt).
|
|
||||||
Order("start_time ASC").
|
|
||||||
Find(&activities).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, activity := range activities {
|
|
||||||
if _, err := s.applyEventToActivity(ctx, activity.ID, event); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyEventToActivity 在一个事务内完成账本幂等、总榜和活动时区日榜聚合。
|
|
||||||
func (s *Service) applyEventToActivity(ctx context.Context, activityID int64, event taskcenter.ValidatedEvent) (bool, error) {
|
|
||||||
accepted := false
|
|
||||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
var activity model.YumiGameKingActivity
|
|
||||||
// 事件仅持有共享活动锁:不同用户可并发入账;结算使用排他锁并等待所有已进入的
|
|
||||||
// 事件事务完成,从而在切换 settlement_status 时形成明确冻结边界。
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where("id = ?", activityID).First(&activity).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// 结算事务先锁活动并切换状态;此处复核状态和 [start,end),因此结算冻结后到达的
|
|
||||||
// 延迟 MQ/补数不会让 Top30 继续漂移。
|
|
||||||
if !activity.Enabled || activity.SettlementStatus != SettlementNotStarted ||
|
|
||||||
event.SysOrigin != activity.SysOrigin || event.OccurredAt.Before(activity.StartTime) || !event.OccurredAt.Before(activity.EndTime) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
ledgerID, err := utils.NextID()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
payloadJSON, _ := json.Marshal(event.Payload)
|
|
||||||
ledger := model.YumiGameKingConsumeLedger{
|
|
||||||
ID: ledgerID, ActivityID: activity.ID, EventID: event.EventID, UserID: event.UserID,
|
|
||||||
SysOrigin: event.SysOrigin, Amount: event.DeltaValue, EventTime: event.OccurredAt,
|
|
||||||
PayloadJSON: string(payloadJSON), CreateTime: time.Now(),
|
|
||||||
}
|
|
||||||
result := tx.Clauses(clause.OnConflict{
|
|
||||||
Columns: []clause.Column{{Name: "activity_id"}, {Name: "event_id"}},
|
|
||||||
DoNothing: true,
|
|
||||||
}).Create(&ledger)
|
|
||||||
if result.Error != nil {
|
|
||||||
return result.Error
|
|
||||||
}
|
|
||||||
if result.RowsAffected == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
if err := addOverallConsumeTx(tx, activity.ID, event.UserID, event.DeltaValue, event.OccurredAt, now); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, location, err := normalizeTimezone(activity.Timezone)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := addDailyConsumeTx(tx, activity.ID, dateKey(event.OccurredAt, location), event.UserID, event.DeltaValue, event.OccurredAt, now); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
accepted = true
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return accepted, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func addOverallConsumeTx(tx *gorm.DB, activityID, userID, amount int64, eventTime, now time.Time) error {
|
|
||||||
row := model.YumiGameKingUser{
|
|
||||||
ActivityID: activityID, UserID: userID, TotalConsumed: amount, UsedChances: 0,
|
|
||||||
ConsumeReachedTime: eventTime, CreateTime: now, UpdateTime: now,
|
|
||||||
}
|
|
||||||
// 首笔事件不能“SELECT 未命中后 INSERT”:同一用户并发首笔会发生 PK 冲突或死锁,
|
|
||||||
// 同步上报若不重试就会漏计。单条 upsert 由聚合主键串行累加,并且不覆盖 used_chances。
|
|
||||||
return tx.Clauses(clause.OnConflict{
|
|
||||||
Columns: []clause.Column{{Name: "activity_id"}, {Name: "user_id"}},
|
|
||||||
DoUpdates: clause.Assignments(map[string]any{
|
|
||||||
"total_consumed": gorm.Expr("total_consumed + ?", amount),
|
|
||||||
"consume_reached_time": gorm.Expr("GREATEST(consume_reached_time, ?)", eventTime),
|
|
||||||
"update_time": now,
|
|
||||||
}),
|
|
||||||
}).Create(&row).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func addDailyConsumeTx(tx *gorm.DB, activityID int64, statDate time.Time, userID, amount int64, eventTime, now time.Time) error {
|
|
||||||
row := model.YumiGameKingUserDaily{
|
|
||||||
ActivityID: activityID, StatDate: statDate, UserID: userID, TotalConsumed: amount,
|
|
||||||
ConsumeReachedTime: eventTime, CreateTime: now, UpdateTime: now,
|
|
||||||
}
|
|
||||||
return tx.Clauses(clause.OnConflict{
|
|
||||||
Columns: []clause.Column{{Name: "activity_id"}, {Name: "stat_date"}, {Name: "user_id"}},
|
|
||||||
DoUpdates: clause.Assignments(map[string]any{
|
|
||||||
"total_consumed": gorm.Expr("total_consumed + ?", amount),
|
|
||||||
"consume_reached_time": gorm.Expr("GREATEST(consume_reached_time, ?)", eventTime),
|
|
||||||
"update_time": now,
|
|
||||||
}),
|
|
||||||
}).Create(&row).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
type archivedTaskEvent struct {
|
|
||||||
DeltaValue int64 `json:"delta_value"`
|
|
||||||
Payload map[string]any `json:"payload"`
|
|
||||||
}
|
|
||||||
|
|
||||||
const taskCenterGameKingBackfillIndex = "idx_task_center_game_king_backfill"
|
|
||||||
|
|
||||||
type backfillIndexColumn struct {
|
|
||||||
ColumnName string `gorm:"column:column_name"`
|
|
||||||
SeqInIndex int `gorm:"column:seq_in_index"`
|
|
||||||
SubPart *int64 `gorm:"column:sub_part"`
|
|
||||||
IndexType string `gorm:"column:index_type"`
|
|
||||||
IsVisible string `gorm:"column:is_visible"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backfill 从任务中心的权威事件归档表按主键游标补数。调用方只控制 lastId/limit,
|
|
||||||
// 不能提交 eventType 或钱包口径;查询始终绑定复合索引并限制到活动 [start,end)。
|
|
||||||
func (s *Service) Backfill(ctx context.Context, activityID, lastID int64, limit int) (*BackfillResult, error) {
|
|
||||||
// 生产归档表约 35M 行;索引若未独立完成,后续游标定位或范围扫描可能退化成全表扫描。
|
|
||||||
// 因此元数据门禁必须位于任何活动/游标查询之前,并且每次调用重新检查,在线 DDL 完成后
|
|
||||||
// 无需重启即可恢复补数。information_schema 查询只读取固定索引的少量元数据行。
|
|
||||||
if err := s.requireBackfillIndex(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
activity, err := s.loadActivity(ctx, activityID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// 游标只有在仍可入账时才允许推进;否则 settled/disabled 活动会扫描到底却全部
|
|
||||||
// accepted=false,给运营造成“补数成功”的假象。结束后等待结算期间仍允许补数。
|
|
||||||
if !activity.Enabled {
|
|
||||||
return nil, NewAppError(http.StatusConflict, "activity_disabled", "disabled activity cannot be backfilled")
|
|
||||||
}
|
|
||||||
if activity.SettlementStatus != SettlementNotStarted {
|
|
||||||
return nil, NewAppError(http.StatusConflict, "settlement_frozen", "settlement has frozen activity consumption")
|
|
||||||
}
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = defaultBackfillLimit
|
|
||||||
}
|
|
||||||
if limit > maxBackfillLimit {
|
|
||||||
limit = maxBackfillLimit
|
|
||||||
}
|
|
||||||
if lastID < 0 {
|
|
||||||
lastID = 0
|
|
||||||
}
|
|
||||||
state, err := s.loadOrCreateBackfillState(ctx, activity)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
cursorTime := activity.StartTime
|
|
||||||
if state.LastOccurredAt != nil {
|
|
||||||
cursorTime = *state.LastOccurredAt
|
|
||||||
}
|
|
||||||
if lastID > 0 && state.LastOutboxID == 0 {
|
|
||||||
var cursor model.TaskCenterEventArchiveOutbox
|
|
||||||
if err := s.db.WithContext(ctx).
|
|
||||||
Where("id = ? AND sys_origin = ? AND event_type = ? AND occurred_at >= ? AND occurred_at < ?",
|
|
||||||
lastID, activity.SysOrigin, taskcenter.EventTypeGameConsumeGold, activity.StartTime, activity.EndTime).
|
|
||||||
First(&cursor).Error; err != nil {
|
|
||||||
if isRecordNotFound(err) {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_backfill_cursor", "lastId does not belong to this activity event window")
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
state, err = s.adoptBackfillCursor(ctx, activity.ID, cursor)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
cursorTime = cursor.OccurredAt
|
|
||||||
} else if lastID > 0 && lastID != state.LastOutboxID {
|
|
||||||
return nil, NewAppError(http.StatusConflict, "backfill_cursor_conflict", "lastId does not match the persisted backfill cursor")
|
|
||||||
}
|
|
||||||
lastID = state.LastOutboxID
|
|
||||||
startingLastID := lastID
|
|
||||||
var rows []model.TaskCenterEventArchiveOutbox
|
|
||||||
if err := s.db.WithContext(ctx).
|
|
||||||
Where("sys_origin = ? AND event_type = ? AND occurred_at >= ? AND occurred_at < ? AND (occurred_at > ? OR (occurred_at = ? AND id > ?))",
|
|
||||||
activity.SysOrigin, taskcenter.EventTypeGameConsumeGold, activity.StartTime, activity.EndTime, cursorTime, cursorTime, lastID).
|
|
||||||
Order("occurred_at ASC, id ASC").Limit(limit + 1).Find(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
finished := len(rows) <= limit
|
|
||||||
if len(rows) > limit {
|
|
||||||
rows = rows[:limit]
|
|
||||||
}
|
|
||||||
result := &BackfillResult{EventType: taskcenter.EventTypeGameConsumeGold, Finished: finished, NextLastID: lastID}
|
|
||||||
for _, row := range rows {
|
|
||||||
var archived archivedTaskEvent
|
|
||||||
if err := json.Unmarshal([]byte(row.RawJSON), &archived); err != nil || archived.DeltaValue <= 0 {
|
|
||||||
return nil, NewAppError(http.StatusUnprocessableEntity, "invalid_archived_event", fmt.Sprintf("task center archive row %d has no valid delta_value", row.ID))
|
|
||||||
}
|
|
||||||
accepted, err := s.applyEventToActivity(ctx, activity.ID, taskcenter.ValidatedEvent{
|
|
||||||
SysOrigin: activity.SysOrigin, EventID: row.EventID, EventType: taskcenter.EventTypeGameConsumeGold,
|
|
||||||
UserID: row.UserID, DeltaValue: archived.DeltaValue, OccurredAt: row.OccurredAt, Payload: archived.Payload,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !accepted {
|
|
||||||
var existing int64
|
|
||||||
if err := s.db.WithContext(ctx).Model(&model.YumiGameKingConsumeLedger{}).
|
|
||||||
Where("activity_id = ? AND event_id = ?", activity.ID, row.EventID).Count(&existing).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if existing == 0 {
|
|
||||||
// 入口校验后结算可能在本页中途取得排他锁。未命中 ledger 说明这条不是幂等
|
|
||||||
// 重复而是已被冻结拒绝;整页返回冲突且不暴露推进后的 cursor,调用方沿旧游标重试。
|
|
||||||
return nil, NewAppError(http.StatusConflict, "backfill_window_closed", "activity stopped accepting consumption during backfill")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.Scanned++
|
|
||||||
if accepted {
|
|
||||||
result.Accepted++
|
|
||||||
}
|
|
||||||
result.NextLastID = row.ID
|
|
||||||
cursorTime = row.OccurredAt
|
|
||||||
}
|
|
||||||
if err := s.persistBackfillState(ctx, activity.ID, startingLastID, result.NextLastID, cursorTime, result.Scanned, result.Accepted, result.Finished); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireBackfillIndex 只接受迁移 058 创建的完整 BTREE 列序;VARCHAR 前缀索引也会拒绝,
|
|
||||||
// 因为它不能提供与完整等值前缀相同的执行计划保证。缺表、缺索引或列序漂移统一 fail-closed。
|
|
||||||
func (s *Service) requireBackfillIndex(ctx context.Context) error {
|
|
||||||
return requireBackfillIndexOnDB(s.db.WithContext(ctx))
|
|
||||||
}
|
|
||||||
@ -1,351 +0,0 @@
|
|||||||
package gameking
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"net/http"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/integration"
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
|
||||||
|
|
||||||
var fixedRankRanges = [][2]int{{1, 1}, {2, 2}, {3, 3}, {4, 7}, {8, 10}, {11, 30}}
|
|
||||||
|
|
||||||
func normalizeSysOrigin(value string) string {
|
|
||||||
value = strings.ToUpper(strings.TrimSpace(value))
|
|
||||||
if value == "" {
|
|
||||||
return "LIKEI"
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeTimezone(value string) (string, *time.Location, error) {
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
if value == "" {
|
|
||||||
value = defaultTimezone
|
|
||||||
}
|
|
||||||
location, err := time.LoadLocation(value)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, NewAppError(http.StatusBadRequest, "invalid_timezone", "timeZone must be a valid IANA timezone")
|
|
||||||
}
|
|
||||||
return value, location, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeRankingType(value string) (string, error) {
|
|
||||||
value = strings.ToUpper(strings.TrimSpace(value))
|
|
||||||
if value == "" {
|
|
||||||
value = rankingTypeTycoon
|
|
||||||
}
|
|
||||||
if value != rankingTypeTycoon {
|
|
||||||
return "", NewAppError(http.StatusBadRequest, "invalid_ranking_type", "rankingType must be TYCOON")
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeRankingPeriod(value string) (string, error) {
|
|
||||||
value = strings.ToUpper(strings.TrimSpace(value))
|
|
||||||
if value == "" {
|
|
||||||
value = rankingPeriodOverall
|
|
||||||
}
|
|
||||||
if value != rankingPeriodOverall {
|
|
||||||
return "", NewAppError(http.StatusBadRequest, "invalid_ranking_period", "configured rankingPeriod must be OVERALL")
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeActivityInput(req SaveActivityRequest) (SaveActivityRequest, error) {
|
|
||||||
req.ActivityCode = strings.TrimSpace(req.ActivityCode)
|
|
||||||
req.ActivityName = strings.TrimSpace(req.ActivityName)
|
|
||||||
req.ActivityDesc = strings.TrimSpace(req.ActivityDesc)
|
|
||||||
if req.ActivityCode == "" || len(req.ActivityCode) > 64 {
|
|
||||||
return req, NewAppError(http.StatusBadRequest, "invalid_activity_code", "activityCode is required and must not exceed 64 characters")
|
|
||||||
}
|
|
||||||
if req.ActivityName == "" || len(req.ActivityName) > 128 {
|
|
||||||
return req, NewAppError(http.StatusBadRequest, "invalid_activity_name", "activityName is required and must not exceed 128 characters")
|
|
||||||
}
|
|
||||||
if len(req.ActivityDesc) > 1000 {
|
|
||||||
return req, NewAppError(http.StatusBadRequest, "invalid_activity_desc", "activityDesc must not exceed 1000 characters")
|
|
||||||
}
|
|
||||||
req.SysOrigin = normalizeSysOrigin(req.SysOrigin)
|
|
||||||
if req.SysOrigin != "LIKEI" {
|
|
||||||
return req, NewAppError(http.StatusBadRequest, "invalid_sys_origin", "Yumi game king only supports LIKEI sysOrigin")
|
|
||||||
}
|
|
||||||
timezone, _, err := normalizeTimezone(req.Timezone)
|
|
||||||
if err != nil {
|
|
||||||
return req, err
|
|
||||||
}
|
|
||||||
req.Timezone = timezone
|
|
||||||
if req.StartTime <= 0 || req.EndTime <= req.StartTime {
|
|
||||||
return req, NewAppError(http.StatusBadRequest, "invalid_activity_time", "startTime and endTime are required and startTime must be before endTime")
|
|
||||||
}
|
|
||||||
if req.SettlementDelayMinutes == nil {
|
|
||||||
value := defaultSettlementDelayMinutes
|
|
||||||
req.SettlementDelayMinutes = &value
|
|
||||||
}
|
|
||||||
if *req.SettlementDelayMinutes < 0 || *req.SettlementDelayMinutes > maxSettlementDelayMinutes {
|
|
||||||
return req, NewAppError(http.StatusBadRequest, "invalid_settlement_delay", "settlementDelayMinutes must be between 0 and 1440")
|
|
||||||
}
|
|
||||||
if req.CoinPerDraw <= 0 {
|
|
||||||
return req, NewAppError(http.StatusBadRequest, "invalid_coin_per_draw", "coinPerDraw must be greater than zero")
|
|
||||||
}
|
|
||||||
req.RankingType, err = normalizeRankingType(req.RankingType)
|
|
||||||
if err != nil {
|
|
||||||
return req, err
|
|
||||||
}
|
|
||||||
req.RankingPeriod, err = normalizeRankingPeriod(req.RankingPeriod)
|
|
||||||
return req, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) normalizePrizes(ctx context.Context, sysOrigin string, values []PrizeInput) ([]normalizedPrize, error) {
|
|
||||||
if len(values) != 7 {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_count", "exactly seven prizes are required")
|
|
||||||
}
|
|
||||||
seenSort := make(map[int]struct{}, 7)
|
|
||||||
result := make([]normalizedPrize, 0, 7)
|
|
||||||
for _, value := range values {
|
|
||||||
value.PrizeName = strings.TrimSpace(value.PrizeName)
|
|
||||||
value.PrizeImage = strings.TrimSpace(value.PrizeImage)
|
|
||||||
if value.PrizeName == "" || len(value.PrizeName) > 128 {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_name", "every prizeName is required and must not exceed 128 characters")
|
|
||||||
}
|
|
||||||
if value.PrizeImage == "" || len(value.PrizeImage) > 500 {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_image", "every prizeImage is required and must not exceed 500 characters")
|
|
||||||
}
|
|
||||||
if !value.Enabled {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_enabled", "all seven prizes must be enabled")
|
|
||||||
}
|
|
||||||
if value.Weight <= 0 {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_weight", "every prize weight must be greater than zero")
|
|
||||||
}
|
|
||||||
if value.Stock < -1 {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_stock", "prize stock must be -1 or greater")
|
|
||||||
}
|
|
||||||
if value.SortOrder < 1 || value.SortOrder > 7 {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_sort", "prize sortOrder must cover 1 through 7")
|
|
||||||
}
|
|
||||||
if _, exists := seenSort[value.SortOrder]; exists {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "duplicate_prize_sort", "prize sortOrder must be unique")
|
|
||||||
}
|
|
||||||
seenSort[value.SortOrder] = struct{}{}
|
|
||||||
if value.ResourceGroupID.Int64() <= 0 {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_reward_group", "every prize must reference a reward group")
|
|
||||||
}
|
|
||||||
snapshot, err := s.loadRewardGroupSnapshot(ctx, sysOrigin, value.ResourceGroupID.Int64())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result = append(result, normalizedPrize{Input: value, Snapshot: snapshot})
|
|
||||||
}
|
|
||||||
sort.Slice(result, func(i, j int) bool { return result[i].Input.SortOrder < result[j].Input.SortOrder })
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) normalizeRankRewards(ctx context.Context, sysOrigin string, values []RankRewardInput) ([]normalizedRankReward, error) {
|
|
||||||
if len(values) != len(fixedRankRanges) {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_count", "exactly six rank reward tiers are required")
|
|
||||||
}
|
|
||||||
byRange := make(map[string]RankRewardInput, len(values))
|
|
||||||
for _, value := range values {
|
|
||||||
key := fmt.Sprintf("%d-%d", value.StartRank, value.EndRank)
|
|
||||||
if _, exists := byRange[key]; exists {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "duplicate_rank_reward", "rank reward ranges must be unique")
|
|
||||||
}
|
|
||||||
byRange[key] = value
|
|
||||||
}
|
|
||||||
result := make([]normalizedRankReward, 0, len(fixedRankRanges))
|
|
||||||
for _, rankRange := range fixedRankRanges {
|
|
||||||
key := fmt.Sprintf("%d-%d", rankRange[0], rankRange[1])
|
|
||||||
value, exists := byRange[key]
|
|
||||||
if !exists {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_ranges", "rank rewards must be 1, 2, 3, 4-7, 8-10 and 11-30")
|
|
||||||
}
|
|
||||||
if value.ResourceGroupID.Int64() <= 0 {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_group", "every rank tier must reference a reward group")
|
|
||||||
}
|
|
||||||
value.RewardName = strings.TrimSpace(value.RewardName)
|
|
||||||
snapshot, err := s.loadRewardGroupSnapshot(ctx, sysOrigin, value.ResourceGroupID.Int64())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if value.RewardName == "" {
|
|
||||||
value.RewardName = snapshot.GroupName
|
|
||||||
}
|
|
||||||
if len(value.RewardName) > 128 {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_name", "rewardName must not exceed 128 characters")
|
|
||||||
}
|
|
||||||
result = append(result, normalizedRankReward{Input: value, Snapshot: snapshot})
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) loadRewardGroupSnapshot(ctx context.Context, sysOrigin string, groupID int64) (rewardGroupSnapshot, error) {
|
|
||||||
if s.gateway == nil {
|
|
||||||
return rewardGroupSnapshot{}, NewAppError(http.StatusServiceUnavailable, "reward_gateway_unavailable", "reward gateway is unavailable")
|
|
||||||
}
|
|
||||||
detail, err := s.gateway.GetRewardGroupDetail(ctx, groupID)
|
|
||||||
if err != nil {
|
|
||||||
return rewardGroupSnapshot{}, NewAppError(http.StatusBadGateway, "reward_group_lookup_failed", err.Error())
|
|
||||||
}
|
|
||||||
if int64(detail.ID) != groupID || len(detail.RewardConfigList) == 0 {
|
|
||||||
return rewardGroupSnapshot{}, NewAppError(http.StatusBadRequest, "reward_group_empty", "reward group must exist and contain at least one reward")
|
|
||||||
}
|
|
||||||
// 租户信息缺失也必须 fail-closed;否则旧 Java DTO 或异常响应会让跨租户奖励组绕过校验。
|
|
||||||
if strings.TrimSpace(detail.SysOrigin) == "" || !strings.EqualFold(strings.TrimSpace(detail.SysOrigin), sysOrigin) {
|
|
||||||
return rewardGroupSnapshot{}, NewAppError(http.StatusBadRequest, "reward_group_sys_origin_mismatch", "reward group sysOrigin does not match activity")
|
|
||||||
}
|
|
||||||
// 启用和每次发奖前都必须拿到明确的上架状态;缺失状态不能被当成“已上架”。
|
|
||||||
if detail.ShelfStatus == nil || !*detail.ShelfStatus {
|
|
||||||
return rewardGroupSnapshot{}, NewAppError(http.StatusBadRequest, "reward_group_not_shelved", "reward group must be shelved")
|
|
||||||
}
|
|
||||||
items := make([]RewardItemView, 0, len(detail.RewardConfigList))
|
|
||||||
for _, item := range detail.RewardConfigList {
|
|
||||||
if int64(item.ID) <= 0 || strings.TrimSpace(item.Type) == "" || int64(item.Quantity) <= 0 || int64(item.Quantity) > math.MaxInt32 {
|
|
||||||
return rewardGroupSnapshot{}, NewAppError(http.StatusBadRequest, "reward_group_item_invalid", "reward group contains an invalid reward item")
|
|
||||||
}
|
|
||||||
items = append(items, RewardItemView{
|
|
||||||
ID: int64(item.ID),
|
|
||||||
Type: strings.TrimSpace(item.Type),
|
|
||||||
DetailType: strings.TrimSpace(item.DetailType),
|
|
||||||
Content: strings.TrimSpace(item.Content),
|
|
||||||
Quantity: int64(item.Quantity),
|
|
||||||
Cover: strings.TrimSpace(item.Cover),
|
|
||||||
SourceURL: strings.TrimSpace(item.SourceURL),
|
|
||||||
Name: strings.TrimSpace(item.Name),
|
|
||||||
Remark: strings.TrimSpace(item.Remark),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// 奖励组接口不承诺返回顺序;按配置 ID 及实际发奖字段规范化后再冻结/比对,
|
|
||||||
// 避免单纯数组重排被误判,同时确保 type/detailType/content/quantity 全部参与校验。
|
|
||||||
sort.Slice(items, func(i, j int) bool {
|
|
||||||
if items[i].ID != items[j].ID {
|
|
||||||
return items[i].ID < items[j].ID
|
|
||||||
}
|
|
||||||
if items[i].Type != items[j].Type {
|
|
||||||
return items[i].Type < items[j].Type
|
|
||||||
}
|
|
||||||
if items[i].DetailType != items[j].DetailType {
|
|
||||||
return items[i].DetailType < items[j].DetailType
|
|
||||||
}
|
|
||||||
if items[i].Content != items[j].Content {
|
|
||||||
return items[i].Content < items[j].Content
|
|
||||||
}
|
|
||||||
return items[i].Quantity < items[j].Quantity
|
|
||||||
})
|
|
||||||
for index := range items {
|
|
||||||
items[index].Sort = index + 1
|
|
||||||
}
|
|
||||||
raw, err := json.Marshal(items)
|
|
||||||
if err != nil {
|
|
||||||
return rewardGroupSnapshot{}, err
|
|
||||||
}
|
|
||||||
return rewardGroupSnapshot{GroupID: groupID, GroupName: strings.TrimSpace(detail.Name), ItemsJSON: string(raw), Items: items}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rewardItems(raw string) []RewardItemView {
|
|
||||||
items := make([]RewardItemView, 0)
|
|
||||||
if strings.TrimSpace(raw) == "" {
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
|
||||||
return []RewardItemView{}
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func activityView(row model.YumiGameKingActivity) ActivityView {
|
|
||||||
return ActivityView{
|
|
||||||
ID: row.ID,
|
|
||||||
ActivityCode: row.ActivityCode,
|
|
||||||
ActivityName: row.ActivityName,
|
|
||||||
ActivityDesc: row.ActivityDesc,
|
|
||||||
SysOrigin: row.SysOrigin,
|
|
||||||
Timezone: row.Timezone,
|
|
||||||
StartTime: unixMilli(row.StartTime),
|
|
||||||
EndTime: unixMilli(row.EndTime),
|
|
||||||
SettlementDelayMinutes: row.SettlementDelayMinutes,
|
|
||||||
SettlementTime: unixMilli(row.SettlementTime),
|
|
||||||
CoinPerDraw: row.CoinPerDraw,
|
|
||||||
EventTypes: []string{"GAME_CONSUME_GOLD"},
|
|
||||||
RankingType: row.RankingType,
|
|
||||||
RankingPeriod: row.RankingPeriod,
|
|
||||||
Enabled: row.Enabled,
|
|
||||||
SettlementStatus: row.SettlementStatus,
|
|
||||||
CreateTime: unixMilli(row.CreateTime),
|
|
||||||
UpdateTime: unixMilli(row.UpdateTime),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func prizeView(row model.YumiGameKingPrize) PrizeView {
|
|
||||||
return PrizeView{
|
|
||||||
ID: row.ID,
|
|
||||||
ActivityID: row.ActivityID,
|
|
||||||
PrizeName: row.PrizeName,
|
|
||||||
PrizeImage: row.PrizeImage,
|
|
||||||
Weight: row.Weight,
|
|
||||||
Stock: row.Stock,
|
|
||||||
ResourceGroupID: row.ResourceGroupID,
|
|
||||||
RewardGroupName: row.RewardGroupName,
|
|
||||||
Enabled: row.Enabled,
|
|
||||||
SortOrder: row.SortOrder,
|
|
||||||
RewardItems: rewardItems(row.RewardItemsJSON),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func rankRewardView(row model.YumiGameKingRankReward) RankRewardView {
|
|
||||||
return RankRewardView{
|
|
||||||
ID: row.ID,
|
|
||||||
ActivityID: row.ActivityID,
|
|
||||||
StartRank: row.StartRank,
|
|
||||||
EndRank: row.EndRank,
|
|
||||||
ResourceGroupID: row.ResourceGroupID,
|
|
||||||
RewardName: row.RewardName,
|
|
||||||
RewardItems: rewardItems(row.RewardItemsJSON),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func activityStatus(row model.YumiGameKingActivity, now time.Time) string {
|
|
||||||
if !row.Enabled {
|
|
||||||
return activityDisabled
|
|
||||||
}
|
|
||||||
if now.Before(row.StartTime) {
|
|
||||||
return activityUpcoming
|
|
||||||
}
|
|
||||||
if now.Before(row.EndTime) {
|
|
||||||
return activityActive
|
|
||||||
}
|
|
||||||
return activityEnded
|
|
||||||
}
|
|
||||||
|
|
||||||
func dateKey(at time.Time, location *time.Location) time.Time {
|
|
||||||
local := at.In(location)
|
|
||||||
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, time.UTC)
|
|
||||||
}
|
|
||||||
|
|
||||||
func withWriteLock(db *gorm.DB) *gorm.DB {
|
|
||||||
return db.Clauses(clause.Locking{Strength: "UPDATE"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func isRecordNotFound(err error) bool { return errors.Is(err, gorm.ErrRecordNotFound) }
|
|
||||||
|
|
||||||
func truncateFailure(value string) string {
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
if len(value) <= 500 {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return value[:500]
|
|
||||||
}
|
|
||||||
|
|
||||||
func integrationProfile(entry *RankEntryView, profile integration.UserProfile) {
|
|
||||||
entry.Account = profile.Account
|
|
||||||
entry.Nickname = profile.UserNickname
|
|
||||||
entry.Avatar = profile.UserAvatar
|
|
||||||
}
|
|
||||||
@ -1,330 +0,0 @@
|
|||||||
package gameking
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Me 返回机会账本和活动默认榜单中的当前名次。
|
|
||||||
func (s *Service) Me(ctx context.Context, user AuthUser, activityID int64) (*UserStateResponse, error) {
|
|
||||||
if user.UserID <= 0 {
|
|
||||||
return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "authenticated user is required")
|
|
||||||
}
|
|
||||||
activity, err := s.resolveAppActivity(ctx, normalizeSysOrigin(user.SysOrigin), activityID, time.Now())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var row model.YumiGameKingUser
|
|
||||||
err = s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", activity.ID, user.UserID).First(&row).Error
|
|
||||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
row = model.YumiGameKingUser{ActivityID: activity.ID, UserID: user.UserID}
|
|
||||||
}
|
|
||||||
earned := row.TotalConsumed / activity.CoinPerDraw
|
|
||||||
available := earned - row.UsedChances
|
|
||||||
if available < 0 {
|
|
||||||
available = 0
|
|
||||||
}
|
|
||||||
remainder := row.TotalConsumed % activity.CoinPerDraw
|
|
||||||
next := activity.CoinPerDraw
|
|
||||||
if remainder > 0 {
|
|
||||||
next -= remainder
|
|
||||||
}
|
|
||||||
var rank *int
|
|
||||||
if row.TotalConsumed > 0 {
|
|
||||||
value, rankErr := s.userRank(ctx, activity, user.UserID, activity.RankingPeriod, time.Now())
|
|
||||||
if rankErr != nil {
|
|
||||||
return nil, rankErr
|
|
||||||
}
|
|
||||||
rank = value
|
|
||||||
}
|
|
||||||
return &UserStateResponse{
|
|
||||||
ActivityID: activity.ID, TotalConsumed: row.TotalConsumed, EarnedChances: earned,
|
|
||||||
UsedChances: row.UsedChances, AvailableChances: available, NextChanceRemaining: next, Rank: rank,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ranking 返回活动时区日榜或总榜。VICTORIOUS 是旧 H5 兼容维度;当前活动没有胜利事件,
|
|
||||||
// 因此显式请求时返回空榜而不是 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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,163 +0,0 @@
|
|||||||
package gameking
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
"chatapp3-golang/internal/utils"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
settlementDispatchBatch = 4
|
|
||||||
settlementDueBatch = 10
|
|
||||||
staleDeliveryBatch = 20
|
|
||||||
)
|
|
||||||
|
|
||||||
// Settle 在结算截点后冻结 TYCOON/OVERALL Top30,并发送尚未开始的 PENDING 奖励。
|
|
||||||
// 重复调用不会重建快照,也不会自动重试 FAILED/UNKNOWN。
|
|
||||||
func (s *Service) Settle(ctx context.Context, activityID int64) error {
|
|
||||||
if activityID <= 0 {
|
|
||||||
return NewAppError(http.StatusBadRequest, "invalid_activity_id", "activityId is required")
|
|
||||||
}
|
|
||||||
if err := s.freezeSettlement(ctx, activityID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var items []model.YumiGameKingDeliveryItem
|
|
||||||
if err := s.db.WithContext(ctx).
|
|
||||||
Where("activity_id = ? AND owner_type = ? AND delivery_status = ?", activityID, OwnerSettlement, DeliveryPending).
|
|
||||||
Order("id ASC").Limit(settlementDispatchBatch).Find(&items).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// 人工结算同样限制每次最多发送 4 组,最坏外部调用约 80 秒并为 DB 留出余量;
|
|
||||||
// 剩余 PENDING 由后续 settle-due 批次继续恢复,FAILED/UNKNOWN 不会自动发送。
|
|
||||||
var lastErr error
|
|
||||||
for _, item := range items {
|
|
||||||
if err := s.dispatchDeliveryItem(ctx, item.ID, false); err != nil {
|
|
||||||
lastErr = err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return lastErr
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) freezeSettlement(ctx context.Context, activityID int64) error {
|
|
||||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
var activity model.YumiGameKingActivity
|
|
||||||
// 排他活动锁与事件/抽奖共享锁构成冻结闸门:先进入的业务事务完成后,状态在同一
|
|
||||||
// 事务切为 PROCESSING;此后新事件会在共享锁放行后看到非 NOT_STARTED 并拒绝入账。
|
|
||||||
if err := withWriteLock(tx).Where("id = ?", activityID).First(&activity).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return NewAppError(http.StatusNotFound, "activity_not_found", "game king activity was not found")
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
if !activity.Enabled {
|
|
||||||
return NewAppError(http.StatusConflict, "activity_disabled", "disabled activity cannot be settled")
|
|
||||||
}
|
|
||||||
if now.Before(activity.SettlementTime) {
|
|
||||||
return NewAppError(http.StatusConflict, "settlement_not_due", "activity settlement time has not arrived")
|
|
||||||
}
|
|
||||||
if activity.SettlementStatus != SettlementNotStarted {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := tx.Model(&model.YumiGameKingActivity{}).Where("id = ?", activity.ID).
|
|
||||||
Updates(map[string]any{"settlement_status": SettlementProcessing, "update_time": now}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var users []model.YumiGameKingUser
|
|
||||||
if err := tx.Where("activity_id = ? AND total_consumed > 0", activity.ID).
|
|
||||||
Order("total_consumed DESC, consume_reached_time ASC, user_id ASC").Limit(30).Find(&users).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(users) == 0 {
|
|
||||||
return tx.Model(&model.YumiGameKingActivity{}).Where("id = ?", activity.ID).
|
|
||||||
Updates(map[string]any{"settlement_status": SettlementCompleted, "update_time": now}).Error
|
|
||||||
}
|
|
||||||
var rewards []model.YumiGameKingRankReward
|
|
||||||
if err := tx.Where("activity_id = ?", activity.ID).Order("start_rank ASC").Find(&rewards).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(rewards) != len(fixedRankRanges) {
|
|
||||||
return NewAppError(http.StatusConflict, "rank_reward_config_invalid", "six rank reward tiers are required for settlement")
|
|
||||||
}
|
|
||||||
for index, user := range users {
|
|
||||||
rank := index + 1
|
|
||||||
reward, ok := rankRewardFor(rewards, rank)
|
|
||||||
if !ok {
|
|
||||||
return NewAppError(http.StatusConflict, "rank_reward_config_invalid", "rank reward tier does not cover Top30")
|
|
||||||
}
|
|
||||||
recordID, err := utils.NextID()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
itemID, err := utils.NextID()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
businessNo := fmt.Sprintf("YUMI_GAME_KING_SETTLEMENT:%d:%d", activity.ID, rank)
|
|
||||||
record := model.YumiGameKingSettlementRecord{
|
|
||||||
ID: recordID, ActivityID: activity.ID, UserID: user.UserID, RankNo: rank,
|
|
||||||
TotalConsumed: user.TotalConsumed, RankRewardID: reward.ID, ResourceGroupID: reward.ResourceGroupID,
|
|
||||||
BusinessNo: businessNo, DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now,
|
|
||||||
}
|
|
||||||
if err := tx.Create(&record).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Create(&model.YumiGameKingDeliveryItem{
|
|
||||||
ID: itemID, OwnerType: OwnerSettlement, OwnerID: record.ID, ActivityID: activity.ID,
|
|
||||||
UserID: user.UserID, ResourceGroupID: reward.ResourceGroupID,
|
|
||||||
DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now,
|
|
||||||
}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func rankRewardFor(rows []model.YumiGameKingRankReward, rank int) (model.YumiGameKingRankReward, bool) {
|
|
||||||
for _, row := range rows {
|
|
||||||
if rank >= row.StartRank && rank <= row.EndRank {
|
|
||||||
return row, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return model.YumiGameKingRankReward{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// SettleDue 是外部 cron 的唯一批量触发业务方法;本仓库不启动 ticker。
|
|
||||||
func (s *Service) SettleDue(ctx context.Context) (*SettleDueResult, error) {
|
|
||||||
// 先把过期 PROCESSING 保守转 UNKNOWN;只做状态修复,不盲发。
|
|
||||||
if err := s.markStaleProcessingUnknown(ctx, staleDeliveryBatch); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var activities []model.YumiGameKingActivity
|
|
||||||
if err := s.db.WithContext(ctx).
|
|
||||||
Where("enabled = ? AND settlement_time <= ? AND settlement_status = ?", true, time.Now(), SettlementNotStarted).
|
|
||||||
Order("settlement_time ASC, id ASC").Limit(settlementDueBatch).Find(&activities).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result := &SettleDueResult{FailedIDs: []string{}}
|
|
||||||
for _, activity := range activities {
|
|
||||||
result.Scanned++
|
|
||||||
// 批量端点只快速冻结排名并生成 PENDING,不在单个活动内串行发送 Top30。
|
|
||||||
if err := s.freezeSettlement(ctx, activity.ID); err != nil {
|
|
||||||
result.FailedIDs = append(result.FailedIDs, strconv.FormatInt(activity.ID, 10))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result.Settled++
|
|
||||||
}
|
|
||||||
// 每次最多领取 4 个全局 PENDING;单项 dispatch 硬超时 20 秒,外部调用上界约
|
|
||||||
// 80 秒,为冻结/状态修复留约 40 秒,chatapp-cron 应配置 >=120 秒超时。
|
|
||||||
if _, err := s.RecoverPendingDeliveries(ctx, settlementDispatchBatch); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
@ -1,359 +0,0 @@
|
|||||||
package gameking
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/common"
|
|
||||||
"chatapp3-golang/internal/integration"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
defaultTimezone = "Asia/Riyadh"
|
|
||||||
defaultSettlementDelayMinutes = 30
|
|
||||||
maxSettlementDelayMinutes = 1440
|
|
||||||
maxBackfillLimit = 500
|
|
||||||
defaultBackfillLimit = 200
|
|
||||||
maxRankingLimit = 100
|
|
||||||
defaultRankingLimit = 30
|
|
||||||
maxRecordLimit = 100
|
|
||||||
defaultRecordLimit = 20
|
|
||||||
|
|
||||||
rankingTypeTycoon = "TYCOON"
|
|
||||||
rankingPeriodOverall = "OVERALL"
|
|
||||||
|
|
||||||
activityUpcoming = "UPCOMING"
|
|
||||||
activityActive = "ACTIVE"
|
|
||||||
activityEnded = "ENDED"
|
|
||||||
activityDisabled = "DISABLED"
|
|
||||||
|
|
||||||
SettlementNotStarted = "NOT_STARTED"
|
|
||||||
SettlementProcessing = "PROCESSING"
|
|
||||||
SettlementCompleted = "COMPLETED"
|
|
||||||
SettlementPartialFailed = "PARTIAL_FAILED"
|
|
||||||
|
|
||||||
DeliveryPending = "PENDING"
|
|
||||||
DeliveryProcessing = "PROCESSING"
|
|
||||||
DeliverySuccess = "SUCCESS"
|
|
||||||
DeliveryFailed = "FAILED"
|
|
||||||
DeliveryUnknown = "UNKNOWN"
|
|
||||||
|
|
||||||
OwnerDraw = "DRAW"
|
|
||||||
OwnerSettlement = "SETTLEMENT"
|
|
||||||
|
|
||||||
rewardOriginGameKing = "GAME_KING_AWARD"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AppError = common.AppError
|
|
||||||
type AuthUser = common.AuthUser
|
|
||||||
|
|
||||||
var NewAppError = common.NewAppError
|
|
||||||
|
|
||||||
type gameKingDB interface {
|
|
||||||
WithContext(context.Context) *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
type gameKingGateway interface {
|
|
||||||
GetRewardGroupDetail(context.Context, int64) (integration.RewardGroupDetail, error)
|
|
||||||
SendActivityReward(context.Context, integration.SendActivityRewardRequest) error
|
|
||||||
MapUserProfiles(context.Context, []int64) (map[int64]integration.UserProfile, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Service 实现 Yumi 游戏王配置、事件入账、抽奖、排行榜与结算业务。
|
|
||||||
// 自动扫描由 chatapp-cron 通过内部接口触发,本服务不持有 ticker。
|
|
||||||
type Service struct {
|
|
||||||
db gameKingDB
|
|
||||||
gateway gameKingGateway
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewService(db gameKingDB, gateway gameKingGateway) *Service {
|
|
||||||
return &Service{db: db, gateway: gateway}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FlexibleInt64 同时接受 JSON 字符串和数字,响应始终输出字符串,避免前端丢失 Snowflake 精度。
|
|
||||||
type FlexibleInt64 int64
|
|
||||||
|
|
||||||
func (v *FlexibleInt64) UnmarshalJSON(data []byte) error {
|
|
||||||
raw := strings.TrimSpace(string(data))
|
|
||||||
if raw == "" || raw == "null" || raw == `""` {
|
|
||||||
*v = 0
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) {
|
|
||||||
unquoted, err := strconv.Unquote(raw)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
raw = strings.TrimSpace(unquoted)
|
|
||||||
}
|
|
||||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid int64 value: %w", err)
|
|
||||||
}
|
|
||||||
*v = FlexibleInt64(parsed)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v FlexibleInt64) Int64() int64 { return int64(v) }
|
|
||||||
|
|
||||||
func (v FlexibleInt64) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(strconv.FormatInt(int64(v), 10))
|
|
||||||
}
|
|
||||||
|
|
||||||
type RewardItemView struct {
|
|
||||||
ID int64 `json:"id,string"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
DetailType string `json:"detailType,omitempty"`
|
|
||||||
Content string `json:"content,omitempty"`
|
|
||||||
Quantity int64 `json:"quantity"`
|
|
||||||
Cover string `json:"cover,omitempty"`
|
|
||||||
SourceURL string `json:"sourceUrl,omitempty"`
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
Remark string `json:"remark,omitempty"`
|
|
||||||
Sort int `json:"sort"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ActivityView struct {
|
|
||||||
ID int64 `json:"id,string"`
|
|
||||||
ActivityCode string `json:"activityCode"`
|
|
||||||
ActivityName string `json:"activityName"`
|
|
||||||
ActivityDesc string `json:"activityDesc"`
|
|
||||||
SysOrigin string `json:"sysOrigin"`
|
|
||||||
Timezone string `json:"timeZone"`
|
|
||||||
StartTime int64 `json:"startTime"`
|
|
||||||
EndTime int64 `json:"endTime"`
|
|
||||||
SettlementDelayMinutes int `json:"settlementDelayMinutes"`
|
|
||||||
SettlementTime int64 `json:"settlementTime"`
|
|
||||||
CoinPerDraw int64 `json:"coinPerDraw"`
|
|
||||||
EventTypes []string `json:"eventTypes"`
|
|
||||||
RankingType string `json:"rankingType"`
|
|
||||||
RankingPeriod string `json:"rankingPeriod"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
SettlementStatus string `json:"settlementStatus"`
|
|
||||||
CreateTime int64 `json:"createTime"`
|
|
||||||
UpdateTime int64 `json:"updateTime"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PrizeView struct {
|
|
||||||
ID int64 `json:"id,string"`
|
|
||||||
ActivityID int64 `json:"activityId,string"`
|
|
||||||
PrizeName string `json:"prizeName"`
|
|
||||||
PrizeImage string `json:"prizeImage"`
|
|
||||||
Weight int64 `json:"weight"`
|
|
||||||
Stock int64 `json:"stock"`
|
|
||||||
ResourceGroupID int64 `json:"resourceGroupId,string"`
|
|
||||||
RewardGroupName string `json:"rewardGroupName,omitempty"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
SortOrder int `json:"sortOrder"`
|
|
||||||
RewardItems []RewardItemView `json:"rewardItems"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RankRewardView struct {
|
|
||||||
ID int64 `json:"id,string"`
|
|
||||||
ActivityID int64 `json:"activityId,string"`
|
|
||||||
StartRank int `json:"startRank"`
|
|
||||||
EndRank int `json:"endRank"`
|
|
||||||
ResourceGroupID int64 `json:"resourceGroupId,string"`
|
|
||||||
RewardName string `json:"rewardName"`
|
|
||||||
RewardItems []RewardItemView `json:"rewardItems"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DetailResponse struct {
|
|
||||||
Activity ActivityView `json:"activity"`
|
|
||||||
Prizes []PrizeView `json:"prizes"`
|
|
||||||
RankRewards []RankRewardView `json:"rankRewards"`
|
|
||||||
SupportedRankingTypes []string `json:"supportedRankingTypes"`
|
|
||||||
SupportedRankingPeriods []string `json:"supportedRankingPeriods"`
|
|
||||||
ActivityStatus string `json:"activityStatus"`
|
|
||||||
ServerTime int64 `json:"serverTime"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserStateResponse struct {
|
|
||||||
ActivityID int64 `json:"activityId,string"`
|
|
||||||
TotalConsumed int64 `json:"totalConsumed"`
|
|
||||||
EarnedChances int64 `json:"earnedChances"`
|
|
||||||
UsedChances int64 `json:"usedChances"`
|
|
||||||
AvailableChances int64 `json:"availableChances"`
|
|
||||||
NextChanceRemaining int64 `json:"nextChanceRemaining"`
|
|
||||||
Rank *int `json:"rank"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeliveryItemView struct {
|
|
||||||
ID int64 `json:"id,string"`
|
|
||||||
TrackID int64 `json:"trackId,string"`
|
|
||||||
OwnerType string `json:"ownerType"`
|
|
||||||
OwnerID int64 `json:"ownerId,string"`
|
|
||||||
BusinessNo string `json:"businessNo"`
|
|
||||||
UserID int64 `json:"userId,string"`
|
|
||||||
ResourceGroupID int64 `json:"resourceGroupId,string"`
|
|
||||||
DeliveryStatus string `json:"deliveryStatus"`
|
|
||||||
RetryCount int `json:"retryCount"`
|
|
||||||
FailureReason string `json:"failureReason"`
|
|
||||||
DeliverTime int64 `json:"deliverTime,omitempty"`
|
|
||||||
UpdateTime int64 `json:"updateTime"`
|
|
||||||
RewardItems []RewardItemView `json:"rewardItems"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DrawRecordView struct {
|
|
||||||
ID int64 `json:"id,string"`
|
|
||||||
ActivityID int64 `json:"activityId,string"`
|
|
||||||
UserID int64 `json:"userId,string"`
|
|
||||||
RequestID string `json:"requestId"`
|
|
||||||
PrizeID int64 `json:"prizeId,string"`
|
|
||||||
PrizeName string `json:"prizeName"`
|
|
||||||
PrizeImage string `json:"prizeImage"`
|
|
||||||
ResourceGroupID int64 `json:"resourceGroupId,string"`
|
|
||||||
DeliveryStatus string `json:"deliveryStatus"`
|
|
||||||
RetryCount int `json:"retryCount"`
|
|
||||||
FailureReason string `json:"failureReason"`
|
|
||||||
DrawTime int64 `json:"drawTime"`
|
|
||||||
DeliverTime int64 `json:"deliverTime,omitempty"`
|
|
||||||
DeliveryItems []DeliveryItemView `json:"deliveryItems,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DrawRequest struct {
|
|
||||||
ActivityID FlexibleInt64 `json:"activityId"`
|
|
||||||
RequestID string `json:"requestId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DrawResult struct {
|
|
||||||
Record DrawRecordView `json:"record"`
|
|
||||||
Prize PrizeView `json:"prize"`
|
|
||||||
RemainingChances int64 `json:"remainingChances"`
|
|
||||||
IdempotentReplay bool `json:"idempotentReplay"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RankEntryView struct {
|
|
||||||
Rank int `json:"rank"`
|
|
||||||
UserID int64 `json:"userId,string"`
|
|
||||||
Account string `json:"account"`
|
|
||||||
Nickname string `json:"nickname"`
|
|
||||||
Avatar string `json:"avatar"`
|
|
||||||
Score int64 `json:"score"`
|
|
||||||
TotalConsumed int64 `json:"totalConsumed"`
|
|
||||||
TotalWon int64 `json:"totalWon"`
|
|
||||||
Me bool `json:"me"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RankingResponse struct {
|
|
||||||
RankingType string `json:"rankingType"`
|
|
||||||
Period string `json:"period"`
|
|
||||||
StatDate string `json:"statDate"`
|
|
||||||
Entries []RankEntryView `json:"entries"`
|
|
||||||
My *RankEntryView `json:"my"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SettlementRecordView struct {
|
|
||||||
ID int64 `json:"id,string"`
|
|
||||||
ActivityID int64 `json:"activityId,string"`
|
|
||||||
UserID int64 `json:"userId,string"`
|
|
||||||
Rank int `json:"rank"`
|
|
||||||
TotalConsumed int64 `json:"totalConsumed"`
|
|
||||||
RankRewardID int64 `json:"rankRewardId,string"`
|
|
||||||
ResourceGroupID int64 `json:"resourceGroupId,string"`
|
|
||||||
BusinessNo string `json:"businessNo"`
|
|
||||||
DeliveryStatus string `json:"deliveryStatus"`
|
|
||||||
RetryCount int `json:"retryCount"`
|
|
||||||
FailureReason string `json:"failureReason"`
|
|
||||||
CreateTime int64 `json:"createTime"`
|
|
||||||
DeliverTime int64 `json:"deliverTime,omitempty"`
|
|
||||||
DeliveryItems []DeliveryItemView `json:"deliveryItems,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PrizeInput struct {
|
|
||||||
ID FlexibleInt64 `json:"id"`
|
|
||||||
PrizeName string `json:"prizeName"`
|
|
||||||
PrizeImage string `json:"prizeImage"`
|
|
||||||
Weight int64 `json:"weight"`
|
|
||||||
Stock int64 `json:"stock"`
|
|
||||||
ResourceGroupID FlexibleInt64 `json:"resourceGroupId"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
SortOrder int `json:"sortOrder"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RankRewardInput struct {
|
|
||||||
ID FlexibleInt64 `json:"id"`
|
|
||||||
StartRank int `json:"startRank"`
|
|
||||||
EndRank int `json:"endRank"`
|
|
||||||
ResourceGroupID FlexibleInt64 `json:"resourceGroupId"`
|
|
||||||
RewardName string `json:"rewardName"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SaveActivityRequest struct {
|
|
||||||
ID FlexibleInt64 `json:"id"`
|
|
||||||
ActivityCode string `json:"activityCode"`
|
|
||||||
ActivityName string `json:"activityName"`
|
|
||||||
ActivityDesc string `json:"activityDesc"`
|
|
||||||
SysOrigin string `json:"sysOrigin"`
|
|
||||||
Timezone string `json:"timeZone"`
|
|
||||||
StartTime int64 `json:"startTime"`
|
|
||||||
EndTime int64 `json:"endTime"`
|
|
||||||
SettlementDelayMinutes *int `json:"settlementDelayMinutes"`
|
|
||||||
CoinPerDraw int64 `json:"coinPerDraw"`
|
|
||||||
RankingType string `json:"rankingType"`
|
|
||||||
RankingPeriod string `json:"rankingPeriod"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
Prizes []PrizeInput `json:"prizes"`
|
|
||||||
RankRewards []RankRewardInput `json:"rankRewards"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SavePrizesRequest struct {
|
|
||||||
ActivityID FlexibleInt64 `json:"activityId"`
|
|
||||||
Prizes []PrizeInput `json:"prizes"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SaveRankRewardsRequest struct {
|
|
||||||
ActivityID FlexibleInt64 `json:"activityId"`
|
|
||||||
RankRewards []RankRewardInput `json:"rankRewards"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BackfillResult struct {
|
|
||||||
EventType string `json:"eventType"`
|
|
||||||
Scanned int `json:"scanned"`
|
|
||||||
Accepted int `json:"accepted"`
|
|
||||||
NextLastID int64 `json:"nextLastId,string"`
|
|
||||||
Finished bool `json:"finished"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SettleDueResult struct {
|
|
||||||
Scanned int `json:"scanned"`
|
|
||||||
Settled int `json:"settled"`
|
|
||||||
FailedIDs []string `json:"failedIds"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type rewardGroupSnapshot struct {
|
|
||||||
GroupID int64
|
|
||||||
GroupName string
|
|
||||||
ItemsJSON string
|
|
||||||
Items []RewardItemView
|
|
||||||
}
|
|
||||||
|
|
||||||
type normalizedPrize struct {
|
|
||||||
Input PrizeInput
|
|
||||||
Snapshot rewardGroupSnapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
type normalizedRankReward struct {
|
|
||||||
Input RankRewardInput
|
|
||||||
Snapshot rewardGroupSnapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
func unixMilli(value time.Time) int64 {
|
|
||||||
if value.IsZero() {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return value.UnixMilli()
|
|
||||||
}
|
|
||||||
|
|
||||||
func unixMilliPtr(value *time.Time) int64 {
|
|
||||||
if value == nil || value.IsZero() {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return value.UnixMilli()
|
|
||||||
}
|
|
||||||
@ -1,387 +0,0 @@
|
|||||||
package gameopen
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/integration"
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
"chatapp3-golang/internal/service/taskcenter"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
hotgameVendorType = "HOTGAME"
|
|
||||||
hotgameCodeTokenInvalid = 1001
|
|
||||||
hotgameCodeInsufficientBalance = 2001
|
|
||||||
hotgameCodeDuplicateOrder = 3001
|
|
||||||
hotgameCodeBadRequest = 4001
|
|
||||||
hotgameCodeSignatureError = 4002
|
|
||||||
hotgameCodeServerError = 5000
|
|
||||||
)
|
|
||||||
|
|
||||||
// HotgameGetUserInfoRequest 是热游 /getUserInfo 回调入参。
|
|
||||||
type HotgameGetUserInfoRequest struct {
|
|
||||||
GameID string `json:"gameId"`
|
|
||||||
UID string `json:"uid"`
|
|
||||||
Token string `json:"token"`
|
|
||||||
Sign string `json:"sign"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// HotgameUpdateBalanceRequest 是热游 /updateBalance 回调入参;roundId 文档是 int,所以这里兼容数字和字符串。
|
|
||||||
type HotgameUpdateBalanceRequest struct {
|
|
||||||
OrderID string `json:"orderId"`
|
|
||||||
GameID string `json:"gameId"`
|
|
||||||
RoundID any `json:"roundId"`
|
|
||||||
UID string `json:"uid"`
|
|
||||||
Coin int64 `json:"coin"`
|
|
||||||
Type int `json:"type"`
|
|
||||||
Token string `json:"token"`
|
|
||||||
Sign string `json:"sign"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleHotgameGetUserInfo 按热游协议查询玩家资料和余额。
|
|
||||||
func (s *GameOpenService) HandleHotgameGetUserInfo(ctx context.Context, req HotgameGetUserInfoRequest, rawJSON string) CallbackResponse {
|
|
||||||
if err := s.validateHotgameGetUserInfoRequest(req); err != nil {
|
|
||||||
resp := failResponse(hotgameCodeBadRequest, err.Error())
|
|
||||||
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, "", req.toQueryUserRequest(), nil, gameOpenLogStatusFailed)
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
appKey := s.resolveCallbackAppKeyForVendor(ctx, hotgameVendorType, req.GameID, req.Token)
|
|
||||||
if strings.TrimSpace(appKey) == "" {
|
|
||||||
resp := failResponse(hotgameCodeBadRequest, "app key is blank")
|
|
||||||
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, "", req.toQueryUserRequest(), nil, gameOpenLogStatusFailed)
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
if !verifySign(req.Sign, buildHotgameGetUserInfoSign(req, appKey)) {
|
|
||||||
resp := failResponse(hotgameCodeSignatureError, "signature error")
|
|
||||||
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, "", req.toQueryUserRequest(), nil, gameOpenLogStatusFailed)
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Token = normalizeCallbackToken(req.Token)
|
|
||||||
credential, err := s.java.AuthenticateToken(ctx, req.Token)
|
|
||||||
if err != nil {
|
|
||||||
resp := failResponse(hotgameCodeTokenInvalid, "token invalid")
|
|
||||||
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, "", req.toQueryUserRequest(), nil, gameOpenLogStatusFailed)
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
resolved, err := s.resolveUserWithToken(ctx, credential, req.UID)
|
|
||||||
if err != nil {
|
|
||||||
resp := failResponse(hotgameCodeTokenInvalid, "uid token mismatch")
|
|
||||||
userID := int64(credential.UserID)
|
|
||||||
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, credential.SysOrigin, req.toQueryUserRequest(), &userID, gameOpenLogStatusFailed)
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
userID := resolved.userID
|
|
||||||
|
|
||||||
profile := resolved.profile
|
|
||||||
if int64(profile.ID) == 0 {
|
|
||||||
profile, err = s.java.GetUserProfile(ctx, userID)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
resp := failResponse(hotgameCodeServerError, "profile load failed")
|
|
||||||
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, credential.SysOrigin, req.toQueryUserRequest(), &userID, gameOpenLogStatusFailed)
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
balance, err := s.readUserBalance(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
resp := failResponse(hotgameCodeServerError, "balance load failed")
|
|
||||||
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, credential.SysOrigin, req.toQueryUserRequest(), &userID, gameOpenLogStatusFailed)
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := successResponse(map[string]any{
|
|
||||||
"uid": req.UID,
|
|
||||||
"nickname": profile.UserNickname,
|
|
||||||
"avatar": profile.UserAvatar,
|
|
||||||
"coin": balance,
|
|
||||||
"vipLevel": 0,
|
|
||||||
})
|
|
||||||
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, credential.SysOrigin, req.toQueryUserRequest(), &userID, gameOpenLogStatusSuccess)
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleHotgameUpdateBalance 按热游协议处理实时扣加币,并用 orderId 做钱包事件幂等。
|
|
||||||
func (s *GameOpenService) HandleHotgameUpdateBalance(ctx context.Context, req HotgameUpdateBalanceRequest, rawJSON string) CallbackResponse {
|
|
||||||
updateReq := req.toUpdateCoinRequest()
|
|
||||||
if err := s.validateHotgameUpdateBalanceRequest(req); err != nil {
|
|
||||||
resp := failResponse(hotgameCodeBadRequest, err.Error())
|
|
||||||
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, "", updateReq, nil, gameOpenLogStatusFailed)
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
appKey := s.resolveCallbackAppKeyForVendor(ctx, hotgameVendorType, req.GameID, req.Token)
|
|
||||||
if strings.TrimSpace(appKey) == "" {
|
|
||||||
resp := failResponse(hotgameCodeBadRequest, "app key is blank")
|
|
||||||
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, "", updateReq, nil, gameOpenLogStatusFailed)
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
if !verifySign(req.Sign, buildHotgameUpdateBalanceSign(req, appKey)) {
|
|
||||||
resp := failResponse(hotgameCodeSignatureError, "signature error")
|
|
||||||
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, "", updateReq, nil, gameOpenLogStatusFailed)
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Token = normalizeCallbackToken(req.Token)
|
|
||||||
updateReq.Token = req.Token
|
|
||||||
credential, err := s.java.AuthenticateToken(ctx, req.Token)
|
|
||||||
if err != nil {
|
|
||||||
resp := failResponse(hotgameCodeTokenInvalid, "token invalid")
|
|
||||||
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, "", updateReq, nil, gameOpenLogStatusFailed)
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
resolved, err := s.resolveUserWithToken(ctx, credential, req.UID)
|
|
||||||
if err != nil {
|
|
||||||
resp := failResponse(hotgameCodeTokenInvalid, "uid token mismatch")
|
|
||||||
userID := int64(credential.UserID)
|
|
||||||
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed)
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
userID := resolved.userID
|
|
||||||
|
|
||||||
eventID := "GAME_HOTGAME:" + strings.TrimSpace(req.OrderID)
|
|
||||||
exists, err := s.java.ExistsGoldEvent(ctx, eventID)
|
|
||||||
if err != nil {
|
|
||||||
resp := failResponse(hotgameCodeServerError, "event check failed")
|
|
||||||
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed)
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
if exists {
|
|
||||||
resp := failResponse(hotgameCodeDuplicateOrder, "duplicate orderId")
|
|
||||||
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed)
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{
|
|
||||||
ReceiptType: receiptTypeFromType(req.Type),
|
|
||||||
UserID: userID,
|
|
||||||
SysOrigin: credential.SysOrigin,
|
|
||||||
EventID: eventID,
|
|
||||||
Remark: fmt.Sprintf("hotgame=%s type=%d round=%s", req.GameID, req.Type, updateReq.RoundID),
|
|
||||||
Amount: integration.NewPennyAmountPayloadFromDollar(req.Coin),
|
|
||||||
CloseDelayAsset: false,
|
|
||||||
OpUserType: "APP",
|
|
||||||
CustomizeOrigin: "GAME_HOTGAME_" + strings.TrimSpace(req.GameID),
|
|
||||||
CustomizeOriginDesc: "HOTGAME[" + strings.TrimSpace(req.GameID) + "]",
|
|
||||||
}); err != nil {
|
|
||||||
// 热游文档要求扣款失败必须返回失败码;支出失败统一归到余额不足类,防止游戏继续开奖。
|
|
||||||
code := hotgameCodeServerError
|
|
||||||
if req.Type == 1 || looksLikeInsufficientBalance(err) {
|
|
||||||
code = hotgameCodeInsufficientBalance
|
|
||||||
}
|
|
||||||
resp := failResponse(code, err.Error())
|
|
||||||
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed)
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
balance, err := s.readUserBalance(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
resp := failResponse(hotgameCodeServerError, "balance load failed")
|
|
||||||
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed)
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := successResponse(map[string]any{"coin": balance, "responseId": eventID})
|
|
||||||
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusSuccess)
|
|
||||||
s.reportHotgameConsumeTaskEvent(ctx, credential.SysOrigin, userID, updateReq)
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
func (req HotgameGetUserInfoRequest) toQueryUserRequest() QueryUserRequest {
|
|
||||||
return QueryUserRequest{
|
|
||||||
GameID: req.GameID,
|
|
||||||
UID: req.UID,
|
|
||||||
Token: req.Token,
|
|
||||||
Sign: req.Sign,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (req HotgameUpdateBalanceRequest) toUpdateCoinRequest() UpdateCoinRequest {
|
|
||||||
return UpdateCoinRequest{
|
|
||||||
OrderID: req.OrderID,
|
|
||||||
GameID: req.GameID,
|
|
||||||
RoundID: hotgameRoundIDString(req.RoundID),
|
|
||||||
UID: req.UID,
|
|
||||||
Coin: req.Coin,
|
|
||||||
Type: req.Type,
|
|
||||||
Token: req.Token,
|
|
||||||
Sign: req.Sign,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *GameOpenService) validateHotgameGetUserInfoRequest(req HotgameGetUserInfoRequest) error {
|
|
||||||
switch {
|
|
||||||
case strings.TrimSpace(req.GameID) == "":
|
|
||||||
return newBadRequest("gameId is required")
|
|
||||||
case strings.TrimSpace(req.UID) == "":
|
|
||||||
return newBadRequest("uid is required")
|
|
||||||
case strings.TrimSpace(req.Token) == "":
|
|
||||||
return newBadRequest("token is required")
|
|
||||||
case strings.TrimSpace(req.Sign) == "":
|
|
||||||
return newBadRequest("sign is required")
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *GameOpenService) validateHotgameUpdateBalanceRequest(req HotgameUpdateBalanceRequest) error {
|
|
||||||
switch {
|
|
||||||
case strings.TrimSpace(req.OrderID) == "":
|
|
||||||
return newBadRequest("orderId is required")
|
|
||||||
case strings.TrimSpace(req.GameID) == "":
|
|
||||||
return newBadRequest("gameId is required")
|
|
||||||
case strings.TrimSpace(hotgameRoundIDString(req.RoundID)) == "":
|
|
||||||
return newBadRequest("roundId is required")
|
|
||||||
case strings.TrimSpace(req.UID) == "":
|
|
||||||
return newBadRequest("uid is required")
|
|
||||||
case req.Coin <= 0:
|
|
||||||
return newBadRequest("coin is required")
|
|
||||||
case req.Type != 1 && req.Type != 2:
|
|
||||||
return newBadRequest("type must be 1 or 2")
|
|
||||||
case strings.TrimSpace(req.Token) == "":
|
|
||||||
return newBadRequest("token is required")
|
|
||||||
case strings.TrimSpace(req.Sign) == "":
|
|
||||||
return newBadRequest("sign is required")
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildHotgameGetUserInfoSign(req HotgameGetUserInfoRequest, key string) string {
|
|
||||||
return md5Hex(req.GameID + req.UID + req.Token + key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildHotgameUpdateBalanceSign(req HotgameUpdateBalanceRequest, key string) string {
|
|
||||||
return md5Hex(
|
|
||||||
req.OrderID +
|
|
||||||
req.GameID +
|
|
||||||
hotgameRoundIDString(req.RoundID) +
|
|
||||||
req.UID +
|
|
||||||
fmt.Sprintf("%d", req.Coin) +
|
|
||||||
fmt.Sprintf("%d", req.Type) +
|
|
||||||
req.Token +
|
|
||||||
key,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func hotgameRoundIDString(value any) string {
|
|
||||||
switch typed := value.(type) {
|
|
||||||
case nil:
|
|
||||||
return ""
|
|
||||||
case string:
|
|
||||||
return strings.TrimSpace(typed)
|
|
||||||
case json.Number:
|
|
||||||
return strings.TrimSpace(typed.String())
|
|
||||||
case float64:
|
|
||||||
if typed == float64(int64(typed)) {
|
|
||||||
return fmt.Sprintf("%d", int64(typed))
|
|
||||||
}
|
|
||||||
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%f", typed), "0"), ".")
|
|
||||||
default:
|
|
||||||
return strings.TrimSpace(fmt.Sprint(typed))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *GameOpenService) resolveCallbackAppKeyForVendor(ctx context.Context, vendorType string, gameID, token string) string {
|
|
||||||
if s != nil && s.repo.DB != nil {
|
|
||||||
if sysOrigin := callbackTokenSysOrigin(token); sysOrigin != "" {
|
|
||||||
if appKey := s.resolveActiveProviderAppKey(ctx, vendorType, sysOrigin); appKey != "" {
|
|
||||||
return appKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if appKey := s.resolveProviderAppKeyByGame(ctx, vendorType, gameID); appKey != "" {
|
|
||||||
return appKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(s.cfg.GameOpen.AppKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *GameOpenService) resolveProviderAppKeyByGame(ctx context.Context, vendorType string, gameID string) string {
|
|
||||||
gameID = strings.TrimSpace(gameID)
|
|
||||||
if gameID == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
switch strings.ToUpper(strings.TrimSpace(vendorType)) {
|
|
||||||
case "LINGXIAN":
|
|
||||||
return s.resolveLingxianAppKeyByGame(ctx, gameID)
|
|
||||||
case hotgameVendorType:
|
|
||||||
var row struct {
|
|
||||||
AppKey string
|
|
||||||
}
|
|
||||||
err := s.repo.DB.WithContext(ctx).
|
|
||||||
Table("hotgame_provider_config AS cfg").
|
|
||||||
Select("cfg.app_key AS app_key").
|
|
||||||
Joins("JOIN sys_game_list_vendor_ext ext ON ext.sys_origin = cfg.sys_origin AND ext.profile = cfg.profile AND ext.vendor_type = ? AND ext.enabled = ?", hotgameVendorType, true).
|
|
||||||
Where("cfg.active = ? AND ext.vendor_game_id = ?", true, gameID).
|
|
||||||
Order("cfg.update_time DESC").
|
|
||||||
Limit(1).
|
|
||||||
Scan(&row).Error
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(row.AppKey)
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *GameOpenService) resolveActiveProviderAppKey(ctx context.Context, vendorType string, sysOrigin string) string {
|
|
||||||
switch strings.ToUpper(strings.TrimSpace(vendorType)) {
|
|
||||||
case "LINGXIAN":
|
|
||||||
return s.resolveActiveLingxianAppKey(ctx, sysOrigin)
|
|
||||||
case hotgameVendorType:
|
|
||||||
sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin))
|
|
||||||
if sysOrigin == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var row model.HotgameProviderConfig
|
|
||||||
err := s.repo.DB.WithContext(ctx).
|
|
||||||
Where("sys_origin = ? AND active = ?", sysOrigin, true).
|
|
||||||
Order("update_time DESC").
|
|
||||||
Limit(1).
|
|
||||||
First(&row).Error
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(row.AppKey)
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func looksLikeInsufficientBalance(err error) bool {
|
|
||||||
if err == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
message := strings.ToLower(err.Error())
|
|
||||||
return strings.Contains(message, "insufficient") ||
|
|
||||||
strings.Contains(message, "balance not made") ||
|
|
||||||
strings.Contains(message, "余额不足")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *GameOpenService) reportHotgameConsumeTaskEvent(ctx context.Context, sysOrigin string, userID int64, req UpdateCoinRequest) {
|
|
||||||
if s.taskReporter == nil || receiptTypeFromType(req.Type) != "EXPENDITURE" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
deltaValue := req.Coin
|
|
||||||
if deltaValue < 0 {
|
|
||||||
deltaValue = -deltaValue
|
|
||||||
}
|
|
||||||
if deltaValue <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
payload := map[string]any{
|
|
||||||
"provider": hotgameVendorType,
|
|
||||||
"orderId": strings.TrimSpace(req.OrderID),
|
|
||||||
"gameId": strings.TrimSpace(req.GameID),
|
|
||||||
"roundId": strings.TrimSpace(req.RoundID),
|
|
||||||
}
|
|
||||||
if err := s.taskReporter.ReportSimpleEvent(ctx, sysOrigin, "GAME_HOTGAME_CONSUME:"+strings.TrimSpace(req.OrderID), taskcenter.EventTypeGameConsumeGold, userID, deltaValue, payload); err != nil {
|
|
||||||
log.Printf("report hotgame task event failed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -219,7 +219,20 @@ func (s *GameOpenService) GetIntegrationInfo(ctx context.Context, publicBaseURL
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *GameOpenService) resolveCallbackAppKey(ctx context.Context, gameID, token string) string {
|
func (s *GameOpenService) resolveCallbackAppKey(ctx context.Context, gameID, token string) string {
|
||||||
return s.resolveCallbackAppKeyForVendor(ctx, "LINGXIAN", gameID, token)
|
if s != nil && s.repo.DB != nil {
|
||||||
|
if sysOrigin := callbackTokenSysOrigin(token); sysOrigin != "" {
|
||||||
|
if appKey := s.resolveActiveLingxianAppKey(ctx, sysOrigin); appKey != "" {
|
||||||
|
return appKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if appKey := s.resolveLingxianAppKeyByGame(ctx, gameID); appKey != "" {
|
||||||
|
return appKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(s.cfg.GameOpen.AppKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *GameOpenService) resolveLingxianAppKeyByGame(ctx context.Context, gameID string) string {
|
func (s *GameOpenService) resolveLingxianAppKeyByGame(ctx context.Context, gameID string) string {
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import (
|
|||||||
"chatapp3-golang/internal/integration"
|
"chatapp3-golang/internal/integration"
|
||||||
"chatapp3-golang/internal/model"
|
"chatapp3-golang/internal/model"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -21,7 +20,6 @@ type stubGateway struct {
|
|||||||
accountMap map[string]integration.UserProfile
|
accountMap map[string]integration.UserProfile
|
||||||
balance int64
|
balance int64
|
||||||
exists bool
|
exists bool
|
||||||
changeErr error
|
|
||||||
cmd integration.GoldReceiptCommand
|
cmd integration.GoldReceiptCommand
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,7 +72,7 @@ func (s *stubGateway) ExistsGoldEvent(context.Context, string) (bool, error) {
|
|||||||
|
|
||||||
func (s *stubGateway) ChangeGoldBalance(_ context.Context, cmd integration.GoldReceiptCommand) error {
|
func (s *stubGateway) ChangeGoldBalance(_ context.Context, cmd integration.GoldReceiptCommand) error {
|
||||||
s.cmd = cmd
|
s.cmd = cmd
|
||||||
return s.changeErr
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleQueryUser(t *testing.T) {
|
func TestHandleQueryUser(t *testing.T) {
|
||||||
@ -353,97 +351,6 @@ func TestCallbacksUseLingxianProviderConfigAppKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHotgameGetUserInfoUsesHotgameSign(t *testing.T) {
|
|
||||||
gateway := &stubGateway{
|
|
||||||
credential: integration.UserCredential{UserID: 1234567, SysOrigin: "LIKEI"},
|
|
||||||
profile: integration.UserProfile{
|
|
||||||
ID: 1234567,
|
|
||||||
Account: "1234567",
|
|
||||||
UserNickname: "tester",
|
|
||||||
UserAvatar: "https://example.com/avatar.png",
|
|
||||||
},
|
|
||||||
balance: 88,
|
|
||||||
}
|
|
||||||
service := NewGameOpenService(config.Config{
|
|
||||||
GameOpen: config.GameOpenConfig{AppKey: "hotgame-key"},
|
|
||||||
}, nil, gateway)
|
|
||||||
req := HotgameGetUserInfoRequest{
|
|
||||||
GameID: "1",
|
|
||||||
UID: "1234567",
|
|
||||||
Token: "token",
|
|
||||||
}
|
|
||||||
req.Sign = buildHotgameGetUserInfoSign(req, "hotgame-key")
|
|
||||||
|
|
||||||
resp := service.HandleHotgameGetUserInfo(context.Background(), req, "{}")
|
|
||||||
if resp.ErrorCode != 0 {
|
|
||||||
t.Fatalf("HandleHotgameGetUserInfo() error = %+v", resp)
|
|
||||||
}
|
|
||||||
data, ok := resp.Data.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("HandleHotgameGetUserInfo() data type = %T", resp.Data)
|
|
||||||
}
|
|
||||||
if got := data["vipLevel"]; got != 0 {
|
|
||||||
t.Fatalf("HandleHotgameGetUserInfo() vipLevel = %v, want 0", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHotgameUpdateBalanceAcceptsNumericRoundIDAndDuplicateReturns3001(t *testing.T) {
|
|
||||||
gateway := &stubGateway{
|
|
||||||
credential: integration.UserCredential{UserID: 1234567, SysOrigin: "LIKEI"},
|
|
||||||
profile: integration.UserProfile{ID: 1234567, Account: "1234567"},
|
|
||||||
balance: 99,
|
|
||||||
exists: true,
|
|
||||||
}
|
|
||||||
service := NewGameOpenService(config.Config{
|
|
||||||
GameOpen: config.GameOpenConfig{AppKey: "hotgame-key"},
|
|
||||||
}, nil, gateway)
|
|
||||||
req := HotgameUpdateBalanceRequest{
|
|
||||||
OrderID: "order-1",
|
|
||||||
GameID: "1",
|
|
||||||
RoundID: float64(3199),
|
|
||||||
UID: "1234567",
|
|
||||||
Coin: 15,
|
|
||||||
Type: 1,
|
|
||||||
Token: "token",
|
|
||||||
}
|
|
||||||
req.Sign = buildHotgameUpdateBalanceSign(req, "hotgame-key")
|
|
||||||
|
|
||||||
resp := service.HandleHotgameUpdateBalance(context.Background(), req, "{}")
|
|
||||||
if resp.ErrorCode != hotgameCodeDuplicateOrder {
|
|
||||||
t.Fatalf("HandleHotgameUpdateBalance() errorCode = %d, want %d", resp.ErrorCode, hotgameCodeDuplicateOrder)
|
|
||||||
}
|
|
||||||
if gateway.cmd.EventID != "" {
|
|
||||||
t.Fatalf("HandleHotgameUpdateBalance() should not change duplicate order, got %q", gateway.cmd.EventID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHotgameUpdateBalanceDeductFailureReturns2001(t *testing.T) {
|
|
||||||
gateway := &stubGateway{
|
|
||||||
credential: integration.UserCredential{UserID: 1234567, SysOrigin: "LIKEI"},
|
|
||||||
profile: integration.UserProfile{ID: 1234567, Account: "1234567"},
|
|
||||||
balance: 99,
|
|
||||||
changeErr: errors.New("insufficient_balance"),
|
|
||||||
}
|
|
||||||
service := NewGameOpenService(config.Config{
|
|
||||||
GameOpen: config.GameOpenConfig{AppKey: "hotgame-key"},
|
|
||||||
}, nil, gateway)
|
|
||||||
req := HotgameUpdateBalanceRequest{
|
|
||||||
OrderID: "order-1",
|
|
||||||
GameID: "1",
|
|
||||||
RoundID: float64(3199),
|
|
||||||
UID: "1234567",
|
|
||||||
Coin: 15,
|
|
||||||
Type: 1,
|
|
||||||
Token: "token",
|
|
||||||
}
|
|
||||||
req.Sign = buildHotgameUpdateBalanceSign(req, "hotgame-key")
|
|
||||||
|
|
||||||
resp := service.HandleHotgameUpdateBalance(context.Background(), req, "{}")
|
|
||||||
if resp.ErrorCode != hotgameCodeInsufficientBalance {
|
|
||||||
t.Fatalf("HandleHotgameUpdateBalance() errorCode = %d, want %d", resp.ErrorCode, hotgameCodeInsufficientBalance)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newGameOpenTestDB(t *testing.T) *gorm.DB {
|
func newGameOpenTestDB(t *testing.T) *gorm.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||||
@ -452,7 +359,6 @@ func newGameOpenTestDB(t *testing.T) *gorm.DB {
|
|||||||
}
|
}
|
||||||
if err := db.AutoMigrate(
|
if err := db.AutoMigrate(
|
||||||
&model.LingxianProviderConfig{},
|
&model.LingxianProviderConfig{},
|
||||||
&model.HotgameProviderConfig{},
|
|
||||||
&model.SysGameListVendorExt{},
|
&model.SysGameListVendorExt{},
|
||||||
&model.GameOpenCallbackLog{},
|
&model.GameOpenCallbackLog{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|||||||
@ -79,7 +79,7 @@ func (r *Registry) List() ProviderListResponse {
|
|||||||
|
|
||||||
// ListShortcutGames 返回聚合后的快捷游戏列表。
|
// ListShortcutGames 返回聚合后的快捷游戏列表。
|
||||||
func (r *Registry) ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) {
|
func (r *Registry) ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) {
|
||||||
if IsEmptyGameListUser(user.UserID) {
|
if isEmptyGameListUser(user.UserID) {
|
||||||
return []RoomGameListItem{}, nil
|
return []RoomGameListItem{}, nil
|
||||||
}
|
}
|
||||||
if r == nil || len(r.ordered) == 0 {
|
if r == nil || len(r.ordered) == 0 {
|
||||||
@ -103,7 +103,7 @@ func (r *Registry) ListShortcutGames(ctx context.Context, user AuthUser, roomID
|
|||||||
|
|
||||||
// ListRoomGames 返回聚合后的房间游戏列表。
|
// ListRoomGames 返回聚合后的房间游戏列表。
|
||||||
func (r *Registry) ListRoomGames(ctx context.Context, user AuthUser, roomID, category string) (*RoomGameListResponse, error) {
|
func (r *Registry) ListRoomGames(ctx context.Context, user AuthUser, roomID, category string) (*RoomGameListResponse, error) {
|
||||||
if IsEmptyGameListUser(user.UserID) {
|
if isEmptyGameListUser(user.UserID) {
|
||||||
return &RoomGameListResponse{Items: []RoomGameListItem{}}, nil
|
return &RoomGameListResponse{Items: []RoomGameListItem{}}, nil
|
||||||
}
|
}
|
||||||
if r == nil || len(r.ordered) == 0 {
|
if r == nil || len(r.ordered) == 0 {
|
||||||
@ -124,7 +124,7 @@ func (r *Registry) ListRoomGames(ctx context.Context, user AuthUser, roomID, cat
|
|||||||
return &RoomGameListResponse{Items: items}, nil
|
return &RoomGameListResponse{Items: items}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func IsEmptyGameListUser(userID int64) bool {
|
func isEmptyGameListUser(userID int64) bool {
|
||||||
_, ok := emptyGameListUserIDs[userID]
|
_, ok := emptyGameListUserIDs[userID]
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
@ -251,8 +251,6 @@ func normalizeProviderAlias(key string) string {
|
|||||||
switch normalizeKey(key) {
|
switch normalizeKey(key) {
|
||||||
case "LEADER":
|
case "LEADER":
|
||||||
return "LINGXIAN"
|
return "LINGXIAN"
|
||||||
case "HOTGAME", "HOT_GAME", "REYOU", "LALU":
|
|
||||||
return "HOTGAME"
|
|
||||||
default:
|
default:
|
||||||
return normalizeKey(key)
|
return normalizeKey(key)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -80,20 +80,6 @@ func TestRegistryResolveMapsLeaderAliasToLingxian(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegistryResolveMapsHotgameAliases(t *testing.T) {
|
|
||||||
registry := NewRegistry(&stubProvider{key: "HOTGAME", name: "热游"})
|
|
||||||
|
|
||||||
for _, alias := range []string{"HOTGAME", "hot_game", "REYOU", "lalu"} {
|
|
||||||
provider, err := registry.Resolve(alias)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Resolve(%q) error = %v", alias, err)
|
|
||||||
}
|
|
||||||
if provider.Key() != "HOTGAME" {
|
|
||||||
t.Fatalf("Resolve(%q) key = %q, want HOTGAME", alias, provider.Key())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistryListKeepsRegistrationOrder(t *testing.T) {
|
func TestRegistryListKeepsRegistrationOrder(t *testing.T) {
|
||||||
registry := NewRegistry(
|
registry := NewRegistry(
|
||||||
&stubProvider{key: "BAISHUN", name: "百顺"},
|
&stubProvider{key: "BAISHUN", name: "百顺"},
|
||||||
|
|||||||
@ -1,57 +0,0 @@
|
|||||||
package gameprovider
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/integration"
|
|
||||||
)
|
|
||||||
|
|
||||||
const gameListTargetSysOrigin = "LIKEI"
|
|
||||||
|
|
||||||
// VisibilityGateway 聚合游戏列表门禁需要的 Java 用户区域能力。
|
|
||||||
type VisibilityGateway interface {
|
|
||||||
GetUserRegion(ctx context.Context, userID int64, authorization string) (integration.UserRegion, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// VisibilityResult 返回门禁结果,并把已经查到的区域带回路由继续参与 SQL 过滤。
|
|
||||||
type VisibilityResult struct {
|
|
||||||
Allow bool
|
|
||||||
RegionID string
|
|
||||||
RegionCode string
|
|
||||||
}
|
|
||||||
|
|
||||||
// VisibilityPolicy 判断当前用户是否可以看到游戏列表。
|
|
||||||
type VisibilityPolicy struct {
|
|
||||||
java VisibilityGateway
|
|
||||||
targetSys string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewVisibilityPolicy 创建 LIKEI 游戏列表门禁:土耳其财富等级限制已下线,现在对所有区域直接展示,
|
|
||||||
// 保留 Evaluate 是因为路由仍依赖它查询用户区域给 provider SQL 做 regions 过滤。
|
|
||||||
func NewVisibilityPolicy(java VisibilityGateway) *VisibilityPolicy {
|
|
||||||
return &VisibilityPolicy{
|
|
||||||
java: java,
|
|
||||||
targetSys: gameListTargetSysOrigin,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Evaluate 给 LIKEI 用户补齐区域信息并放行;非 LIKEI 用户沿用自带区域直接展示。
|
|
||||||
func (p *VisibilityPolicy) Evaluate(ctx context.Context, user AuthUser) (VisibilityResult, error) {
|
|
||||||
if p == nil || !strings.EqualFold(strings.TrimSpace(user.SysOrigin), p.targetSys) {
|
|
||||||
return VisibilityResult{Allow: true, RegionID: user.RegionID, RegionCode: user.RegionCode}, nil
|
|
||||||
}
|
|
||||||
if user.UserID <= 0 || p.java == nil {
|
|
||||||
return VisibilityResult{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
region, err := p.java.GetUserRegion(ctx, user.UserID, user.Authorization)
|
|
||||||
if err != nil {
|
|
||||||
return VisibilityResult{}, err
|
|
||||||
}
|
|
||||||
return VisibilityResult{
|
|
||||||
Allow: true,
|
|
||||||
RegionID: strings.TrimSpace(region.RegionID),
|
|
||||||
RegionCode: strings.TrimSpace(region.RegionCode),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
package gameprovider
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/integration"
|
|
||||||
)
|
|
||||||
|
|
||||||
type stubVisibilityGateway struct {
|
|
||||||
region integration.UserRegion
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s stubVisibilityGateway) GetUserRegion(context.Context, int64, string) (integration.UserRegion, error) {
|
|
||||||
return s.region, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const account1007500UserID int64 = 2054163533368717314
|
|
||||||
|
|
||||||
func TestVisibilityPolicyAllowsAllRegions(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
region integration.UserRegion
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "turkey region is visible without wealth gate",
|
|
||||||
region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "south asia region is visible",
|
|
||||||
region: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
policy := NewVisibilityPolicy(stubVisibilityGateway{region: tt.region})
|
|
||||||
|
|
||||||
result, err := policy.Evaluate(context.Background(), AuthUser{UserID: account1007500UserID, SysOrigin: "LIKEI"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Evaluate() error = %v", err)
|
|
||||||
}
|
|
||||||
if !result.Allow {
|
|
||||||
t.Fatalf("Evaluate() allow = false, want true")
|
|
||||||
}
|
|
||||||
if result.RegionID != tt.region.RegionID || result.RegionCode != tt.region.RegionCode {
|
|
||||||
t.Fatalf("Evaluate() region = %#v, want %#v", result, tt.region)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVisibilityPolicySkipsOtherSysOrigin(t *testing.T) {
|
|
||||||
policy := NewVisibilityPolicy(stubVisibilityGateway{
|
|
||||||
region: integration.UserRegion{RegionID: "2046067036517363714", RegionCode: "AR"},
|
|
||||||
})
|
|
||||||
|
|
||||||
result, err := policy.Evaluate(context.Background(), AuthUser{UserID: 1001, SysOrigin: "ASWAT", RegionID: "r-1", RegionCode: "SA"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Evaluate() error = %v", err)
|
|
||||||
}
|
|
||||||
if !result.Allow {
|
|
||||||
t.Fatalf("Evaluate() allow = false, want true")
|
|
||||||
}
|
|
||||||
if result.RegionID != "r-1" || result.RegionCode != "SA" {
|
|
||||||
t.Fatalf("Evaluate() region = %#v, want user-provided region", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVisibilityPolicyDeniesInvalidUser(t *testing.T) {
|
|
||||||
policy := NewVisibilityPolicy(stubVisibilityGateway{})
|
|
||||||
|
|
||||||
result, err := policy.Evaluate(context.Background(), AuthUser{UserID: 0, SysOrigin: "LIKEI"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Evaluate() error = %v", err)
|
|
||||||
}
|
|
||||||
if result.Allow {
|
|
||||||
t.Fatalf("Evaluate() allow = true, want false for invalid user")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,315 +0,0 @@
|
|||||||
package hotgame
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/common"
|
|
||||||
"chatapp3-golang/internal/service/gameprovider"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Key 返回统一厂商标识,和后台游戏来源 HOTGAME 保持一致。
|
|
||||||
func (p *AppProvider) Key() string {
|
|
||||||
return vendorType
|
|
||||||
}
|
|
||||||
|
|
||||||
// DisplayName 返回后台和调试接口展示名。
|
|
||||||
func (p *AppProvider) DisplayName() string {
|
|
||||||
return displayName
|
|
||||||
}
|
|
||||||
|
|
||||||
// SupportsLaunch 判断统一启动请求是否命中热游配置。
|
|
||||||
func (p *AppProvider) SupportsLaunch(ctx context.Context, user gameprovider.AuthUser, req gameprovider.LaunchRequest) (bool, error) {
|
|
||||||
_, err := p.service.findGameRow(ctx, user.SysOrigin, req)
|
|
||||||
if err == nil {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
var appErr *common.AppError
|
|
||||||
if errors.As(err, &appErr) && appErr.Code == "game_not_found" {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListShortcutGames 返回统一快捷列表,热游沿用完整列表前 5 个。
|
|
||||||
func (p *AppProvider) ListShortcutGames(ctx context.Context, user gameprovider.AuthUser, roomID string) ([]gameprovider.RoomGameListItem, error) {
|
|
||||||
resp, err := p.ListRoomGames(ctx, user, roomID, "")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(resp.Items) > 5 {
|
|
||||||
resp.Items = resp.Items[:5]
|
|
||||||
}
|
|
||||||
return resp.Items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListRoomGames 返回用户当前区域可见的热游房间游戏列表。
|
|
||||||
func (p *AppProvider) ListRoomGames(ctx context.Context, user gameprovider.AuthUser, roomID, category string) (*gameprovider.RoomGameListResponse, error) {
|
|
||||||
items, err := p.service.listRoomGames(ctx, user.SysOrigin, user.RegionID, category)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &gameprovider.RoomGameListResponse{Items: items}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRoomState 热游 H5 不需要 Go 持久化房间状态,统一入口只返回空闲态。
|
|
||||||
func (p *AppProvider) GetRoomState(ctx context.Context, user gameprovider.AuthUser, roomID string) (*gameprovider.RoomStateResponse, error) {
|
|
||||||
return &gameprovider.RoomStateResponse{RoomID: roomID, State: "IDLE", Provider: vendorType}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// LaunchGame 生成热游 H5 URL,uid/token/lang 会被替换进热游提供的启动链接。
|
|
||||||
func (p *AppProvider) LaunchGame(ctx context.Context, user gameprovider.AuthUser, req gameprovider.LaunchRequest, clientIP string) (*gameprovider.LaunchResponse, error) {
|
|
||||||
row, err := p.service.findGameRow(ctx, user.SysOrigin, req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
runtimeCfg, err := p.service.resolveRuntimeConfigForProfile(ctx, normalizeSysOrigin(user.SysOrigin), row.Profile)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
entryURL := buildLaunchURL(row.entryURL(), runtimeCfg, user, req)
|
|
||||||
sessionID := fmt.Sprintf("hg_%d_%s", user.UserID, row.VendorGameID)
|
|
||||||
return &gameprovider.LaunchResponse{
|
|
||||||
ID: row.ConfigID,
|
|
||||||
GameSessionID: sessionID,
|
|
||||||
Provider: vendorType,
|
|
||||||
GameID: row.InternalGameID,
|
|
||||||
ProviderGameID: row.VendorGameID,
|
|
||||||
Entry: gameprovider.LaunchEntry{
|
|
||||||
LaunchMode: defaultIfBlank(row.LaunchMode, launchModeH5),
|
|
||||||
EntryURL: entryURL,
|
|
||||||
PreviewURL: row.previewURL(),
|
|
||||||
DownloadURL: row.entryURL(),
|
|
||||||
PackageVersion: row.packageVersion(),
|
|
||||||
Orientation: row.orientation(),
|
|
||||||
SafeHeight: row.safeHeight(),
|
|
||||||
},
|
|
||||||
LaunchConfig: map[string]any{
|
|
||||||
"uid": launchUID(runtimeCfg, user),
|
|
||||||
"token": launchToken(runtimeCfg, user),
|
|
||||||
"lang": launchLang(req),
|
|
||||||
"gameId": row.VendorGameID,
|
|
||||||
},
|
|
||||||
RoomState: gameprovider.RoomStateResponse{
|
|
||||||
RoomID: req.RoomID,
|
|
||||||
State: "PLAYING",
|
|
||||||
Provider: vendorType,
|
|
||||||
GameSessionID: sessionID,
|
|
||||||
CurrentGameID: row.InternalGameID,
|
|
||||||
CurrentProviderGameID: row.VendorGameID,
|
|
||||||
CurrentGameName: row.name(),
|
|
||||||
CurrentGameCover: row.cover(),
|
|
||||||
HostUserID: user.UserID,
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CloseGame 热游关闭只影响客户端 WebView,Go 侧不保留会话状态。
|
|
||||||
func (p *AppProvider) CloseGame(ctx context.Context, user gameprovider.AuthUser, req gameprovider.CloseRequest) (*gameprovider.RoomStateResponse, error) {
|
|
||||||
return &gameprovider.RoomStateResponse{RoomID: req.RoomID, State: "IDLE", Provider: vendorType}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) listRoomGames(ctx context.Context, sysOrigin, regionID, category string) ([]gameprovider.RoomGameListItem, error) {
|
|
||||||
sysOrigin = normalizeSysOrigin(sysOrigin)
|
|
||||||
profile, err := s.activeProfile(ctx, sysOrigin)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var rows []gameRow
|
|
||||||
query := s.baseGameQuery(ctx, sysOrigin, profile).
|
|
||||||
Where("cfg.sys_origin = ? AND cfg.is_showcase = 1 AND ext.vendor_type = ?", sysOrigin, vendorType)
|
|
||||||
if strings.TrimSpace(regionID) != "" {
|
|
||||||
// 统一游戏列表按运营配置的区域 ID 过滤;空 regions 表示所有区域都可见。
|
|
||||||
query = query.Where("(COALESCE(TRIM(cfg.regions), '') = '' OR FIND_IN_SET(?, REPLACE(COALESCE(cfg.regions, ''), ' ', '')) > 0)", strings.TrimSpace(regionID))
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(category) != "" && !strings.EqualFold(strings.TrimSpace(category), roomCategory) {
|
|
||||||
query = query.Where("cfg.category = ?", category)
|
|
||||||
}
|
|
||||||
if err := query.Order("cfg.sort DESC").Scan(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items := make([]gameprovider.RoomGameListItem, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
items = append(items, row.toListItem())
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) findGameRow(ctx context.Context, sysOrigin string, req gameprovider.LaunchRequest) (gameRow, error) {
|
|
||||||
sysOrigin = normalizeSysOrigin(sysOrigin)
|
|
||||||
profile, err := s.activeProfile(ctx, sysOrigin)
|
|
||||||
if err != nil {
|
|
||||||
return gameRow{}, err
|
|
||||||
}
|
|
||||||
var row gameRow
|
|
||||||
query := s.baseGameQuery(ctx, sysOrigin, profile).
|
|
||||||
Where("cfg.sys_origin = ? AND ext.vendor_type = ?", sysOrigin, vendorType)
|
|
||||||
if req.ID > 0 {
|
|
||||||
query = query.Where("cfg.id = ?", req.ID)
|
|
||||||
} else {
|
|
||||||
query = query.Where("cfg.game_id = ? OR ext.vendor_game_id = ?", strings.TrimSpace(req.GameID), strings.TrimSpace(req.GameID))
|
|
||||||
}
|
|
||||||
if err := query.Limit(1).Scan(&row).Error; err != nil {
|
|
||||||
return gameRow{}, err
|
|
||||||
}
|
|
||||||
if row.ConfigID > 0 {
|
|
||||||
return row, nil
|
|
||||||
}
|
|
||||||
return gameRow{}, common.NewAppError(http.StatusNotFound, "game_not_found", "hotgame game config not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) baseGameQuery(ctx context.Context, sysOrigin, profile string) *gorm.DB {
|
|
||||||
return s.db.WithContext(ctx).
|
|
||||||
Table("sys_game_list_config AS cfg").
|
|
||||||
Select(`
|
|
||||||
cfg.id AS config_id,
|
|
||||||
cfg.sys_origin AS sys_origin,
|
|
||||||
ext.profile AS profile,
|
|
||||||
cfg.game_id AS internal_game_id,
|
|
||||||
cfg.name AS name,
|
|
||||||
cfg.category AS category,
|
|
||||||
cfg.cover AS cover,
|
|
||||||
cfg.sort AS sort,
|
|
||||||
cfg.full_screen AS full_screen,
|
|
||||||
ext.vendor_game_id AS vendor_game_id,
|
|
||||||
ext.launch_mode AS launch_mode,
|
|
||||||
ext.package_version AS ext_package_version,
|
|
||||||
ext.preview_url AS ext_preview_url,
|
|
||||||
ext.package_url AS package_url,
|
|
||||||
ext.orientation AS ext_orientation,
|
|
||||||
ext.safe_height AS ext_safe_height,
|
|
||||||
ext.extra_json AS ext_extra_json,
|
|
||||||
cat.name AS catalog_name,
|
|
||||||
cat.cover AS catalog_cover,
|
|
||||||
cat.preview_url AS catalog_preview_url,
|
|
||||||
cat.download_url AS catalog_download_url,
|
|
||||||
cat.package_version AS catalog_package_version,
|
|
||||||
cat.status AS catalog_status
|
|
||||||
`).
|
|
||||||
Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1 AND ext.profile = ?", profile).
|
|
||||||
Joins("LEFT JOIN hotgame_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND cat.profile = ext.profile AND cat.vendor_game_id = ext.vendor_game_id")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r gameRow) toListItem() gameprovider.RoomGameListItem {
|
|
||||||
return gameprovider.RoomGameListItem{
|
|
||||||
ID: r.ConfigID,
|
|
||||||
GameID: r.InternalGameID,
|
|
||||||
GameType: vendorType,
|
|
||||||
Provider: vendorType,
|
|
||||||
ProviderGameID: r.VendorGameID,
|
|
||||||
Name: r.name(),
|
|
||||||
Cover: r.cover(),
|
|
||||||
Category: r.Category,
|
|
||||||
Sort: r.Sort,
|
|
||||||
LaunchMode: defaultIfBlank(r.LaunchMode, launchModeH5),
|
|
||||||
FullScreen: r.FullScreen,
|
|
||||||
GameMode: 3,
|
|
||||||
SafeHeight: r.safeHeight(),
|
|
||||||
Orientation: r.orientation(),
|
|
||||||
PackageVersion: r.packageVersion(),
|
|
||||||
Status: defaultIfBlank(r.CatalogStatus, catalogEnabled),
|
|
||||||
LaunchParams: map[string]any{
|
|
||||||
"gameType": vendorType,
|
|
||||||
"lang": defaultLaunchLang,
|
|
||||||
"screenMode": defaultIfBlank(catalogRawMode(r.ExtExtraJSON), "seven"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r gameRow) name() string {
|
|
||||||
return defaultIfBlank(r.Name, r.CatalogName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r gameRow) cover() string {
|
|
||||||
// 热游的 preview_url 是带用户占位符的 H5 启动地址,不是图片资源;封面为空时直接返回空值,避免客户端把启动页当图片加载。
|
|
||||||
return defaultIfBlank(r.Cover, r.CatalogCover)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r gameRow) previewURL() string {
|
|
||||||
return defaultIfBlank(r.ExtPreviewURL, r.CatalogPreviewURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r gameRow) entryURL() string {
|
|
||||||
return defaultIfBlank(r.PackageURL, defaultIfBlank(r.CatalogDownloadURL, r.ExtPreviewURL))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r gameRow) packageVersion() string {
|
|
||||||
return defaultIfBlank(r.ExtPackageVersion, r.CatalogPackageVersion)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r gameRow) orientation() int {
|
|
||||||
if r.ExtOrientation != nil {
|
|
||||||
return *r.ExtOrientation
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r gameRow) safeHeight() int {
|
|
||||||
return r.ExtSafeHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildLaunchURL(entry string, cfg runtimeConfig, user gameprovider.AuthUser, req gameprovider.LaunchRequest) string {
|
|
||||||
entry = strings.TrimSpace(entry)
|
|
||||||
uid := launchUID(cfg, user)
|
|
||||||
token := launchToken(cfg, user)
|
|
||||||
lang := launchLang(req)
|
|
||||||
replaced := strings.NewReplacer(
|
|
||||||
"{uid}", url.QueryEscape(uid),
|
|
||||||
"{token}", url.QueryEscape(token),
|
|
||||||
"{lang}", url.QueryEscape(lang),
|
|
||||||
).Replace(entry)
|
|
||||||
if replaced != entry {
|
|
||||||
return replaced
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, err := url.Parse(entry)
|
|
||||||
if err != nil {
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
query := parsed.Query()
|
|
||||||
query.Set("uid", uid)
|
|
||||||
query.Set("token", token)
|
|
||||||
query.Set("lang", lang)
|
|
||||||
parsed.RawQuery = query.Encode()
|
|
||||||
return parsed.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func launchUID(cfg runtimeConfig, user gameprovider.AuthUser) string {
|
|
||||||
return defaultIfBlank(cfg.TestUID, strconv.FormatInt(user.UserID, 10))
|
|
||||||
}
|
|
||||||
|
|
||||||
func launchToken(cfg runtimeConfig, user gameprovider.AuthUser) string {
|
|
||||||
return defaultIfBlank(cfg.TestToken, user.Token)
|
|
||||||
}
|
|
||||||
|
|
||||||
func launchLang(req gameprovider.LaunchRequest) string {
|
|
||||||
for _, key := range []string{"lang", "language", "locale"} {
|
|
||||||
if req.Params == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if value := strings.TrimSpace(fmt.Sprint(req.Params[key])); value != "" && value != "<nil>" {
|
|
||||||
return normalizeLaunchLang(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return defaultLaunchLang
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeLaunchLang(value string) string {
|
|
||||||
value = strings.ToLower(strings.TrimSpace(value))
|
|
||||||
if strings.Contains(value, "-") {
|
|
||||||
value = strings.SplitN(value, "-", 2)[0]
|
|
||||||
}
|
|
||||||
switch value {
|
|
||||||
case "en", "ar", "id", "tr", "ur", "vi":
|
|
||||||
return value
|
|
||||||
default:
|
|
||||||
return defaultLaunchLang
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,446 +0,0 @@
|
|||||||
package hotgame
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
"chatapp3-golang/internal/utils"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
|
||||||
|
|
||||||
var defaultCatalogGames = []defaultCatalogGame{
|
|
||||||
{
|
|
||||||
GameID: "1",
|
|
||||||
Name: "幸运77(Lucky77)",
|
|
||||||
TestURL: "https://hk.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}",
|
|
||||||
ProdURL: "https://cdn-res.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
GameID: "13",
|
|
||||||
Name: "海盗捕鱼(PirateFishing)",
|
|
||||||
TestURL: "https://hk.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
|
|
||||||
ProdURL: "https://cdn-res.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
GameID: "19",
|
|
||||||
Name: "FortuneSlot (宝石solt)",
|
|
||||||
TestURL: "https://hk.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
|
|
||||||
ProdURL: "https://cdn-res.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
GameID: "15",
|
|
||||||
Name: "美味摩天轮(Delicious)",
|
|
||||||
TestURL: "https://hk.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
|
|
||||||
ProdURL: "https://cdn-res.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
GameID: "20",
|
|
||||||
Name: "水果派对(FruitParty)",
|
|
||||||
TestURL: "https://hk.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
|
|
||||||
ProdURL: "https://cdn-res.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
GameID: "16",
|
|
||||||
Name: "太空摩天轮(GreedyStar)",
|
|
||||||
TestURL: "https://hk.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
|
|
||||||
ProdURL: "https://cdn-res.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncCatalog 把表格里确认过的热游可用游戏同步到本地目录表。
|
|
||||||
func (s *Service) SyncCatalog(ctx context.Context, req SyncCatalogRequest) (*SyncCatalogResponse, error) {
|
|
||||||
sysOrigin := normalizeSysOrigin(req.SysOrigin)
|
|
||||||
profile, err := s.requestProfile(ctx, sysOrigin, req.Profile)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if _, err := s.requireRuntimeConfigForProfile(ctx, sysOrigin, profile); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
filter := buildVendorGameIDFilter(req.VendorGameIDs)
|
|
||||||
resp := &SyncCatalogResponse{}
|
|
||||||
for _, game := range defaultCatalogGames {
|
|
||||||
vendorGameID := strings.TrimSpace(game.GameID)
|
|
||||||
if vendorGameID == "" {
|
|
||||||
resp.Skipped++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(filter) > 0 {
|
|
||||||
if _, ok := filter[vendorGameID]; !ok {
|
|
||||||
resp.Skipped++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
id, err := utils.NextID()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
launchURL := hotgameLaunchURLForProfile(game, profile)
|
|
||||||
record := model.HotgameGameCatalog{
|
|
||||||
ID: id,
|
|
||||||
SysOrigin: sysOrigin,
|
|
||||||
Profile: profile,
|
|
||||||
InternalGameID: internalGameID(vendorGameID),
|
|
||||||
VendorGameID: vendorGameID,
|
|
||||||
Name: game.Name,
|
|
||||||
Cover: "",
|
|
||||||
PreviewURL: launchURL,
|
|
||||||
DownloadURL: launchURL,
|
|
||||||
PackageVersion: "2026",
|
|
||||||
RawJSON: utils.MustJSONString(game, ""),
|
|
||||||
Status: catalogEnabled,
|
|
||||||
CreateTime: now,
|
|
||||||
UpdateTime: now,
|
|
||||||
}
|
|
||||||
if err := s.db.WithContext(ctx).Clauses(clause.OnConflict{
|
|
||||||
Columns: []clause.Column{{Name: "sys_origin"}, {Name: "profile"}, {Name: "vendor_game_id"}},
|
|
||||||
DoUpdates: clause.AssignmentColumns([]string{
|
|
||||||
"internal_game_id", "name", "cover", "preview_url", "download_url",
|
|
||||||
"package_version", "raw_json", "status", "update_time",
|
|
||||||
}),
|
|
||||||
}).Create(&record).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if req.Force {
|
|
||||||
resp.Updated++
|
|
||||||
} else {
|
|
||||||
resp.Inserted++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PageCatalog 返回热游目录,并标记每个目录游戏是否已经进入统一游戏列表。
|
|
||||||
func (s *Service) PageCatalog(ctx context.Context, sysOrigin, profile, keyword string, cursor, limit int) (*CatalogPageResponse, error) {
|
|
||||||
sysOrigin = normalizeSysOrigin(sysOrigin)
|
|
||||||
profile, err := s.requestProfile(ctx, sysOrigin, profile)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
cursor = normalizePageCursor(cursor)
|
|
||||||
limit = normalizePageLimit(limit)
|
|
||||||
|
|
||||||
countQuery := s.buildCatalogQuery(ctx, sysOrigin, profile, keyword)
|
|
||||||
var total int64
|
|
||||||
if err := countQuery.Count(&total).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
type row struct {
|
|
||||||
VendorGameID string
|
|
||||||
Profile string
|
|
||||||
InternalGameID string
|
|
||||||
Name string
|
|
||||||
Cover string
|
|
||||||
PreviewURL string
|
|
||||||
DownloadURL string
|
|
||||||
PackageVersion string
|
|
||||||
Status string
|
|
||||||
AddedConfigID *int64
|
|
||||||
Showcase *bool
|
|
||||||
UpdateTime string
|
|
||||||
}
|
|
||||||
var rows []row
|
|
||||||
if err := s.buildCatalogQuery(ctx, sysOrigin, profile, keyword).
|
|
||||||
Select(`
|
|
||||||
cat.vendor_game_id AS vendor_game_id,
|
|
||||||
cat.profile AS profile,
|
|
||||||
cat.internal_game_id AS internal_game_id,
|
|
||||||
cat.name AS name,
|
|
||||||
cat.cover AS cover,
|
|
||||||
cat.preview_url AS preview_url,
|
|
||||||
cat.download_url AS download_url,
|
|
||||||
cat.package_version AS package_version,
|
|
||||||
cat.status AS status,
|
|
||||||
cfg.id AS added_config_id,
|
|
||||||
cfg.is_showcase AS showcase,
|
|
||||||
DATE_FORMAT(cat.update_time, '%Y-%m-%d %H:%i:%s') AS update_time
|
|
||||||
`).
|
|
||||||
Order("CAST(cat.vendor_game_id AS UNSIGNED) ASC").
|
|
||||||
Limit(limit).
|
|
||||||
Offset((cursor - 1) * limit).
|
|
||||||
Scan(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]CatalogItem, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
items = append(items, CatalogItem{
|
|
||||||
VendorGameID: row.VendorGameID,
|
|
||||||
Profile: normalizeProfile(row.Profile),
|
|
||||||
GameID: row.InternalGameID,
|
|
||||||
Name: row.Name,
|
|
||||||
Cover: row.Cover,
|
|
||||||
PreviewURL: row.PreviewURL,
|
|
||||||
DownloadURL: row.DownloadURL,
|
|
||||||
PackageVersion: row.PackageVersion,
|
|
||||||
Status: row.Status,
|
|
||||||
Added: row.AddedConfigID != nil && *row.AddedConfigID > 0,
|
|
||||||
Showcase: row.Showcase != nil && *row.Showcase,
|
|
||||||
UpdateTime: row.UpdateTime,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return &CatalogPageResponse{Cursor: cursor, Limit: limit, Records: items, Total: total}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) buildCatalogQuery(ctx context.Context, sysOrigin, profile, keyword string) *gorm.DB {
|
|
||||||
query := s.db.WithContext(ctx).
|
|
||||||
Table("hotgame_game_catalog AS cat").
|
|
||||||
Joins(`
|
|
||||||
LEFT JOIN sys_game_list_vendor_ext ext
|
|
||||||
ON ext.sys_origin = cat.sys_origin
|
|
||||||
AND ext.profile = cat.profile
|
|
||||||
AND ext.vendor_type = ?
|
|
||||||
AND ext.vendor_game_id = cat.vendor_game_id
|
|
||||||
AND ext.enabled = 1
|
|
||||||
`, vendorType).
|
|
||||||
Joins(`
|
|
||||||
LEFT JOIN sys_game_list_config cfg
|
|
||||||
ON cfg.id = ext.game_list_config_id
|
|
||||||
AND cfg.sys_origin = cat.sys_origin
|
|
||||||
AND cfg.game_origin = ?
|
|
||||||
`, vendorType).
|
|
||||||
Where("cat.sys_origin = ? AND cat.profile = ?", sysOrigin, profile)
|
|
||||||
keyword = strings.TrimSpace(keyword)
|
|
||||||
if keyword != "" {
|
|
||||||
like := "%" + keyword + "%"
|
|
||||||
query = query.Where("cat.name LIKE ? OR cat.internal_game_id LIKE ? OR cat.vendor_game_id LIKE ?", like, like, like)
|
|
||||||
}
|
|
||||||
return query
|
|
||||||
}
|
|
||||||
|
|
||||||
// FetchAdminCatalog 供后台按钮调用,热游没有远程目录接口,因此读取内置对接表目录。
|
|
||||||
func (s *Service) FetchAdminCatalog(ctx context.Context, req SyncCatalogRequest) (*SyncCatalogResponse, error) {
|
|
||||||
return s.SyncCatalog(ctx, req)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImportCatalogGames 把热游目录导入统一游戏列表,app 侧列表随后会通过统一 provider 返回。
|
|
||||||
func (s *Service) ImportCatalogGames(ctx context.Context, sysOrigin, profile string, vendorGameIDs []string, defaultShowcase ...bool) (*ImportCatalogResponse, error) {
|
|
||||||
sysOrigin = normalizeSysOrigin(sysOrigin)
|
|
||||||
profile, err := s.requestProfile(ctx, sysOrigin, profile)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
tx := s.db.WithContext(ctx).Begin()
|
|
||||||
if tx.Error != nil {
|
|
||||||
return nil, tx.Error
|
|
||||||
}
|
|
||||||
resp, err := s.importCatalogGamesTx(tx, sysOrigin, profile, buildVendorGameIDFilter(vendorGameIDs), resolveImportDefaultShowcase(defaultShowcase...))
|
|
||||||
if err != nil {
|
|
||||||
tx.Rollback()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := tx.Commit().Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s.clearJavaGameListCache(ctx)
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncAdminGames 一次完成目录同步和统一游戏列表导入,便于后台首次配置。
|
|
||||||
func (s *Service) SyncAdminGames(ctx context.Context, req SyncCatalogRequest) (*SyncAdminCatalogResponse, error) {
|
|
||||||
syncResp, err := s.SyncCatalog(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
importResp, err := s.ImportCatalogGames(ctx, req.SysOrigin, req.Profile, req.VendorGameIDs, resolveBoolPtrDefault(req.DefaultShowcase, true))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &SyncAdminCatalogResponse{
|
|
||||||
Inserted: syncResp.Inserted,
|
|
||||||
Updated: syncResp.Updated,
|
|
||||||
Skipped: syncResp.Skipped,
|
|
||||||
Existing: importResp.Existing,
|
|
||||||
Imported: importResp.Imported,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) importCatalogGamesTx(tx *gorm.DB, sysOrigin, profile string, filter map[string]struct{}, defaultShowcase bool) (*ImportCatalogResponse, error) {
|
|
||||||
query := tx.Where("sys_origin = ? AND profile = ? AND status = ?", sysOrigin, profile, catalogEnabled)
|
|
||||||
if len(filter) > 0 {
|
|
||||||
ids := make([]string, 0, len(filter))
|
|
||||||
for id := range filter {
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
sort.Strings(ids)
|
|
||||||
query = query.Where("vendor_game_id IN ?", ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
var catalogs []model.HotgameGameCatalog
|
|
||||||
if err := query.Order("CAST(vendor_game_id AS UNSIGNED) ASC").Find(&catalogs).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := &ImportCatalogResponse{}
|
|
||||||
for _, catalog := range catalogs {
|
|
||||||
vendorGameID := strings.TrimSpace(catalog.VendorGameID)
|
|
||||||
if vendorGameID == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
fullScreen := false
|
|
||||||
var ext model.SysGameListVendorExt
|
|
||||||
err := tx.Where("sys_origin = ? AND profile = ? AND vendor_type = ? AND vendor_game_id = ?", sysOrigin, profile, vendorType, vendorGameID).First(&ext).Error
|
|
||||||
if err != nil && err != gorm.ErrRecordNotFound {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err == nil && ext.GameListConfigID > 0 {
|
|
||||||
if err := s.refreshExistingGame(tx, ext.GameListConfigID, catalog, fullScreen); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
resp.Existing++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
nextCfgID, idErr := utils.NextID()
|
|
||||||
if idErr != nil {
|
|
||||||
return nil, idErr
|
|
||||||
}
|
|
||||||
cfg := model.SysGameListConfig{
|
|
||||||
ID: nextCfgID,
|
|
||||||
SysOrigin: sysOrigin,
|
|
||||||
GameOrigin: vendorType,
|
|
||||||
GameID: defaultIfBlank(catalog.InternalGameID, vendorGameID),
|
|
||||||
Name: catalog.Name,
|
|
||||||
Category: roomCategory,
|
|
||||||
GameCode: vendorGameID,
|
|
||||||
// 热游启动地址包含 uid/token/lang 占位符且整体很长,不能在缺少封面时回填到 cover;封面为空时交给客户端展示默认占位,真正启动地址只放在扩展表里。
|
|
||||||
Cover: hotgameCatalogCover(catalog),
|
|
||||||
Showcase: defaultShowcase,
|
|
||||||
Sort: parseSort(vendorGameID),
|
|
||||||
FullScreen: fullScreen,
|
|
||||||
ClientOrigin: "COMMON",
|
|
||||||
GameMode: "[3]",
|
|
||||||
}
|
|
||||||
if err := tx.Create(&cfg).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
nextID, idErr := utils.NextID()
|
|
||||||
if idErr != nil {
|
|
||||||
return nil, idErr
|
|
||||||
}
|
|
||||||
newExt := model.SysGameListVendorExt{
|
|
||||||
ID: nextID,
|
|
||||||
GameListConfigID: cfg.ID,
|
|
||||||
SysOrigin: sysOrigin,
|
|
||||||
Profile: profile,
|
|
||||||
VendorType: vendorType,
|
|
||||||
VendorGameID: vendorGameID,
|
|
||||||
LaunchMode: launchModeH5,
|
|
||||||
PackageVersion: catalog.PackageVersion,
|
|
||||||
PackageURL: catalog.DownloadURL,
|
|
||||||
PreviewURL: catalog.PreviewURL,
|
|
||||||
BridgeSchemaVersion: "1.0",
|
|
||||||
ExtraJSON: `{"launchParams":{"gameType":"HOTGAME","screenMode":"seven"}}`,
|
|
||||||
Enabled: true,
|
|
||||||
CreateTime: now,
|
|
||||||
UpdateTime: now,
|
|
||||||
}
|
|
||||||
if err := tx.Create(&newExt).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
resp.Imported++
|
|
||||||
}
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) refreshExistingGame(tx *gorm.DB, configID int64, catalog model.HotgameGameCatalog, fullScreen bool) error {
|
|
||||||
// 目录链接来自热游正式表格,重新同步时要同时刷新主表展示信息和扩展表启动入口,避免旧 URL 继续下发给 app。
|
|
||||||
if err := tx.Model(&model.SysGameListConfig{}).
|
|
||||||
Where("id = ? AND game_origin = ?", configID, vendorType).
|
|
||||||
Updates(map[string]any{
|
|
||||||
"name": catalog.Name,
|
|
||||||
// 这里和新导入保持一致,只刷新真实封面,避免把 H5 启动链接写入封面列导致 MySQL 长度错误。
|
|
||||||
"cover": hotgameCatalogCover(catalog),
|
|
||||||
"full_screen": fullScreen,
|
|
||||||
}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tx.Model(&model.SysGameListVendorExt{}).
|
|
||||||
Where("game_list_config_id = ? AND vendor_type = ?", configID, vendorType).
|
|
||||||
Updates(map[string]any{
|
|
||||||
"package_version": catalog.PackageVersion,
|
|
||||||
"package_url": catalog.DownloadURL,
|
|
||||||
"preview_url": catalog.PreviewURL,
|
|
||||||
"extra_json": `{"launchParams":{"gameType":"HOTGAME","screenMode":"seven"}}`,
|
|
||||||
"update_time": time.Now(),
|
|
||||||
}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func hotgameLaunchURLForProfile(game defaultCatalogGame, profile string) string {
|
|
||||||
if normalizeProfile(profile) == profileTest {
|
|
||||||
return strings.TrimSpace(game.TestURL)
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(game.ProdURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func hotgameCatalogCover(catalog model.HotgameGameCatalog) string {
|
|
||||||
return strings.TrimSpace(catalog.Cover)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveImportDefaultShowcase(values ...bool) bool {
|
|
||||||
if len(values) == 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return values[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveBoolPtrDefault(value *bool, fallback bool) bool {
|
|
||||||
if value == nil {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
return *value
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildVendorGameIDFilter(vendorGameIDs []string) map[string]struct{} {
|
|
||||||
filter := make(map[string]struct{}, len(vendorGameIDs))
|
|
||||||
for _, id := range vendorGameIDs {
|
|
||||||
id = strings.TrimSpace(id)
|
|
||||||
if id != "" {
|
|
||||||
filter[id] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return filter
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizePageCursor(cursor int) int {
|
|
||||||
if cursor <= 0 {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return cursor
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizePageLimit(limit int) int {
|
|
||||||
if limit <= 0 {
|
|
||||||
return 20
|
|
||||||
}
|
|
||||||
if limit > 200 {
|
|
||||||
return 200
|
|
||||||
}
|
|
||||||
return limit
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseSort(value string) int64 {
|
|
||||||
var sortValue int64
|
|
||||||
_, _ = fmt.Sscanf(strings.TrimSpace(value), "%d", &sortValue)
|
|
||||||
return sortValue
|
|
||||||
}
|
|
||||||
|
|
||||||
func catalogRawMode(rawJSON string) string {
|
|
||||||
var raw struct {
|
|
||||||
Mode string `json:"mode"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal([]byte(strings.TrimSpace(rawJSON)), &raw)
|
|
||||||
return strings.TrimSpace(raw.Mode)
|
|
||||||
}
|
|
||||||
@ -1,135 +0,0 @@
|
|||||||
package hotgame
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/config"
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
"chatapp3-golang/internal/service/gameprovider"
|
|
||||||
|
|
||||||
"gorm.io/driver/sqlite"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSyncImportAndListRoomGames(t *testing.T) {
|
|
||||||
db := newHotgameTestDB(t)
|
|
||||||
service := NewService(config.Config{}, db, nil)
|
|
||||||
|
|
||||||
resp, err := service.SyncCatalog(context.Background(), SyncCatalogRequest{
|
|
||||||
SysOrigin: "LIKEI",
|
|
||||||
Profile: "PROD",
|
|
||||||
VendorGameIDs: []string{"1"},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SyncCatalog() error = %v", err)
|
|
||||||
}
|
|
||||||
if resp.Inserted != 1 {
|
|
||||||
t.Fatalf("SyncCatalog() inserted = %d, want 1", resp.Inserted)
|
|
||||||
}
|
|
||||||
|
|
||||||
importResp, err := service.ImportCatalogGames(context.Background(), "LIKEI", "PROD", []string{"1"}, true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ImportCatalogGames() error = %v", err)
|
|
||||||
}
|
|
||||||
if importResp.Imported != 1 {
|
|
||||||
t.Fatalf("ImportCatalogGames() imported = %d, want 1", importResp.Imported)
|
|
||||||
}
|
|
||||||
|
|
||||||
var cfg model.SysGameListConfig
|
|
||||||
if err := db.Where("game_origin = ? AND game_id = ?", vendorType, "hg_1").First(&cfg).Error; err != nil {
|
|
||||||
t.Fatalf("load imported game config: %v", err)
|
|
||||||
}
|
|
||||||
if cfg.Cover != "" {
|
|
||||||
t.Fatalf("imported cover = %q, want blank because launch url must stay out of cover", cfg.Cover)
|
|
||||||
}
|
|
||||||
|
|
||||||
var ext model.SysGameListVendorExt
|
|
||||||
if err := db.Where("game_list_config_id = ? AND vendor_type = ?", cfg.ID, vendorType).First(&ext).Error; err != nil {
|
|
||||||
t.Fatalf("load imported vendor ext: %v", err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(ext.PreviewURL, "uid={uid}") || !strings.Contains(ext.PackageURL, "token={token}") {
|
|
||||||
t.Fatalf("vendor ext lost launch url placeholders: preview=%q package=%q", ext.PreviewURL, ext.PackageURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
provider := NewAppProvider(service)
|
|
||||||
list, err := provider.ListRoomGames(context.Background(), gameprovider.AuthUser{UserID: 1234567, SysOrigin: "LIKEI"}, "room-1", "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListRoomGames() error = %v", err)
|
|
||||||
}
|
|
||||||
if len(list.Items) != 1 {
|
|
||||||
t.Fatalf("ListRoomGames() items = %d, want 1", len(list.Items))
|
|
||||||
}
|
|
||||||
item := list.Items[0]
|
|
||||||
if item.Provider != vendorType || item.ProviderGameID != "1" || item.GameID != "hg_1" {
|
|
||||||
t.Fatalf("ListRoomGames() item = %+v", item)
|
|
||||||
}
|
|
||||||
if item.Cover != "" {
|
|
||||||
t.Fatalf("ListRoomGames() cover = %q, want blank because hotgame preview url is not an image", item.Cover)
|
|
||||||
}
|
|
||||||
if item.LaunchParams["screenMode"] != "seven" {
|
|
||||||
t.Fatalf("ListRoomGames() screenMode = %v, want seven", item.LaunchParams["screenMode"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLaunchGameReplacesHotgameURLParams(t *testing.T) {
|
|
||||||
db := newHotgameTestDB(t)
|
|
||||||
service := NewService(config.Config{}, db, nil)
|
|
||||||
if _, err := service.SyncAdminGames(context.Background(), SyncCatalogRequest{
|
|
||||||
SysOrigin: "LIKEI",
|
|
||||||
Profile: "PROD",
|
|
||||||
VendorGameIDs: []string{"1"},
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("SyncAdminGames() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
provider := NewAppProvider(service)
|
|
||||||
resp, err := provider.LaunchGame(
|
|
||||||
context.Background(),
|
|
||||||
gameprovider.AuthUser{UserID: 1234567, SysOrigin: "LIKEI", Token: "token=="},
|
|
||||||
gameprovider.LaunchRequest{RoomID: "room-1", GameID: "hg_1", Params: map[string]any{"lang": "tr-TR"}},
|
|
||||||
"127.0.0.1",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LaunchGame() error = %v", err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(resp.Entry.EntryURL, "uid=1234567") {
|
|
||||||
t.Fatalf("LaunchGame() url missing uid: %s", resp.Entry.EntryURL)
|
|
||||||
}
|
|
||||||
if !strings.Contains(resp.Entry.EntryURL, "token=token%3D%3D") {
|
|
||||||
t.Fatalf("LaunchGame() url missing escaped token: %s", resp.Entry.EntryURL)
|
|
||||||
}
|
|
||||||
if !strings.Contains(resp.Entry.EntryURL, "lang=tr") {
|
|
||||||
t.Fatalf("LaunchGame() url missing lang: %s", resp.Entry.EntryURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newHotgameTestDB(t *testing.T) *gorm.DB {
|
|
||||||
t.Helper()
|
|
||||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open sqlite: %v", err)
|
|
||||||
}
|
|
||||||
if err := db.AutoMigrate(
|
|
||||||
&model.HotgameProviderConfig{},
|
|
||||||
&model.HotgameGameCatalog{},
|
|
||||||
&model.SysGameListConfig{},
|
|
||||||
&model.SysGameListVendorExt{},
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("migrate sqlite: %v", err)
|
|
||||||
}
|
|
||||||
if err := db.Create(&model.HotgameProviderConfig{
|
|
||||||
ID: 1,
|
|
||||||
SysOrigin: "LIKEI",
|
|
||||||
Profile: "PROD",
|
|
||||||
Active: true,
|
|
||||||
AppKey: "hotgame-key",
|
|
||||||
CreateTime: time.Now(),
|
|
||||||
UpdateTime: time.Now(),
|
|
||||||
}).Error; err != nil {
|
|
||||||
t.Fatalf("create provider config: %v", err)
|
|
||||||
}
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
@ -1,203 +0,0 @@
|
|||||||
package hotgame
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/common"
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
"chatapp3-golang/internal/utils"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *Service) defaultRuntimeConfig(sysOrigin string) runtimeConfig {
|
|
||||||
return runtimeConfig{
|
|
||||||
SysOrigin: normalizeSysOrigin(sysOrigin),
|
|
||||||
Profile: profileProd,
|
|
||||||
AppKey: strings.TrimSpace(s.cfg.Hotgame.AppKey),
|
|
||||||
CallbackBaseURL: strings.TrimSpace(s.cfg.Hotgame.CallbackBaseURL),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) activeProfile(ctx context.Context, sysOrigin string) (string, error) {
|
|
||||||
sysOrigin = normalizeSysOrigin(sysOrigin)
|
|
||||||
var row model.HotgameProviderConfig
|
|
||||||
err := s.db.WithContext(ctx).
|
|
||||||
Where("sys_origin = ? AND active = ?", sysOrigin, true).
|
|
||||||
Order("update_time DESC").
|
|
||||||
Limit(1).
|
|
||||||
First(&row).Error
|
|
||||||
if err == nil {
|
|
||||||
return normalizeProfile(row.Profile), nil
|
|
||||||
}
|
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
return profileProd, nil
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) requestProfile(ctx context.Context, sysOrigin, profile string) (string, error) {
|
|
||||||
if strings.TrimSpace(profile) != "" {
|
|
||||||
return normalizeProfile(profile), nil
|
|
||||||
}
|
|
||||||
return s.activeProfile(ctx, sysOrigin)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) resolveRuntimeConfigForProfile(ctx context.Context, sysOrigin, profile string) (runtimeConfig, error) {
|
|
||||||
sysOrigin = normalizeSysOrigin(sysOrigin)
|
|
||||||
profile = normalizeProfile(profile)
|
|
||||||
base := s.defaultRuntimeConfig(sysOrigin)
|
|
||||||
base.Profile = profile
|
|
||||||
|
|
||||||
var row model.HotgameProviderConfig
|
|
||||||
err := s.db.WithContext(ctx).
|
|
||||||
Where("sys_origin = ? AND profile = ?", sysOrigin, profile).
|
|
||||||
Limit(1).
|
|
||||||
First(&row).Error
|
|
||||||
if err == nil {
|
|
||||||
return configToRuntime(base, row), nil
|
|
||||||
}
|
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
return base, nil
|
|
||||||
}
|
|
||||||
return runtimeConfig{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) requireRuntimeConfigForProfile(ctx context.Context, sysOrigin, profile string) (runtimeConfig, error) {
|
|
||||||
cfg, err := s.resolveRuntimeConfigForProfile(ctx, sysOrigin, profile)
|
|
||||||
if err != nil {
|
|
||||||
return runtimeConfig{}, err
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(cfg.AppKey) == "" {
|
|
||||||
return runtimeConfig{}, common.NewAppError(http.StatusBadRequest, "hotgame_config_missing", "hotgame appKey is required")
|
|
||||||
}
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetProviderConfig 读取某个系统、某个环境的热游配置,并附带当前启用环境。
|
|
||||||
func (s *Service) GetProviderConfig(ctx context.Context, sysOrigin, profile string) (*ProviderConfigItem, error) {
|
|
||||||
sysOrigin = normalizeSysOrigin(sysOrigin)
|
|
||||||
activeProfile, err := s.activeProfile(ctx, sysOrigin)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
profile, err = s.requestProfile(ctx, sysOrigin, profile)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
runtimeCfg, err := s.resolveRuntimeConfigForProfile(ctx, sysOrigin, profile)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var row model.HotgameProviderConfig
|
|
||||||
_ = s.db.WithContext(ctx).
|
|
||||||
Where("sys_origin = ? AND profile = ?", sysOrigin, profile).
|
|
||||||
Limit(1).
|
|
||||||
First(&row).Error
|
|
||||||
|
|
||||||
return &ProviderConfigItem{
|
|
||||||
ID: row.ID,
|
|
||||||
SysOrigin: runtimeCfg.SysOrigin,
|
|
||||||
Profile: runtimeCfg.Profile,
|
|
||||||
ActiveProfile: activeProfile,
|
|
||||||
Active: runtimeCfg.Profile == activeProfile,
|
|
||||||
AppKey: runtimeCfg.AppKey,
|
|
||||||
CallbackBaseURL: runtimeCfg.CallbackBaseURL,
|
|
||||||
TestUID: runtimeCfg.TestUID,
|
|
||||||
TestToken: runtimeCfg.TestToken,
|
|
||||||
UpdateTime: formatTime(row.UpdateTime),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SaveProviderConfig 保存热游配置;首个配置自动设为当前启用环境。
|
|
||||||
func (s *Service) SaveProviderConfig(ctx context.Context, req SaveProviderConfigRequest) (*ProviderConfigItem, error) {
|
|
||||||
sysOrigin := normalizeSysOrigin(req.SysOrigin)
|
|
||||||
profile := normalizeProfile(req.Profile)
|
|
||||||
appKey := strings.TrimSpace(req.AppKey)
|
|
||||||
if appKey == "" {
|
|
||||||
return nil, common.NewAppError(http.StatusBadRequest, "app_key_required", "appKey is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := utils.NextID()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
row := model.HotgameProviderConfig{
|
|
||||||
ID: id,
|
|
||||||
SysOrigin: sysOrigin,
|
|
||||||
Profile: profile,
|
|
||||||
AppKey: appKey,
|
|
||||||
CallbackBaseURL: strings.TrimSpace(req.CallbackBaseURL),
|
|
||||||
TestUID: strings.TrimSpace(req.TestUID),
|
|
||||||
TestToken: strings.TrimSpace(req.TestToken),
|
|
||||||
CreateTime: now,
|
|
||||||
UpdateTime: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
var activeCount int64
|
|
||||||
if err := s.db.WithContext(ctx).
|
|
||||||
Model(&model.HotgameProviderConfig{}).
|
|
||||||
Where("sys_origin = ? AND active = ?", sysOrigin, true).
|
|
||||||
Count(&activeCount).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
row.Active = activeCount == 0
|
|
||||||
|
|
||||||
if err := s.db.WithContext(ctx).Clauses(clause.OnConflict{
|
|
||||||
Columns: []clause.Column{{Name: "sys_origin"}, {Name: "profile"}},
|
|
||||||
DoUpdates: clause.AssignmentColumns([]string{
|
|
||||||
"app_key", "callback_base_url", "test_uid", "test_token", "update_time",
|
|
||||||
}),
|
|
||||||
}).Create(&row).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if row.Active {
|
|
||||||
s.clearJavaGameListCache(ctx)
|
|
||||||
}
|
|
||||||
return s.GetProviderConfig(ctx, sysOrigin, profile)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ActivateProviderProfile 切换 app 侧读取的热游环境,并清理 Java 老游戏列表缓存。
|
|
||||||
func (s *Service) ActivateProviderProfile(ctx context.Context, req ActivateProviderProfileRequest) (*ProviderConfigItem, error) {
|
|
||||||
sysOrigin := normalizeSysOrigin(req.SysOrigin)
|
|
||||||
profile := normalizeProfile(req.Profile)
|
|
||||||
var row model.HotgameProviderConfig
|
|
||||||
if err := s.db.WithContext(ctx).
|
|
||||||
Where("sys_origin = ? AND profile = ?", sysOrigin, profile).
|
|
||||||
Limit(1).
|
|
||||||
First(&row).Error; err != nil {
|
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
return nil, common.NewAppError(http.StatusBadRequest, "profile_config_missing", "profile config is missing")
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Model(&model.HotgameProviderConfig{}).
|
|
||||||
Where("sys_origin = ?", sysOrigin).
|
|
||||||
Updates(map[string]any{"active": false, "update_time": now}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tx.Model(&model.HotgameProviderConfig{}).
|
|
||||||
Where("sys_origin = ? AND profile = ?", sysOrigin, profile).
|
|
||||||
Updates(map[string]any{"active": true, "update_time": now}).Error
|
|
||||||
}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s.clearJavaGameListCache(ctx)
|
|
||||||
return s.GetProviderConfig(ctx, sysOrigin, profile)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) clearJavaGameListCache(ctx context.Context) {
|
|
||||||
if s.cache == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = s.cache.Del(ctx, "GAME_LIST").Err()
|
|
||||||
}
|
|
||||||
@ -1,235 +0,0 @@
|
|||||||
package hotgame
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/config"
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
"chatapp3-golang/internal/service/gameprovider"
|
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
vendorType = "HOTGAME"
|
|
||||||
displayName = "热游"
|
|
||||||
profileProd = "PROD"
|
|
||||||
profileTest = "TEST"
|
|
||||||
launchModeH5 = "H5_REMOTE"
|
|
||||||
catalogEnabled = "ENABLED"
|
|
||||||
roomCategory = "CHAT_ROOM"
|
|
||||||
defaultLaunchLang = "en"
|
|
||||||
)
|
|
||||||
|
|
||||||
type dbHandle interface {
|
|
||||||
WithContext(ctx context.Context) *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
// Service 负责热游后台配置、目录导入、app 列表和启动 URL 生成。
|
|
||||||
type Service struct {
|
|
||||||
cfg config.Config
|
|
||||||
db dbHandle
|
|
||||||
cache redis.Cmdable
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewService 创建热游服务;db 为空时路由仍可安全跳过。
|
|
||||||
func NewService(cfg config.Config, db dbHandle, cache redis.Cmdable) *Service {
|
|
||||||
return &Service{cfg: cfg, db: db, cache: cache}
|
|
||||||
}
|
|
||||||
|
|
||||||
// runtimeConfig 是某个系统、某个环境下实际用于启动和回调验签的配置快照。
|
|
||||||
type runtimeConfig struct {
|
|
||||||
SysOrigin string
|
|
||||||
Profile string
|
|
||||||
AppKey string
|
|
||||||
CallbackBaseURL string
|
|
||||||
TestUID string
|
|
||||||
TestToken string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProviderConfigItem 是后台热游配置页读取到的配置。
|
|
||||||
type ProviderConfigItem struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
SysOrigin string `json:"sysOrigin"`
|
|
||||||
Profile string `json:"profile"`
|
|
||||||
ActiveProfile string `json:"activeProfile"`
|
|
||||||
Active bool `json:"active"`
|
|
||||||
AppKey string `json:"appKey"`
|
|
||||||
CallbackBaseURL string `json:"callbackBaseUrl"`
|
|
||||||
TestUID string `json:"testUid"`
|
|
||||||
TestToken string `json:"testToken"`
|
|
||||||
UpdateTime string `json:"updateTime"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SaveProviderConfigRequest 是后台保存热游接入配置的入参。
|
|
||||||
type SaveProviderConfigRequest struct {
|
|
||||||
SysOrigin string `json:"sysOrigin"`
|
|
||||||
Profile string `json:"profile"`
|
|
||||||
AppKey string `json:"appKey"`
|
|
||||||
CallbackBaseURL string `json:"callbackBaseUrl"`
|
|
||||||
TestUID string `json:"testUid"`
|
|
||||||
TestToken string `json:"testToken"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ActivateProviderProfileRequest 是后台切换当前启用热游环境的入参。
|
|
||||||
type ActivateProviderProfileRequest struct {
|
|
||||||
SysOrigin string `json:"sysOrigin"`
|
|
||||||
Profile string `json:"profile"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncCatalogRequest 是后台同步或导入热游目录的入参。
|
|
||||||
type SyncCatalogRequest struct {
|
|
||||||
SysOrigin string `json:"sysOrigin"`
|
|
||||||
Profile string `json:"profile"`
|
|
||||||
VendorGameIDs []string `json:"vendorGameIds"`
|
|
||||||
Force bool `json:"force"`
|
|
||||||
DefaultShowcase *bool `json:"defaultShowcase"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncCatalogResponse 是热游目录同步结果。
|
|
||||||
type SyncCatalogResponse struct {
|
|
||||||
Inserted int `json:"inserted"`
|
|
||||||
Updated int `json:"updated"`
|
|
||||||
Skipped int `json:"skipped"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImportCatalogResponse 是热游目录导入统一游戏列表的结果。
|
|
||||||
type ImportCatalogResponse struct {
|
|
||||||
Existing int `json:"existing"`
|
|
||||||
Imported int `json:"imported"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncAdminCatalogResponse 是“同步目录并导入游戏”的合并结果。
|
|
||||||
type SyncAdminCatalogResponse struct {
|
|
||||||
Inserted int `json:"inserted"`
|
|
||||||
Updated int `json:"updated"`
|
|
||||||
Skipped int `json:"skipped"`
|
|
||||||
Existing int `json:"existing"`
|
|
||||||
Imported int `json:"imported"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CatalogItem 是后台热游目录表格行。
|
|
||||||
type CatalogItem struct {
|
|
||||||
VendorGameID string `json:"vendorGameId"`
|
|
||||||
Profile string `json:"profile"`
|
|
||||||
GameID string `json:"gameId"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Cover string `json:"cover"`
|
|
||||||
PreviewURL string `json:"previewUrl"`
|
|
||||||
DownloadURL string `json:"downloadUrl"`
|
|
||||||
PackageVersion string `json:"packageVersion"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Added bool `json:"added"`
|
|
||||||
Showcase bool `json:"showcase"`
|
|
||||||
UpdateTime string `json:"updateTime"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CatalogPageResponse 是热游目录分页响应。
|
|
||||||
type CatalogPageResponse struct {
|
|
||||||
Cursor int `json:"cursor"`
|
|
||||||
Limit int `json:"limit"`
|
|
||||||
Records []CatalogItem `json:"records"`
|
|
||||||
Total int64 `json:"total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type defaultCatalogGame struct {
|
|
||||||
GameID string
|
|
||||||
Name string
|
|
||||||
TestURL string
|
|
||||||
ProdURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
type gameRow struct {
|
|
||||||
ConfigID int64
|
|
||||||
SysOrigin string
|
|
||||||
Profile string
|
|
||||||
InternalGameID string
|
|
||||||
Name string
|
|
||||||
Category string
|
|
||||||
Cover string
|
|
||||||
Sort int64
|
|
||||||
FullScreen bool
|
|
||||||
VendorGameID string
|
|
||||||
LaunchMode string
|
|
||||||
ExtPackageVersion string
|
|
||||||
ExtPreviewURL string
|
|
||||||
PackageURL string
|
|
||||||
ExtOrientation *int
|
|
||||||
ExtSafeHeight int
|
|
||||||
ExtExtraJSON string
|
|
||||||
CatalogName string
|
|
||||||
CatalogCover string
|
|
||||||
CatalogPreviewURL string
|
|
||||||
CatalogDownloadURL string
|
|
||||||
CatalogPackageVersion string
|
|
||||||
CatalogStatus string
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ gameprovider.Provider = (*AppProvider)(nil)
|
|
||||||
|
|
||||||
// AppProvider 把热游服务适配到统一游戏厂商注册表。
|
|
||||||
type AppProvider struct {
|
|
||||||
service *Service
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAppProvider 创建热游 app 侧 provider。
|
|
||||||
func NewAppProvider(service *Service) *AppProvider {
|
|
||||||
if service == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &AppProvider{service: service}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeSysOrigin(sysOrigin string) string {
|
|
||||||
sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin))
|
|
||||||
if sysOrigin == "" {
|
|
||||||
return "LIKEI"
|
|
||||||
}
|
|
||||||
return sysOrigin
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeProfile(profile string) string {
|
|
||||||
switch strings.ToUpper(strings.TrimSpace(profile)) {
|
|
||||||
case "", "PROD", "PRODUCT", "PRODUCTION", "ONLINE", "OFFICIAL", "FORMAL":
|
|
||||||
return profileProd
|
|
||||||
case "TEST", "TESTING", "SANDBOX", "DEV":
|
|
||||||
return profileTest
|
|
||||||
default:
|
|
||||||
return strings.ToUpper(strings.TrimSpace(profile))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultIfBlank(value, fallback string) string {
|
|
||||||
if strings.TrimSpace(value) != "" {
|
|
||||||
return strings.TrimSpace(value)
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(fallback)
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatTime(t time.Time) string {
|
|
||||||
if t.IsZero() {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return t.Format("2006-01-02 15:04:05")
|
|
||||||
}
|
|
||||||
|
|
||||||
func configToRuntime(base runtimeConfig, row model.HotgameProviderConfig) runtimeConfig {
|
|
||||||
base.SysOrigin = normalizeSysOrigin(defaultIfBlank(row.SysOrigin, base.SysOrigin))
|
|
||||||
base.Profile = normalizeProfile(defaultIfBlank(row.Profile, base.Profile))
|
|
||||||
base.AppKey = defaultIfBlank(row.AppKey, base.AppKey)
|
|
||||||
base.CallbackBaseURL = defaultIfBlank(row.CallbackBaseURL, base.CallbackBaseURL)
|
|
||||||
base.TestUID = strings.TrimSpace(row.TestUID)
|
|
||||||
base.TestToken = strings.TrimSpace(row.TestToken)
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
|
|
||||||
func internalGameID(vendorGameID string) string {
|
|
||||||
vendorGameID = strings.TrimSpace(vendorGameID)
|
|
||||||
if vendorGameID == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return "hg_" + vendorGameID
|
|
||||||
}
|
|
||||||
@ -47,7 +47,7 @@ func (p *AppProvider) ListShortcutGames(ctx context.Context, user gameprovider.A
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *AppProvider) ListRoomGames(ctx context.Context, user gameprovider.AuthUser, roomID, category string) (*gameprovider.RoomGameListResponse, error) {
|
func (p *AppProvider) ListRoomGames(ctx context.Context, user gameprovider.AuthUser, roomID, category string) (*gameprovider.RoomGameListResponse, error) {
|
||||||
items, err := p.service.listRoomGames(ctx, user.SysOrigin, user.RegionID, category)
|
items, err := p.service.listRoomGames(ctx, user.SysOrigin, category)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -116,7 +116,7 @@ func (p *AppProvider) CloseGame(ctx context.Context, user gameprovider.AuthUser,
|
|||||||
return &gameprovider.RoomStateResponse{RoomID: req.RoomID, State: "IDLE", Provider: vendorType}, nil
|
return &gameprovider.RoomStateResponse{RoomID: req.RoomID, State: "IDLE", Provider: vendorType}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) listRoomGames(ctx context.Context, sysOrigin, regionID, category string) ([]gameprovider.RoomGameListItem, error) {
|
func (s *Service) listRoomGames(ctx context.Context, sysOrigin, category string) ([]gameprovider.RoomGameListItem, error) {
|
||||||
sysOrigin = normalizeSysOrigin(sysOrigin)
|
sysOrigin = normalizeSysOrigin(sysOrigin)
|
||||||
profile, err := s.activeProfile(ctx, sysOrigin)
|
profile, err := s.activeProfile(ctx, sysOrigin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -125,10 +125,6 @@ func (s *Service) listRoomGames(ctx context.Context, sysOrigin, regionID, catego
|
|||||||
var rows []gameRow
|
var rows []gameRow
|
||||||
query := s.baseGameQuery(ctx, sysOrigin, profile).
|
query := s.baseGameQuery(ctx, sysOrigin, profile).
|
||||||
Where("cfg.sys_origin = ? AND cfg.is_showcase = 1 AND ext.vendor_type = ?", sysOrigin, vendorType)
|
Where("cfg.sys_origin = ? AND cfg.is_showcase = 1 AND ext.vendor_type = ?", sysOrigin, vendorType)
|
||||||
if strings.TrimSpace(regionID) != "" {
|
|
||||||
// regions 为空表示全区域;有值时按用户区域 ID 精确匹配,避免土耳其用户看到其他区域游戏。
|
|
||||||
query = query.Where("(COALESCE(TRIM(cfg.regions), '') = '' OR FIND_IN_SET(?, REPLACE(COALESCE(cfg.regions, ''), ' ', '')) > 0)", strings.TrimSpace(regionID))
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(category) != "" && !strings.EqualFold(strings.TrimSpace(category), roomCategory) {
|
if strings.TrimSpace(category) != "" && !strings.EqualFold(strings.TrimSpace(category), roomCategory) {
|
||||||
query = query.Where("cfg.category = ?", category)
|
query = query.Where("cfg.category = ?", category)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -124,7 +124,7 @@ func (s *Service) ListBDLeaders(ctx context.Context, user AuthUser) (*BDLeaderLi
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) AddBDLeader(ctx context.Context, user AuthUser, req AddBDLeaderRequest) (*AddBDLeaderResponse, error) {
|
func (s *Service) AddBDLeader(ctx context.Context, user AuthUser, req AddBDLeaderRequest) (*AddBDLeaderResponse, error) {
|
||||||
manager, err := s.requireManagerFeature(ctx, user, managerFeatureAddBDLeader)
|
manager, err := s.requireManagerInfo(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/integration"
|
"chatapp3-golang/internal/integration"
|
||||||
"chatapp3-golang/internal/model"
|
"chatapp3-golang/internal/model"
|
||||||
@ -20,11 +19,6 @@ type teamManagerInfoRow struct {
|
|||||||
Origin string `gorm:"column:sys_origin"`
|
Origin string `gorm:"column:sys_origin"`
|
||||||
Contact string `gorm:"column:contact"`
|
Contact string `gorm:"column:contact"`
|
||||||
RegionID string `gorm:"column:region_id"`
|
RegionID string `gorm:"column:region_id"`
|
||||||
CanAddBDLeader *bool `gorm:"column:can_add_bd_leader"`
|
|
||||||
CanGiftProps *bool `gorm:"column:can_gift_props"`
|
|
||||||
CanBanUser *bool `gorm:"column:can_ban_user"`
|
|
||||||
CanUnbanUser *bool `gorm:"column:can_unban_user"`
|
|
||||||
CanChangeCountry *bool `gorm:"column:can_change_country"`
|
|
||||||
CreateUser int64 `gorm:"column:create_user"`
|
CreateUser int64 `gorm:"column:create_user"`
|
||||||
UpdateUser int64 `gorm:"column:update_user"`
|
UpdateUser int64 `gorm:"column:update_user"`
|
||||||
}
|
}
|
||||||
@ -38,34 +32,17 @@ type userBaseInfoRow struct {
|
|||||||
Account string `gorm:"column:account"`
|
Account string `gorm:"column:account"`
|
||||||
UserAvatar string `gorm:"column:user_avatar"`
|
UserAvatar string `gorm:"column:user_avatar"`
|
||||||
UserNickname string `gorm:"column:user_nickname"`
|
UserNickname string `gorm:"column:user_nickname"`
|
||||||
CountryID int64 `gorm:"column:country_id"`
|
|
||||||
CountryCode string `gorm:"column:country_code"`
|
CountryCode string `gorm:"column:country_code"`
|
||||||
CountryName string `gorm:"column:country_name"`
|
CountryName string `gorm:"column:country_name"`
|
||||||
OriginSys string `gorm:"column:origin_sys"`
|
OriginSys string `gorm:"column:origin_sys"`
|
||||||
AccountStatus string `gorm:"column:account_status"`
|
AccountStatus string `gorm:"column:account_status"`
|
||||||
IsDel bool `gorm:"column:is_del"`
|
IsDel bool `gorm:"column:is_del"`
|
||||||
UpdateTime time.Time `gorm:"column:update_time"`
|
|
||||||
UpdateUser int64 `gorm:"column:update_user"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (userBaseInfoRow) TableName() string {
|
func (userBaseInfoRow) TableName() string {
|
||||||
return "user_base_info"
|
return "user_base_info"
|
||||||
}
|
}
|
||||||
|
|
||||||
type sysCountryCodeRow struct {
|
|
||||||
ID int64 `gorm:"column:id;primaryKey"`
|
|
||||||
AlphaTwo string `gorm:"column:alpha_two"`
|
|
||||||
CountryName string `gorm:"column:country_name"`
|
|
||||||
NationalFlag string `gorm:"column:national_flag"`
|
|
||||||
PhonePrefix int `gorm:"column:phone_prefix"`
|
|
||||||
Open bool `gorm:"column:is_open"`
|
|
||||||
Sort int `gorm:"column:sort"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sysCountryCodeRow) TableName() string {
|
|
||||||
return "sys_country_code"
|
|
||||||
}
|
|
||||||
|
|
||||||
type propsSourceRecordRow struct {
|
type propsSourceRecordRow struct {
|
||||||
ID int64 `gorm:"column:id;primaryKey"`
|
ID int64 `gorm:"column:id;primaryKey"`
|
||||||
Type string `gorm:"column:type"`
|
Type string `gorm:"column:type"`
|
||||||
@ -96,8 +73,7 @@ func (propsNobleVIPAbilityRow) TableName() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetProfile(ctx context.Context, user AuthUser) (*ProfileResponse, error) {
|
func (s *Service) GetProfile(ctx context.Context, user AuthUser) (*ProfileResponse, error) {
|
||||||
manager, err := s.requireManagerInfo(ctx, user)
|
if err := s.ensureManager(ctx, user); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,7 +81,7 @@ func (s *Service) GetProfile(ctx context.Context, user AuthUser) (*ProfileRespon
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &ProfileResponse{Manager: view, Features: managerFeaturesFromRow(manager)}, nil
|
return &ProfileResponse{Manager: view}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) SearchUser(ctx context.Context, user AuthUser, account string) (*UserSearchResponse, error) {
|
func (s *Service) SearchUser(ctx context.Context, user AuthUser, account string) (*UserSearchResponse, error) {
|
||||||
@ -125,34 +101,6 @@ func (s *Service) SearchUser(ctx context.Context, user AuthUser, account string)
|
|||||||
return &UserSearchResponse{User: view}, nil
|
return &UserSearchResponse{User: view}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListCountries 给经理中心返回当前开放国家,H5 只展示可绑定目标,避免经理手输无效国家码。
|
|
||||||
func (s *Service) ListCountries(ctx context.Context, user AuthUser) (*CountryListResponse, error) {
|
|
||||||
if err := s.ensureManager(ctx, user); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if s.db == nil {
|
|
||||||
return nil, serviceUnavailable("database_unavailable", "database is unavailable")
|
|
||||||
}
|
|
||||||
|
|
||||||
var rows []sysCountryCodeRow
|
|
||||||
if err := s.db.WithContext(ctx).
|
|
||||||
Table("sys_country_code").
|
|
||||||
Select("id, alpha_two, country_name, national_flag, phone_prefix, is_open, sort").
|
|
||||||
Where("is_open = ?", true).
|
|
||||||
Order("sort ASC, country_name ASC, id ASC").
|
|
||||||
Find(&rows).Error; err != nil {
|
|
||||||
return nil, serverError("country_query_failed", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
records := make([]CountryView, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
if view, ok := countryRowToView(row); ok {
|
|
||||||
records = append(records, view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &CountryListResponse{Records: records}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) ListProps(ctx context.Context, user AuthUser, propsType string) (*PropsListResponse, error) {
|
func (s *Service) ListProps(ctx context.Context, user AuthUser, propsType string) (*PropsListResponse, error) {
|
||||||
if err := s.ensureManager(ctx, user); err != nil {
|
if err := s.ensureManager(ctx, user); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -200,7 +148,7 @@ func (s *Service) ListProps(ctx context.Context, user AuthUser, propsType string
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsRequest) (*SendPropsResponse, error) {
|
func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsRequest) (*SendPropsResponse, error) {
|
||||||
if _, err := s.requireManagerFeature(ctx, user, managerFeatureGiftProps); err != nil {
|
if err := s.ensureManager(ctx, user); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if req.AcceptUserID.Int64() <= 0 {
|
if req.AcceptUserID.Int64() <= 0 {
|
||||||
@ -209,10 +157,6 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
|
|||||||
if req.PropsID.Int64() <= 0 {
|
if req.PropsID.Int64() <= 0 {
|
||||||
return nil, badRequest("props_required", "propsId is required")
|
return nil, badRequest("props_required", "propsId is required")
|
||||||
}
|
}
|
||||||
days, err := normalizeGiftDays(req.Days)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if s.java == nil {
|
if s.java == nil {
|
||||||
return nil, serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable")
|
return nil, serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable")
|
||||||
}
|
}
|
||||||
@ -228,7 +172,7 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
|
|||||||
prop, err := s.loadGiftableProps(ctx, req.PropsID.Int64())
|
prop, err := s.loadGiftableProps(ctx, req.PropsID.Int64())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isAppErrorCode(err, "prop_not_giftable") {
|
if isAppErrorCode(err, "prop_not_giftable") {
|
||||||
if resp, vipErr := s.sendVIPGift(ctx, user, target, req.PropsID.Int64(), days); vipErr != nil {
|
if resp, vipErr := s.sendVIPGift(ctx, user, target, req.PropsID.Int64()); vipErr != nil {
|
||||||
return nil, vipErr
|
return nil, vipErr
|
||||||
} else if resp != nil {
|
} else if resp != nil {
|
||||||
return resp, nil
|
return resp, nil
|
||||||
@ -237,6 +181,7 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
days := propsGiftDays(prop.Type)
|
||||||
if prop.Type == propsTypeBadge {
|
if prop.Type == propsTypeBadge {
|
||||||
if err := s.java.ActivateTemporaryBadge(ctx, req.AcceptUserID.Int64(), prop.ID, days); err != nil {
|
if err := s.java.ActivateTemporaryBadge(ctx, req.AcceptUserID.Int64(), prop.ID, days); err != nil {
|
||||||
return nil, serverError("send_badge_failed", err.Error())
|
return nil, serverError("send_badge_failed", err.Error())
|
||||||
@ -263,7 +208,7 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, serverError("vip_ability_query_failed", err.Error())
|
return nil, serverError("vip_ability_query_failed", err.Error())
|
||||||
}
|
}
|
||||||
grants = append(grants, nobleVIPAccessoryGrants(ability, days)...)
|
grants = append(grants, nobleVIPAccessoryGrants(ability)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, grant := range grants {
|
for _, grant := range grants {
|
||||||
@ -298,7 +243,7 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) BanUser(ctx context.Context, user AuthUser, req BanUserRequest) (*BanUserResponse, error) {
|
func (s *Service) BanUser(ctx context.Context, user AuthUser, req BanUserRequest) (*BanUserResponse, error) {
|
||||||
if _, err := s.requireManagerFeature(ctx, user, managerFeatureBanUser); err != nil {
|
if err := s.ensureManager(ctx, user); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if req.UserID.Int64() <= 0 {
|
if req.UserID.Int64() <= 0 {
|
||||||
@ -345,7 +290,7 @@ func (s *Service) BanUser(ctx context.Context, user AuthUser, req BanUserRequest
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) UnbanUser(ctx context.Context, user AuthUser, req UnbanUserRequest) (*UnbanUserResponse, error) {
|
func (s *Service) UnbanUser(ctx context.Context, user AuthUser, req UnbanUserRequest) (*UnbanUserResponse, error) {
|
||||||
if _, err := s.requireManagerFeature(ctx, user, managerFeatureUnbanUser); err != nil {
|
if err := s.ensureManager(ctx, user); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if req.UserID.Int64() <= 0 {
|
if req.UserID.Int64() <= 0 {
|
||||||
@ -382,107 +327,11 @@ func (s *Service) UnbanUser(ctx context.Context, user AuthUser, req UnbanUserReq
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChangeUserCountry 只允许 admin 开启改国家权限的经理移动普通用户国家;目标用户已有业务身份时拒绝,避免跨国家后团队、币商和主播归属数据不一致。
|
|
||||||
func (s *Service) ChangeUserCountry(ctx context.Context, user AuthUser, req ChangeUserCountryRequest) (*ChangeUserCountryResponse, error) {
|
|
||||||
if _, err := s.requireManagerFeature(ctx, user, managerFeatureChangeCountry); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if req.UserID.Int64() <= 0 {
|
|
||||||
return nil, badRequest("user_required", "userId is required")
|
|
||||||
}
|
|
||||||
if req.CountryID.Int64() <= 0 {
|
|
||||||
return nil, badRequest("country_required", "countryId is required")
|
|
||||||
}
|
|
||||||
if s.db == nil {
|
|
||||||
return nil, serviceUnavailable("database_unavailable", "database is unavailable")
|
|
||||||
}
|
|
||||||
if s.java == nil {
|
|
||||||
return nil, serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable")
|
|
||||||
}
|
|
||||||
|
|
||||||
target, err := s.loadUserRowByID(ctx, req.UserID.Int64())
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, notFound("user_not_found", "user not found")
|
|
||||||
}
|
|
||||||
return nil, serverError("user_query_failed", err.Error())
|
|
||||||
}
|
|
||||||
country, err := s.loadOpenCountryByID(ctx, req.CountryID.Int64())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := s.ensureUserCountryMovable(ctx, target.ID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 国家写入统一交给 Java,复用 country -> user 锁顺序,避免与后台国家批量同步并发时漏数据。
|
|
||||||
if err := s.java.UpdateUserCountry(ctx, target.ID, country.ID); err != nil {
|
|
||||||
return nil, serverError("user_country_update_failed", err.Error())
|
|
||||||
}
|
|
||||||
if err := s.java.RemoveUserProfileCacheAll(ctx, target.ID); err != nil {
|
|
||||||
return nil, serverError("remove_profile_cache_failed", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
target.CountryID = country.ID
|
|
||||||
target.CountryCode = strings.ToUpper(strings.TrimSpace(country.AlphaTwo))
|
|
||||||
target.CountryName = strings.TrimSpace(country.CountryName)
|
|
||||||
view, err := s.userRowToView(ctx, target)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &ChangeUserCountryResponse{Success: true, User: view}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) ensureManager(ctx context.Context, user AuthUser) error {
|
func (s *Service) ensureManager(ctx context.Context, user AuthUser) error {
|
||||||
_, err := s.requireManagerInfo(ctx, user)
|
_, err := s.requireManagerInfo(ctx, user)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// requireManagerFeature 先确认调用人仍然是团队经理,再检查 admin 给这个经理打开的单项 H5 操作权限。
|
|
||||||
// 这样前端隐藏按钮和后端写操作使用同一张 team_manager_base_info 表做判断,H5 被绕过时也会被服务层拦截。
|
|
||||||
func (s *Service) requireManagerFeature(ctx context.Context, user AuthUser, feature managerFeature) (teamManagerInfoRow, error) {
|
|
||||||
manager, err := s.requireManagerInfo(ctx, user)
|
|
||||||
if err != nil {
|
|
||||||
return teamManagerInfoRow{}, err
|
|
||||||
}
|
|
||||||
features := managerFeaturesFromRow(manager)
|
|
||||||
if !features.enabled(feature) {
|
|
||||||
return teamManagerInfoRow{}, forbidden("manager_feature_disabled", "manager feature is disabled")
|
|
||||||
}
|
|
||||||
return manager, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func managerFeaturesFromRow(row teamManagerInfoRow) ManagerFeatures {
|
|
||||||
return ManagerFeatures{
|
|
||||||
AddBDLeader: defaultEnabled(row.CanAddBDLeader),
|
|
||||||
GiftProps: defaultEnabled(row.CanGiftProps),
|
|
||||||
BanUser: defaultEnabled(row.CanBanUser),
|
|
||||||
UnbanUser: defaultEnabled(row.CanUnbanUser),
|
|
||||||
ChangeCountry: defaultEnabled(row.CanChangeCountry),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultEnabled(value *bool) bool {
|
|
||||||
return value == nil || *value
|
|
||||||
}
|
|
||||||
|
|
||||||
func (features ManagerFeatures) enabled(feature managerFeature) bool {
|
|
||||||
switch feature {
|
|
||||||
case managerFeatureAddBDLeader:
|
|
||||||
return features.AddBDLeader
|
|
||||||
case managerFeatureGiftProps:
|
|
||||||
return features.GiftProps
|
|
||||||
case managerFeatureBanUser:
|
|
||||||
return features.BanUser
|
|
||||||
case managerFeatureUnbanUser:
|
|
||||||
return features.UnbanUser
|
|
||||||
case managerFeatureChangeCountry:
|
|
||||||
return features.ChangeCountry
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) findUserView(ctx context.Context, account string) (UserView, error) {
|
func (s *Service) findUserView(ctx context.Context, account string) (UserView, error) {
|
||||||
if userID, err := strconv.ParseInt(account, 10, 64); err == nil && userID > 0 {
|
if userID, err := strconv.ParseInt(account, 10, 64); err == nil && userID > 0 {
|
||||||
if row, err := s.loadUserRowByID(ctx, userID); err == nil {
|
if row, err := s.loadUserRowByID(ctx, userID); err == nil {
|
||||||
@ -548,65 +397,6 @@ func (s *Service) loadUserRowByAccount(ctx context.Context, account string) (use
|
|||||||
return row, err
|
return row, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) loadOpenCountryByID(ctx context.Context, countryID int64) (sysCountryCodeRow, error) {
|
|
||||||
var row sysCountryCodeRow
|
|
||||||
err := s.db.WithContext(ctx).
|
|
||||||
Table("sys_country_code").
|
|
||||||
Select("id, alpha_two, country_name, national_flag, phone_prefix, is_open, sort").
|
|
||||||
Where("id = ? AND is_open = ?", countryID, true).
|
|
||||||
First(&row).Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return sysCountryCodeRow{}, notFound("country_not_found", "country not found")
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return sysCountryCodeRow{}, serverError("country_query_failed", err.Error())
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(row.AlphaTwo) == "" {
|
|
||||||
return sysCountryCodeRow{}, badRequest("country_code_required", "country code is required")
|
|
||||||
}
|
|
||||||
return row, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureUserCountryMovable 串行检查所有会绑定归属关系的身份;任一命中都返回同一个业务错误,前端显示统一提示。
|
|
||||||
func (s *Service) ensureUserCountryMovable(ctx context.Context, userID int64) error {
|
|
||||||
locked, err := s.userHasLockedHistoryIdentity(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if locked {
|
|
||||||
return badRequest("user_identity_locked", "user has host, agency, BD, BD leader or coin seller identity and cannot be moved")
|
|
||||||
}
|
|
||||||
locked, err = s.userIsBDLeader(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if locked {
|
|
||||||
return badRequest("user_identity_locked", "user has host, agency, BD, BD leader or coin seller identity and cannot be moved")
|
|
||||||
}
|
|
||||||
if s.java == nil {
|
|
||||||
return serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable")
|
|
||||||
}
|
|
||||||
locked, err = s.java.ExistsFreightSeller(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return serverError("coin_seller_query_failed", err.Error())
|
|
||||||
}
|
|
||||||
if locked {
|
|
||||||
return badRequest("user_identity_locked", "user has host, agency, BD, BD leader or coin seller identity and cannot be moved")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) userHasLockedHistoryIdentity(ctx context.Context, userID int64) (bool, error) {
|
|
||||||
var count int64
|
|
||||||
if err := s.db.WithContext(ctx).
|
|
||||||
Table("user_history_identity").
|
|
||||||
Where("user_id = ? AND (is_host = ? OR is_agent = ? OR is_bd = ?)", userID, true, true, true).
|
|
||||||
Count(&count).Error; err != nil {
|
|
||||||
return false, serverError("history_identity_query_failed", err.Error())
|
|
||||||
}
|
|
||||||
return count > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) userRowToView(ctx context.Context, row userBaseInfoRow) (UserView, error) {
|
func (s *Service) userRowToView(ctx context.Context, row userBaseInfoRow) (UserView, error) {
|
||||||
hasRecharge, canBan := s.userRechargeEligibility(ctx, row.ID)
|
hasRecharge, canBan := s.userRechargeEligibility(ctx, row.ID)
|
||||||
canBan = canBan && !isUnbannableAccountStatus(row.AccountStatus)
|
canBan = canBan && !isUnbannableAccountStatus(row.AccountStatus)
|
||||||
@ -615,7 +405,6 @@ func (s *Service) userRowToView(ctx context.Context, row userBaseInfoRow) (UserV
|
|||||||
Account: row.Account,
|
Account: row.Account,
|
||||||
UserAvatar: row.UserAvatar,
|
UserAvatar: row.UserAvatar,
|
||||||
UserNickname: row.UserNickname,
|
UserNickname: row.UserNickname,
|
||||||
CountryID: ID(row.CountryID),
|
|
||||||
CountryCode: row.CountryCode,
|
CountryCode: row.CountryCode,
|
||||||
CountryName: row.CountryName,
|
CountryName: row.CountryName,
|
||||||
OriginSys: row.OriginSys,
|
OriginSys: row.OriginSys,
|
||||||
@ -633,7 +422,6 @@ func (s *Service) javaProfileToView(ctx context.Context, profile integration.Use
|
|||||||
Account: profile.Account,
|
Account: profile.Account,
|
||||||
UserAvatar: profile.UserAvatar,
|
UserAvatar: profile.UserAvatar,
|
||||||
UserNickname: profile.UserNickname,
|
UserNickname: profile.UserNickname,
|
||||||
CountryID: ID(profile.CountryID),
|
|
||||||
CountryCode: profile.CountryCode,
|
CountryCode: profile.CountryCode,
|
||||||
CountryName: profile.CountryName,
|
CountryName: profile.CountryName,
|
||||||
OriginSys: profile.OriginSys,
|
OriginSys: profile.OriginSys,
|
||||||
@ -659,9 +447,6 @@ func overlayJavaProfile(view *UserView, profile integration.UserProfile) {
|
|||||||
if profile.CountryName != "" {
|
if profile.CountryName != "" {
|
||||||
view.CountryName = profile.CountryName
|
view.CountryName = profile.CountryName
|
||||||
}
|
}
|
||||||
if int64(profile.CountryID) > 0 {
|
|
||||||
view.CountryID = ID(profile.CountryID)
|
|
||||||
}
|
|
||||||
if profile.OriginSys != "" {
|
if profile.OriginSys != "" {
|
||||||
view.OriginSys = profile.OriginSys
|
view.OriginSys = profile.OriginSys
|
||||||
}
|
}
|
||||||
@ -673,21 +458,6 @@ func overlayJavaProfile(view *UserView, profile integration.UserProfile) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func countryRowToView(row sysCountryCodeRow) (CountryView, bool) {
|
|
||||||
code := strings.ToUpper(strings.TrimSpace(row.AlphaTwo))
|
|
||||||
name := strings.TrimSpace(row.CountryName)
|
|
||||||
if row.ID <= 0 || code == "" || name == "" {
|
|
||||||
return CountryView{}, false
|
|
||||||
}
|
|
||||||
return CountryView{
|
|
||||||
ID: ID(row.ID),
|
|
||||||
CountryCode: code,
|
|
||||||
CountryName: name,
|
|
||||||
NationalFlag: strings.TrimSpace(row.NationalFlag),
|
|
||||||
PhonePrefix: row.PhonePrefix,
|
|
||||||
}, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) userHasRecharge(ctx context.Context, userID int64) (bool, error) {
|
func (s *Service) userHasRecharge(ctx context.Context, userID int64) (bool, error) {
|
||||||
if userID <= 0 {
|
if userID <= 0 {
|
||||||
return false, nil
|
return false, nil
|
||||||
@ -753,7 +523,7 @@ func (s *Service) loadGiftableNobleVIPAbility(ctx context.Context, propsID int64
|
|||||||
return propsNobleVIPAbilityRow{}, serverError("vip_ability_query_failed", err.Error())
|
return propsNobleVIPAbilityRow{}, serverError("vip_ability_query_failed", err.Error())
|
||||||
}
|
}
|
||||||
if row.VIPLevel > maxGiftableVIPLevel {
|
if row.VIPLevel > maxGiftableVIPLevel {
|
||||||
return propsNobleVIPAbilityRow{}, badRequest("vip_not_giftable", "only noble VIP level 1-5 can be gifted")
|
return propsNobleVIPAbilityRow{}, badRequest("vip_not_giftable", "only noble VIP level 1-3 can be gifted")
|
||||||
}
|
}
|
||||||
return row, nil
|
return row, nil
|
||||||
}
|
}
|
||||||
@ -791,13 +561,13 @@ type propsGrant struct {
|
|||||||
days int
|
days int
|
||||||
}
|
}
|
||||||
|
|
||||||
func nobleVIPAccessoryGrants(ability integration.PropsNobleVIPAbility, days int) []propsGrant {
|
func nobleVIPAccessoryGrants(ability integration.PropsNobleVIPAbility) []propsGrant {
|
||||||
grants := make([]propsGrant, 0, 4)
|
grants := make([]propsGrant, 0, 4)
|
||||||
appendIfPositive := func(propsID int64, typ string) {
|
appendIfPositive := func(propsID int64, typ string) {
|
||||||
if propsID <= 0 {
|
if propsID <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
grants = append(grants, propsGrant{propsID: propsID, typ: typ, days: days})
|
grants = append(grants, propsGrant{propsID: propsID, typ: typ, days: defaultVIPGiftDays})
|
||||||
}
|
}
|
||||||
appendIfPositive(int64(ability.CarID), propsTypeRide)
|
appendIfPositive(int64(ability.CarID), propsTypeRide)
|
||||||
appendIfPositive(int64(ability.AvatarFrameID), propsTypeAvatarFrame)
|
appendIfPositive(int64(ability.AvatarFrameID), propsTypeAvatarFrame)
|
||||||
@ -851,7 +621,7 @@ func propsRowToView(row propsSourceRecordRow, vipLevels map[int64]int) (PropsVie
|
|||||||
if row.Type == propsTypeNobleVIP {
|
if row.Type == propsTypeNobleVIP {
|
||||||
var exists bool
|
var exists bool
|
||||||
level, exists = vipLevels[row.ID]
|
level, exists = vipLevels[row.ID]
|
||||||
if !exists || level > maxGiftableVIPLevel {
|
if !exists || level > 3 {
|
||||||
return PropsView{}, false
|
return PropsView{}, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -875,19 +645,6 @@ func propsGiftDays(propsType string) int {
|
|||||||
return defaultGiftDays
|
return defaultGiftDays
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeGiftDays(days int) (int, error) {
|
|
||||||
// days=0 兼容尚未升级的旧 H5,并按新规则默认赠送 30 天。
|
|
||||||
if days == 0 {
|
|
||||||
return defaultGiftDays, nil
|
|
||||||
}
|
|
||||||
switch days {
|
|
||||||
case giftDays30, giftDays60, giftDays90:
|
|
||||||
return days, nil
|
|
||||||
default:
|
|
||||||
return 0, badRequest("invalid_gift_days", "days must be one of 30, 60, 90")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizePropsType(propsType string) string {
|
func normalizePropsType(propsType string) string {
|
||||||
return strings.ToUpper(strings.TrimSpace(propsType))
|
return strings.ToUpper(strings.TrimSpace(propsType))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,8 +17,6 @@ import (
|
|||||||
type fakeManagerGateway struct {
|
type fakeManagerGateway struct {
|
||||||
recharges map[int64][]integration.UserTotalRecharge
|
recharges map[int64][]integration.UserTotalRecharge
|
||||||
rechargeErr error
|
rechargeErr error
|
||||||
freightSellers map[int64]bool
|
|
||||||
freightSellerErr error
|
|
||||||
abilities map[int64]integration.PropsNobleVIPAbility
|
abilities map[int64]integration.PropsNobleVIPAbility
|
||||||
maxAbility integration.PropsNobleVIPAbility
|
maxAbility integration.PropsNobleVIPAbility
|
||||||
giveRequests []integration.GivePropsBackpackRequest
|
giveRequests []integration.GivePropsBackpackRequest
|
||||||
@ -26,16 +24,9 @@ type fakeManagerGateway struct {
|
|||||||
unblockRequests []integration.UnblockAccountRequest
|
unblockRequests []integration.UnblockAccountRequest
|
||||||
switchIDs []int64
|
switchIDs []int64
|
||||||
cacheCleared []int64
|
cacheCleared []int64
|
||||||
countryUpdates []fakeCountryUpdate
|
|
||||||
countryUpdateErr error
|
|
||||||
badgeActivations []fakeBadgeActivation
|
badgeActivations []fakeBadgeActivation
|
||||||
}
|
}
|
||||||
|
|
||||||
type fakeCountryUpdate struct {
|
|
||||||
userID int64
|
|
||||||
countryID int64
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeBadgeActivation struct {
|
type fakeBadgeActivation struct {
|
||||||
userID int64
|
userID int64
|
||||||
badgeID int64
|
badgeID int64
|
||||||
@ -46,14 +37,6 @@ func (f *fakeManagerGateway) GetUserProfile(context.Context, int64) (integration
|
|||||||
return integration.UserProfile{}, nil
|
return integration.UserProfile{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeManagerGateway) UpdateUserCountry(_ context.Context, userID int64, countryID int64) error {
|
|
||||||
if f.countryUpdateErr != nil {
|
|
||||||
return f.countryUpdateErr
|
|
||||||
}
|
|
||||||
f.countryUpdates = append(f.countryUpdates, fakeCountryUpdate{userID: userID, countryID: countryID})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeManagerGateway) GetUserProfileByAccountOne(context.Context, string) (integration.UserProfile, error) {
|
func (f *fakeManagerGateway) GetUserProfileByAccountOne(context.Context, string) (integration.UserProfile, error) {
|
||||||
return integration.UserProfile{}, nil
|
return integration.UserProfile{}, nil
|
||||||
}
|
}
|
||||||
@ -69,13 +52,6 @@ func (f *fakeManagerGateway) MapUserTotalRecharge(_ context.Context, userIDs []i
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeManagerGateway) ExistsFreightSeller(_ context.Context, userID int64) (bool, error) {
|
|
||||||
if f.freightSellerErr != nil {
|
|
||||||
return false, f.freightSellerErr
|
|
||||||
}
|
|
||||||
return f.freightSellers[userID], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeManagerGateway) GivePropsBackpack(_ context.Context, req integration.GivePropsBackpackRequest) error {
|
func (f *fakeManagerGateway) GivePropsBackpack(_ context.Context, req integration.GivePropsBackpackRequest) error {
|
||||||
f.giveRequests = append(f.giveRequests, req)
|
f.giveRequests = append(f.giveRequests, req)
|
||||||
return nil
|
return nil
|
||||||
@ -122,14 +98,10 @@ func TestListPropsRequiresAdminFreeAndFiltersVIPLevels(t *testing.T) {
|
|||||||
propsSourceRecordRow{ID: 102, Type: propsTypeRide, Name: "Paid ride", AdminFree: false},
|
propsSourceRecordRow{ID: 102, Type: propsTypeRide, Name: "Paid ride", AdminFree: false},
|
||||||
propsSourceRecordRow{ID: 201, Type: propsTypeNobleVIP, Name: "VIP 3", AdminFree: true},
|
propsSourceRecordRow{ID: 201, Type: propsTypeNobleVIP, Name: "VIP 3", AdminFree: true},
|
||||||
propsSourceRecordRow{ID: 204, Type: propsTypeNobleVIP, Name: "VIP 4", AdminFree: true},
|
propsSourceRecordRow{ID: 204, Type: propsTypeNobleVIP, Name: "VIP 4", AdminFree: true},
|
||||||
propsSourceRecordRow{ID: 205, Type: propsTypeNobleVIP, Name: "VIP 5", AdminFree: true},
|
|
||||||
propsSourceRecordRow{ID: 206, Type: propsTypeNobleVIP, Name: "VIP 6", AdminFree: true},
|
|
||||||
)
|
)
|
||||||
seedAbilities(t, service.db.(*gorm.DB),
|
seedAbilities(t, service.db.(*gorm.DB),
|
||||||
propsNobleVIPAbilityRow{ID: 201, VIPLevel: 3},
|
propsNobleVIPAbilityRow{ID: 201, VIPLevel: 3},
|
||||||
propsNobleVIPAbilityRow{ID: 204, VIPLevel: 4},
|
propsNobleVIPAbilityRow{ID: 204, VIPLevel: 4},
|
||||||
propsNobleVIPAbilityRow{ID: 205, VIPLevel: 5},
|
|
||||||
propsNobleVIPAbilityRow{ID: 206, VIPLevel: 6},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
resp, err := service.ListProps(context.Background(), AuthUser{UserID: 1}, "")
|
resp, err := service.ListProps(context.Background(), AuthUser{UserID: 1}, "")
|
||||||
@ -148,16 +120,10 @@ func TestListPropsRequiresAdminFreeAndFiltersVIPLevels(t *testing.T) {
|
|||||||
t.Fatalf("records = %+v, non-admin-free props must be hidden", resp.Records)
|
t.Fatalf("records = %+v, non-admin-free props must be hidden", resp.Records)
|
||||||
}
|
}
|
||||||
if item, ok := got[201]; !ok || item.Days != defaultVIPGiftDays || item.VIPLevel != 3 {
|
if item, ok := got[201]; !ok || item.Days != defaultVIPGiftDays || item.VIPLevel != 3 {
|
||||||
t.Fatalf("vip 201 = %+v, want level 3 with default VIP days", item)
|
t.Fatalf("vip 201 = %+v, want level 3 with 3 days", item)
|
||||||
}
|
}
|
||||||
if item, ok := got[204]; !ok || item.VIPLevel != 4 {
|
if _, ok := got[204]; ok {
|
||||||
t.Fatalf("vip 204 = %+v, want level 4 visible", item)
|
t.Fatalf("records = %+v, VIP level 4 must be hidden", resp.Records)
|
||||||
}
|
|
||||||
if item, ok := got[205]; !ok || item.VIPLevel != 5 {
|
|
||||||
t.Fatalf("vip 205 = %+v, want level 5 visible", item)
|
|
||||||
}
|
|
||||||
if _, ok := got[206]; ok {
|
|
||||||
t.Fatalf("records = %+v, VIP level 6 must be hidden", resp.Records)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -170,32 +136,18 @@ func TestListPropsReturnsVIPLevelConfigs(t *testing.T) {
|
|||||||
model.VipLevelConfig{ID: 302, SysOrigin: "LIKEI", Level: 2, LevelCode: "VIP2", DisplayName: "VIP 2", Enabled: false, DurationDays: 30},
|
model.VipLevelConfig{ID: 302, SysOrigin: "LIKEI", Level: 2, LevelCode: "VIP2", DisplayName: "VIP 2", Enabled: false, DurationDays: 30},
|
||||||
model.VipLevelConfig{ID: 303, SysOrigin: "ATYOU", Level: 1, LevelCode: "VIP1", DisplayName: "ATYOU VIP 1", Enabled: true, DurationDays: 30},
|
model.VipLevelConfig{ID: 303, SysOrigin: "ATYOU", Level: 1, LevelCode: "VIP1", DisplayName: "ATYOU VIP 1", Enabled: true, DurationDays: 30},
|
||||||
model.VipLevelConfig{ID: 304, SysOrigin: "LIKEI", Level: 4, LevelCode: "VIP4", DisplayName: "VIP 4", Enabled: true, DurationDays: 30},
|
model.VipLevelConfig{ID: 304, SysOrigin: "LIKEI", Level: 4, LevelCode: "VIP4", DisplayName: "VIP 4", Enabled: true, DurationDays: 30},
|
||||||
model.VipLevelConfig{ID: 305, SysOrigin: "LIKEI", Level: 5, LevelCode: "VIP5", DisplayName: "VIP 5", Enabled: true, DurationDays: 30},
|
|
||||||
model.VipLevelConfig{ID: 306, SysOrigin: "LIKEI", Level: 6, LevelCode: "VIP6", DisplayName: "VIP 6", Enabled: true, DurationDays: 30},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
resp, err := service.ListProps(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, propsTypeNobleVIP)
|
resp, err := service.ListProps(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, propsTypeNobleVIP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListProps() error = %v", err)
|
t.Fatalf("ListProps() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(resp.Records) != 3 {
|
if len(resp.Records) != 1 {
|
||||||
t.Fatalf("records = %+v, want enabled LIKEI vip configs up to level 5", resp.Records)
|
t.Fatalf("records = %+v, want only one enabled LIKEI vip config", resp.Records)
|
||||||
}
|
}
|
||||||
got := map[int64]PropsView{}
|
got := resp.Records[0]
|
||||||
for _, item := range resp.Records {
|
if got.ID.Int64() != 301 || got.Type != propsTypeNobleVIP || got.VIPLevel != 1 || got.Days != 30 {
|
||||||
got[item.ID.Int64()] = item
|
t.Fatalf("vip props = %+v, want config 301 level 1", got)
|
||||||
}
|
|
||||||
if item := got[301]; item.Type != propsTypeNobleVIP || item.VIPLevel != 1 || item.Days != 30 {
|
|
||||||
t.Fatalf("vip props 301 = %+v, want config 301 level 1", item)
|
|
||||||
}
|
|
||||||
if item := got[304]; item.Type != propsTypeNobleVIP || item.VIPLevel != 4 || item.Days != 30 {
|
|
||||||
t.Fatalf("vip props 304 = %+v, want config 304 level 4", item)
|
|
||||||
}
|
|
||||||
if item := got[305]; item.Type != propsTypeNobleVIP || item.VIPLevel != 5 || item.Days != 30 {
|
|
||||||
t.Fatalf("vip props 305 = %+v, want config 305 level 5", item)
|
|
||||||
}
|
|
||||||
if _, ok := got[306]; ok {
|
|
||||||
t.Fatalf("records = %+v, VIP level 6 must be hidden", resp.Records)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -215,27 +167,6 @@ func TestGetProfileToleratesRechargeQueryFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetProfileReturnsManagerFeatures(t *testing.T) {
|
|
||||||
service, _ := newManagerCenterTestService(t)
|
|
||||||
db := service.db.(*gorm.DB)
|
|
||||||
seedManagerWithFeatures(t, db, 1, ManagerFeatures{
|
|
||||||
AddBDLeader: true,
|
|
||||||
GiftProps: false,
|
|
||||||
BanUser: true,
|
|
||||||
UnbanUser: false,
|
|
||||||
ChangeCountry: true,
|
|
||||||
})
|
|
||||||
seedUsers(t, db, userBaseInfoRow{ID: 1, Account: "1"})
|
|
||||||
|
|
||||||
resp, err := service.GetProfile(context.Background(), AuthUser{UserID: 1})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetProfile() error = %v", err)
|
|
||||||
}
|
|
||||||
if !resp.Features.AddBDLeader || resp.Features.GiftProps || !resp.Features.BanUser || resp.Features.UnbanUser || !resp.Features.ChangeCountry {
|
|
||||||
t.Fatalf("features = %+v, want row switches returned", resp.Features)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAddBDLeaderCreatesLeaderAndListEntry(t *testing.T) {
|
func TestAddBDLeaderCreatesLeaderAndListEntry(t *testing.T) {
|
||||||
service, _ := newManagerCenterTestService(t)
|
service, _ := newManagerCenterTestService(t)
|
||||||
db := service.db.(*gorm.DB)
|
db := service.db.(*gorm.DB)
|
||||||
@ -303,24 +234,6 @@ func TestAddBDLeaderRejectsTargetFromOtherOrigin(t *testing.T) {
|
|||||||
assertAppErrorCode(t, err, "bd_leader_origin_mismatch")
|
assertAppErrorCode(t, err, "bd_leader_origin_mismatch")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAddBDLeaderRejectsDisabledFeature(t *testing.T) {
|
|
||||||
service, _ := newManagerCenterTestService(t)
|
|
||||||
db := service.db.(*gorm.DB)
|
|
||||||
seedManagerWithFeatures(t, db, 1, ManagerFeatures{GiftProps: true, BanUser: true, UnbanUser: true})
|
|
||||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2002", OriginSys: "LIKEI"})
|
|
||||||
|
|
||||||
_, err := service.AddBDLeader(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, AddBDLeaderRequest{UserID: ID(2)})
|
|
||||||
assertAppErrorCode(t, err, "manager_feature_disabled")
|
|
||||||
|
|
||||||
var count int64
|
|
||||||
if err := db.Model(&bdLeaderRow{}).Where("user_id = ?", int64(2)).Count(&count).Error; err != nil {
|
|
||||||
t.Fatalf("count bd leaders: %v", err)
|
|
||||||
}
|
|
||||||
if count != 0 {
|
|
||||||
t.Fatalf("bd leader count = %d, want 0", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendPropsRejectsNonAdminFree(t *testing.T) {
|
func TestSendPropsRejectsNonAdminFree(t *testing.T) {
|
||||||
service, fake := newManagerCenterTestService(t)
|
service, fake := newManagerCenterTestService(t)
|
||||||
db := service.db.(*gorm.DB)
|
db := service.db.(*gorm.DB)
|
||||||
@ -381,23 +294,6 @@ func TestSendNobleVIPGivesAccessoriesAndSwitchesMax(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSendPropsRejectsDisabledFeature(t *testing.T) {
|
|
||||||
service, fake := newManagerCenterTestService(t)
|
|
||||||
db := service.db.(*gorm.DB)
|
|
||||||
seedManagerWithFeatures(t, db, 1, ManagerFeatures{AddBDLeader: true, BanUser: true, UnbanUser: true})
|
|
||||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2"})
|
|
||||||
seedProps(t, db, propsSourceRecordRow{ID: 101, Type: propsTypeRide, Name: "Giftable ride", AdminFree: true})
|
|
||||||
|
|
||||||
_, err := service.SendProps(context.Background(), AuthUser{UserID: 1}, SendPropsRequest{
|
|
||||||
AcceptUserID: ID(2),
|
|
||||||
PropsID: ID(101),
|
|
||||||
})
|
|
||||||
assertAppErrorCode(t, err, "manager_feature_disabled")
|
|
||||||
if len(fake.giveRequests) != 0 {
|
|
||||||
t.Fatalf("give requests = %+v, want none", fake.giveRequests)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendBadgeActivatesBadgeBackpack(t *testing.T) {
|
func TestSendBadgeActivatesBadgeBackpack(t *testing.T) {
|
||||||
service, fake := newManagerCenterTestService(t)
|
service, fake := newManagerCenterTestService(t)
|
||||||
db := service.db.(*gorm.DB)
|
db := service.db.(*gorm.DB)
|
||||||
@ -437,12 +333,12 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
|
|||||||
seedVIPConfigs(t, db, model.VipLevelConfig{
|
seedVIPConfigs(t, db, model.VipLevelConfig{
|
||||||
ID: 301,
|
ID: 301,
|
||||||
SysOrigin: "LIKEI",
|
SysOrigin: "LIKEI",
|
||||||
Level: 5,
|
Level: 3,
|
||||||
LevelCode: "VIP5",
|
LevelCode: "VIP3",
|
||||||
DisplayName: "VIP 5",
|
DisplayName: "VIP 3",
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
DurationDays: 30,
|
DurationDays: 30,
|
||||||
PriceGold: 5000,
|
PriceGold: 3000,
|
||||||
})
|
})
|
||||||
|
|
||||||
resp, err := service.SendProps(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, SendPropsRequest{
|
resp, err := service.SendProps(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, SendPropsRequest{
|
||||||
@ -452,7 +348,7 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SendProps() error = %v", err)
|
t.Fatalf("SendProps() error = %v", err)
|
||||||
}
|
}
|
||||||
if !resp.Success || resp.Type != propsTypeNobleVIP || resp.VIPLevel != 5 || resp.Days != 30 {
|
if !resp.Success || resp.Type != propsTypeNobleVIP || resp.VIPLevel != 3 || resp.Days != 30 {
|
||||||
t.Fatalf("resp = %+v, want vip gift success", resp)
|
t.Fatalf("resp = %+v, want vip gift success", resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -460,8 +356,8 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
|
|||||||
if err := db.Where("sys_origin = ? AND user_id = ?", "LIKEI", int64(2)).First(&state).Error; err != nil {
|
if err := db.Where("sys_origin = ? AND user_id = ?", "LIKEI", int64(2)).First(&state).Error; err != nil {
|
||||||
t.Fatalf("load state: %v", err)
|
t.Fatalf("load state: %v", err)
|
||||||
}
|
}
|
||||||
if state.Level != 5 || state.Status != vipStatusActive {
|
if state.Level != 3 || state.Status != vipStatusActive {
|
||||||
t.Fatalf("state = %+v, want vip5 active", state)
|
t.Fatalf("state = %+v, want vip3 active", state)
|
||||||
}
|
}
|
||||||
var orders int64
|
var orders int64
|
||||||
if err := db.Model(&model.VipOrderRecord{}).Where("user_id = ? AND order_type = ?", int64(2), vipOrderTypeGMGift).Count(&orders).Error; err != nil {
|
if err := db.Model(&model.VipOrderRecord{}).Where("user_id = ? AND order_type = ?", int64(2), vipOrderTypeGMGift).Count(&orders).Error; err != nil {
|
||||||
@ -471,7 +367,7 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
|
|||||||
t.Fatalf("orders = %d, want 1", orders)
|
t.Fatalf("orders = %d, want 1", orders)
|
||||||
}
|
}
|
||||||
var records int64
|
var records int64
|
||||||
if err := db.Model(&gmVIPGiftRecordRow{}).Where("user_id = ? AND vip_level = ?", int64(2), 5).Count(&records).Error; err != nil {
|
if err := db.Model(&gmVIPGiftRecordRow{}).Where("user_id = ? AND vip_level = ?", int64(2), 3).Count(&records).Error; err != nil {
|
||||||
t.Fatalf("count gift records: %v", err)
|
t.Fatalf("count gift records: %v", err)
|
||||||
}
|
}
|
||||||
if records != 1 {
|
if records != 1 {
|
||||||
@ -532,9 +428,9 @@ func TestSendVIPRejectsLevelAboveGiftLimit(t *testing.T) {
|
|||||||
seedVIPConfigs(t, db, model.VipLevelConfig{
|
seedVIPConfigs(t, db, model.VipLevelConfig{
|
||||||
ID: 304,
|
ID: 304,
|
||||||
SysOrigin: "LIKEI",
|
SysOrigin: "LIKEI",
|
||||||
Level: 6,
|
Level: 4,
|
||||||
LevelCode: "VIP6",
|
LevelCode: "VIP4",
|
||||||
DisplayName: "VIP 6",
|
DisplayName: "VIP 4",
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
DurationDays: 30,
|
DurationDays: 30,
|
||||||
})
|
})
|
||||||
@ -617,19 +513,6 @@ func assertManagerVIPCleanupBadge(t *testing.T, db *gorm.DB, badgeID int64, expi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBanUserRejectsDisabledFeature(t *testing.T) {
|
|
||||||
service, fake := newManagerCenterTestService(t)
|
|
||||||
db := service.db.(*gorm.DB)
|
|
||||||
seedManagerWithFeatures(t, db, 1, ManagerFeatures{AddBDLeader: true, GiftProps: true, UnbanUser: true})
|
|
||||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2"})
|
|
||||||
|
|
||||||
_, err := service.BanUser(context.Background(), AuthUser{UserID: 1}, BanUserRequest{UserID: ID(2)})
|
|
||||||
assertAppErrorCode(t, err, "manager_feature_disabled")
|
|
||||||
if len(fake.punishRequests) != 0 {
|
|
||||||
t.Fatalf("punish requests = %+v, want none", fake.punishRequests)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBanUserRejectsRechargedUser(t *testing.T) {
|
func TestBanUserRejectsRechargedUser(t *testing.T) {
|
||||||
service, fake := newManagerCenterTestService(t)
|
service, fake := newManagerCenterTestService(t)
|
||||||
db := service.db.(*gorm.DB)
|
db := service.db.(*gorm.DB)
|
||||||
@ -709,19 +592,6 @@ func TestBanUserWithoutRechargeCallsPunishment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnbanUserRejectsDisabledFeature(t *testing.T) {
|
|
||||||
service, fake := newManagerCenterTestService(t)
|
|
||||||
db := service.db.(*gorm.DB)
|
|
||||||
seedManagerWithFeatures(t, db, 1, ManagerFeatures{AddBDLeader: true, GiftProps: true, BanUser: true})
|
|
||||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", AccountStatus: accountStatusArchive})
|
|
||||||
|
|
||||||
_, err := service.UnbanUser(context.Background(), AuthUser{UserID: 1}, UnbanUserRequest{UserID: ID(2)})
|
|
||||||
assertAppErrorCode(t, err, "manager_feature_disabled")
|
|
||||||
if len(fake.unblockRequests) != 0 {
|
|
||||||
t.Fatalf("unblock requests = %+v, want none", fake.unblockRequests)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUnbanUserRejectsNormalUser(t *testing.T) {
|
func TestUnbanUserRejectsNormalUser(t *testing.T) {
|
||||||
service, fake := newManagerCenterTestService(t)
|
service, fake := newManagerCenterTestService(t)
|
||||||
db := service.db.(*gorm.DB)
|
db := service.db.(*gorm.DB)
|
||||||
@ -781,120 +651,6 @@ func TestUnbanUserAllowsFrozenUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListCountriesReturnsOpenCountries(t *testing.T) {
|
|
||||||
service, _ := newManagerCenterTestService(t)
|
|
||||||
db := service.db.(*gorm.DB)
|
|
||||||
seedManager(t, db, 1)
|
|
||||||
seedCountries(t, db,
|
|
||||||
sysCountryCodeRow{ID: 10, AlphaTwo: "SA", CountryName: "Saudi Arabia", Open: true, Sort: 2},
|
|
||||||
sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true, Sort: 1},
|
|
||||||
sysCountryCodeRow{ID: 12, AlphaTwo: "XX", CountryName: "Closed", Open: false, Sort: 3},
|
|
||||||
)
|
|
||||||
|
|
||||||
resp, err := service.ListCountries(context.Background(), AuthUser{UserID: 1})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListCountries() error = %v", err)
|
|
||||||
}
|
|
||||||
if len(resp.Records) != 2 {
|
|
||||||
t.Fatalf("countries = %+v, want two open rows", resp.Records)
|
|
||||||
}
|
|
||||||
if resp.Records[0].CountryCode != "AE" || resp.Records[1].CountryCode != "SA" {
|
|
||||||
t.Fatalf("countries = %+v, want sort order AE then SA", resp.Records)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChangeUserCountryDelegatesToJavaAndClearsCache(t *testing.T) {
|
|
||||||
service, fake := newManagerCenterTestService(t)
|
|
||||||
db := service.db.(*gorm.DB)
|
|
||||||
seedManager(t, db, 1)
|
|
||||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA", CountryName: "Saudi Arabia"})
|
|
||||||
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
|
|
||||||
|
|
||||||
resp, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{
|
|
||||||
UserID: ID(2),
|
|
||||||
CountryID: ID(11),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ChangeUserCountry() error = %v", err)
|
|
||||||
}
|
|
||||||
if !resp.Success || resp.User.CountryCode != "AE" || resp.User.CountryName != "United Arab Emirates" {
|
|
||||||
t.Fatalf("resp = %+v, want changed country", resp)
|
|
||||||
}
|
|
||||||
if len(fake.countryUpdates) != 1 || fake.countryUpdates[0] != (fakeCountryUpdate{userID: 2, countryID: 11}) {
|
|
||||||
t.Fatalf("countryUpdates = %+v, want user 2 country 11", fake.countryUpdates)
|
|
||||||
}
|
|
||||||
|
|
||||||
var row userBaseInfoRow
|
|
||||||
if err := db.Where("id = ?", int64(2)).First(&row).Error; err != nil {
|
|
||||||
t.Fatalf("load user: %v", err)
|
|
||||||
}
|
|
||||||
if row.CountryID != 10 || row.CountryCode != "SA" || row.CountryName != "Saudi Arabia" {
|
|
||||||
t.Fatalf("user row = %+v, want Go service not to write country directly", row)
|
|
||||||
}
|
|
||||||
if !equalInt64Slice(fake.cacheCleared, []int64{2}) {
|
|
||||||
t.Fatalf("cacheCleared = %+v, want [2]", fake.cacheCleared)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChangeUserCountryRejectsDisabledFeature(t *testing.T) {
|
|
||||||
service, _ := newManagerCenterTestService(t)
|
|
||||||
db := service.db.(*gorm.DB)
|
|
||||||
seedManagerWithFeatures(t, db, 1, ManagerFeatures{AddBDLeader: true, GiftProps: true, BanUser: true, UnbanUser: true})
|
|
||||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA", CountryName: "Saudi Arabia"})
|
|
||||||
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
|
|
||||||
|
|
||||||
_, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{UserID: ID(2), CountryID: ID(11)})
|
|
||||||
assertAppErrorCode(t, err, "manager_feature_disabled")
|
|
||||||
|
|
||||||
var row userBaseInfoRow
|
|
||||||
if err := db.Where("id = ?", int64(2)).First(&row).Error; err != nil {
|
|
||||||
t.Fatalf("load user: %v", err)
|
|
||||||
}
|
|
||||||
if row.CountryID != 10 || row.CountryCode != "SA" {
|
|
||||||
t.Fatalf("user row = %+v, want unchanged country when feature disabled", row)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChangeUserCountryRejectsHistoryIdentity(t *testing.T) {
|
|
||||||
service, _ := newManagerCenterTestService(t)
|
|
||||||
db := service.db.(*gorm.DB)
|
|
||||||
seedManager(t, db, 1)
|
|
||||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA"})
|
|
||||||
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
|
|
||||||
if err := db.Create(&userHistoryIdentityRow{UserID: 2, IsHost: true}).Error; err != nil {
|
|
||||||
t.Fatalf("seed identity: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{UserID: ID(2), CountryID: ID(11)})
|
|
||||||
assertAppErrorCode(t, err, "user_identity_locked")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChangeUserCountryRejectsBDLeader(t *testing.T) {
|
|
||||||
service, _ := newManagerCenterTestService(t)
|
|
||||||
db := service.db.(*gorm.DB)
|
|
||||||
seedManager(t, db, 1)
|
|
||||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA"})
|
|
||||||
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
|
|
||||||
if err := db.Create(&bdLeaderRow{ID: 20, UserID: 2, Origin: "LIKEI", RegionID: "SA"}).Error; err != nil {
|
|
||||||
t.Fatalf("seed bd leader: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{UserID: ID(2), CountryID: ID(11)})
|
|
||||||
assertAppErrorCode(t, err, "user_identity_locked")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChangeUserCountryRejectsCoinSeller(t *testing.T) {
|
|
||||||
service, fake := newManagerCenterTestService(t)
|
|
||||||
db := service.db.(*gorm.DB)
|
|
||||||
seedManager(t, db, 1)
|
|
||||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA"})
|
|
||||||
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
|
|
||||||
fake.freightSellers[2] = true
|
|
||||||
|
|
||||||
_, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{UserID: ID(2), CountryID: ID(11)})
|
|
||||||
assertAppErrorCode(t, err, "user_identity_locked")
|
|
||||||
}
|
|
||||||
|
|
||||||
func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
|
func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||||
@ -904,7 +660,6 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
|
|||||||
if err := db.AutoMigrate(
|
if err := db.AutoMigrate(
|
||||||
&teamManagerInfoRow{},
|
&teamManagerInfoRow{},
|
||||||
&userBaseInfoRow{},
|
&userBaseInfoRow{},
|
||||||
&sysCountryCodeRow{},
|
|
||||||
&bdLeaderRow{},
|
&bdLeaderRow{},
|
||||||
&businessDevelopmentBaseInfoRow{},
|
&businessDevelopmentBaseInfoRow{},
|
||||||
&userHistoryIdentityRow{},
|
&userHistoryIdentityRow{},
|
||||||
@ -920,7 +675,6 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
|
|||||||
}
|
}
|
||||||
fake := &fakeManagerGateway{
|
fake := &fakeManagerGateway{
|
||||||
recharges: map[int64][]integration.UserTotalRecharge{},
|
recharges: map[int64][]integration.UserTotalRecharge{},
|
||||||
freightSellers: map[int64]bool{},
|
|
||||||
abilities: map[int64]integration.PropsNobleVIPAbility{},
|
abilities: map[int64]integration.PropsNobleVIPAbility{},
|
||||||
}
|
}
|
||||||
return NewService(db, fake), fake
|
return NewService(db, fake), fake
|
||||||
@ -928,36 +682,11 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
|
|||||||
|
|
||||||
func seedManager(t *testing.T, db *gorm.DB, userID int64) {
|
func seedManager(t *testing.T, db *gorm.DB, userID int64) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
seedManagerWithFeatures(t, db, userID, ManagerFeatures{
|
if err := db.Create(&teamManagerInfoRow{ID: userID, UserID: userID, Origin: "LIKEI", RegionID: "SA"}).Error; err != nil {
|
||||||
AddBDLeader: true,
|
|
||||||
GiftProps: true,
|
|
||||||
BanUser: true,
|
|
||||||
UnbanUser: true,
|
|
||||||
ChangeCountry: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func seedManagerWithFeatures(t *testing.T, db *gorm.DB, userID int64, features ManagerFeatures) {
|
|
||||||
t.Helper()
|
|
||||||
if err := db.Create(&teamManagerInfoRow{
|
|
||||||
ID: userID,
|
|
||||||
UserID: userID,
|
|
||||||
Origin: "LIKEI",
|
|
||||||
RegionID: "SA",
|
|
||||||
CanAddBDLeader: boolPtr(features.AddBDLeader),
|
|
||||||
CanGiftProps: boolPtr(features.GiftProps),
|
|
||||||
CanBanUser: boolPtr(features.BanUser),
|
|
||||||
CanUnbanUser: boolPtr(features.UnbanUser),
|
|
||||||
CanChangeCountry: boolPtr(features.ChangeCountry),
|
|
||||||
}).Error; err != nil {
|
|
||||||
t.Fatalf("seed manager: %v", err)
|
t.Fatalf("seed manager: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func boolPtr(value bool) *bool {
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
|
|
||||||
func seedUsers(t *testing.T, db *gorm.DB, rows ...userBaseInfoRow) {
|
func seedUsers(t *testing.T, db *gorm.DB, rows ...userBaseInfoRow) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
@ -970,15 +699,6 @@ func seedUsers(t *testing.T, db *gorm.DB, rows ...userBaseInfoRow) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func seedCountries(t *testing.T, db *gorm.DB, rows ...sysCountryCodeRow) {
|
|
||||||
t.Helper()
|
|
||||||
for _, row := range rows {
|
|
||||||
if err := db.Create(&row).Error; err != nil {
|
|
||||||
t.Fatalf("seed country: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func seedProps(t *testing.T, db *gorm.DB, rows ...propsSourceRecordRow) {
|
func seedProps(t *testing.T, db *gorm.DB, rows ...propsSourceRecordRow) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
|
|||||||
@ -28,14 +28,9 @@ const (
|
|||||||
vipGiftOrigin = "VIP_PURCHASE"
|
vipGiftOrigin = "VIP_PURCHASE"
|
||||||
vipGiftOriginDesc = "VIP purchase"
|
vipGiftOriginDesc = "VIP purchase"
|
||||||
|
|
||||||
defaultGiftDays = 30
|
defaultGiftDays = 7
|
||||||
defaultVIPGiftDays = 30
|
defaultVIPGiftDays = 30
|
||||||
// 经理赠送统一限制为运营确认的三个有效期,避免客户端提交任意天数。
|
maxGiftableVIPLevel = 3
|
||||||
giftDays30 = 30
|
|
||||||
giftDays60 = 60
|
|
||||||
giftDays90 = 90
|
|
||||||
// 经理中心 VIP 赠送按当前运营范围放开 VIP1-VIP5;列表和发放校验共用该上限,避免展示可选但提交失败。
|
|
||||||
maxGiftableVIPLevel = 5
|
|
||||||
|
|
||||||
accountStatusNormal = "NORMAL"
|
accountStatusNormal = "NORMAL"
|
||||||
accountStatusFreeze = "FREEZE"
|
accountStatusFreeze = "FREEZE"
|
||||||
@ -43,16 +38,6 @@ const (
|
|||||||
accountStatusArchiveDevice = "ARCHIVE_DEVICE"
|
accountStatusArchiveDevice = "ARCHIVE_DEVICE"
|
||||||
)
|
)
|
||||||
|
|
||||||
type managerFeature string
|
|
||||||
|
|
||||||
const (
|
|
||||||
managerFeatureAddBDLeader managerFeature = "add_bd_leader"
|
|
||||||
managerFeatureGiftProps managerFeature = "gift_props"
|
|
||||||
managerFeatureBanUser managerFeature = "ban_user"
|
|
||||||
managerFeatureUnbanUser managerFeature = "unban_user"
|
|
||||||
managerFeatureChangeCountry managerFeature = "change_country"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AppError = common.AppError
|
type AppError = common.AppError
|
||||||
type AuthUser = common.AuthUser
|
type AuthUser = common.AuthUser
|
||||||
|
|
||||||
@ -64,10 +49,8 @@ type managerDB interface {
|
|||||||
|
|
||||||
type managerGateway interface {
|
type managerGateway interface {
|
||||||
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error)
|
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error)
|
||||||
UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error
|
|
||||||
GetUserProfileByAccountOne(ctx context.Context, account string) (integration.UserProfile, error)
|
GetUserProfileByAccountOne(ctx context.Context, account string) (integration.UserProfile, error)
|
||||||
MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error)
|
MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error)
|
||||||
ExistsFreightSeller(ctx context.Context, userID int64) (bool, error)
|
|
||||||
GivePropsBackpack(ctx context.Context, req integration.GivePropsBackpackRequest) error
|
GivePropsBackpack(ctx context.Context, req integration.GivePropsBackpackRequest) error
|
||||||
GetNobleVIPAbility(ctx context.Context, sourceID int64) (integration.PropsNobleVIPAbility, error)
|
GetNobleVIPAbility(ctx context.Context, sourceID int64) (integration.PropsNobleVIPAbility, error)
|
||||||
GetUserMaxNobleVIPAbility(ctx context.Context, userID int64) (integration.PropsNobleVIPAbility, error)
|
GetUserMaxNobleVIPAbility(ctx context.Context, userID int64) (integration.PropsNobleVIPAbility, error)
|
||||||
@ -124,39 +107,17 @@ func (id *ID) UnmarshalJSON(data []byte) error {
|
|||||||
|
|
||||||
type ProfileResponse struct {
|
type ProfileResponse struct {
|
||||||
Manager UserView `json:"manager"`
|
Manager UserView `json:"manager"`
|
||||||
Features ManagerFeatures `json:"features"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ManagerFeatures struct {
|
|
||||||
AddBDLeader bool `json:"addBdLeader"`
|
|
||||||
GiftProps bool `json:"giftProps"`
|
|
||||||
BanUser bool `json:"banUser"`
|
|
||||||
UnbanUser bool `json:"unbanUser"`
|
|
||||||
ChangeCountry bool `json:"changeCountry"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserSearchResponse struct {
|
type UserSearchResponse struct {
|
||||||
User UserView `json:"user"`
|
User UserView `json:"user"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CountryListResponse struct {
|
|
||||||
Records []CountryView `json:"records"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CountryView struct {
|
|
||||||
ID ID `json:"id"`
|
|
||||||
CountryCode string `json:"countryCode"`
|
|
||||||
CountryName string `json:"countryName"`
|
|
||||||
NationalFlag string `json:"nationalFlag,omitempty"`
|
|
||||||
PhonePrefix int `json:"phonePrefix,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserView struct {
|
type UserView struct {
|
||||||
UserID ID `json:"userId"`
|
UserID ID `json:"userId"`
|
||||||
Account string `json:"account"`
|
Account string `json:"account"`
|
||||||
UserAvatar string `json:"userAvatar"`
|
UserAvatar string `json:"userAvatar"`
|
||||||
UserNickname string `json:"userNickname"`
|
UserNickname string `json:"userNickname"`
|
||||||
CountryID ID `json:"countryId,omitempty"`
|
|
||||||
CountryCode string `json:"countryCode,omitempty"`
|
CountryCode string `json:"countryCode,omitempty"`
|
||||||
CountryName string `json:"countryName,omitempty"`
|
CountryName string `json:"countryName,omitempty"`
|
||||||
OriginSys string `json:"originSys,omitempty"`
|
OriginSys string `json:"originSys,omitempty"`
|
||||||
@ -185,7 +146,6 @@ type PropsView struct {
|
|||||||
type SendPropsRequest struct {
|
type SendPropsRequest struct {
|
||||||
AcceptUserID ID `json:"acceptUserId"`
|
AcceptUserID ID `json:"acceptUserId"`
|
||||||
PropsID ID `json:"propsId"`
|
PropsID ID `json:"propsId"`
|
||||||
Days int `json:"days"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SendPropsResponse struct {
|
type SendPropsResponse struct {
|
||||||
@ -219,16 +179,6 @@ type UnbanUserResponse struct {
|
|||||||
AccountStatus string `json:"accountStatus"`
|
AccountStatus string `json:"accountStatus"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChangeUserCountryRequest struct {
|
|
||||||
UserID ID `json:"userId"`
|
|
||||||
CountryID ID `json:"countryId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChangeUserCountryResponse struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
User UserView `json:"user"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BDLeaderListResponse struct {
|
type BDLeaderListResponse struct {
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
Records []BDLeaderView `json:"records"`
|
Records []BDLeaderView `json:"records"`
|
||||||
|
|||||||
@ -79,7 +79,7 @@ func (s *Service) listVIPProps(ctx context.Context, user AuthUser) (*PropsListRe
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) sendVIPGift(ctx context.Context, manager AuthUser, target userBaseInfoRow, configID int64, days int) (*SendPropsResponse, error) {
|
func (s *Service) sendVIPGift(ctx context.Context, manager AuthUser, target userBaseInfoRow, configID int64) (*SendPropsResponse, error) {
|
||||||
sysOrigin := normalizeSysOrigin(target.OriginSys)
|
sysOrigin := normalizeSysOrigin(target.OriginSys)
|
||||||
config, found, err := s.loadVIPConfigByID(ctx, sysOrigin, configID)
|
config, found, err := s.loadVIPConfigByID(ctx, sysOrigin, configID)
|
||||||
if err != nil || !found {
|
if err != nil || !found {
|
||||||
@ -91,8 +91,9 @@ func (s *Service) sendVIPGift(ctx context.Context, manager AuthUser, target user
|
|||||||
if config.Level < 1 || config.Level > maxGiftableVIPLevel {
|
if config.Level < 1 || config.Level > maxGiftableVIPLevel {
|
||||||
return nil, badRequest("vip_not_giftable", "vip level is invalid")
|
return nil, badRequest("vip_not_giftable", "vip level is invalid")
|
||||||
}
|
}
|
||||||
// 经理选择的赠送天数覆盖 VIP 售卖配置时长,订单、状态和附属权益保持同一到期口径。
|
if config.DurationDays <= 0 {
|
||||||
config.DurationDays = days
|
config.DurationDays = defaultVIPGiftDays
|
||||||
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
orderID, err := utils.NextID()
|
orderID, err := utils.NextID()
|
||||||
|
|||||||
@ -117,7 +117,7 @@ ORDER BY claims.last_claim_time DESC, claims.user_id ASC`
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rewardItems := s.loadRewardItemsMap(ctx, sysOrigin, claimDetailLevels(details))
|
rewardItems := s.loadRewardItemsMap(ctx, claimDetailLevels(details))
|
||||||
records = make([]RechargeRewardClaimRecordView, 0, len(rows))
|
records = make([]RechargeRewardClaimRecordView, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
records = append(records, rechargeRewardClaimRecordView(row, displayMonth, rechargeDate, details[row.UserID], rewardItems))
|
records = append(records, rechargeRewardClaimRecordView(row, displayMonth, rechargeDate, details[row.UserID], rewardItems))
|
||||||
|
|||||||
@ -39,7 +39,7 @@ func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*RechargeRew
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rewardItems := s.loadRewardItemsMap(ctx, configRow.SysOrigin, levels)
|
rewardItems := s.loadRewardItemsMap(ctx, levels)
|
||||||
return &RechargeRewardConfigResponse{
|
return &RechargeRewardConfigResponse{
|
||||||
Configured: true,
|
Configured: true,
|
||||||
ID: configRow.ID,
|
ID: configRow.ID,
|
||||||
@ -60,7 +60,7 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveRechargeRewardConfigRe
|
|||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error())
|
return nil, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
levels, err := s.normalizeLevelInputs(ctx, sysOrigin, req.LevelConfigs)
|
levels, err := s.normalizeLevelInputs(req.LevelConfigs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -133,7 +133,7 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveRechargeRewardConfigRe
|
|||||||
return s.GetConfig(ctx, rechargeRewardSysOriginBySavedID(ctx, s.db, savedConfigID, sysOrigin))
|
return s.GetConfig(ctx, rechargeRewardSysOriginBySavedID(ctx, s.db, savedConfigID, sysOrigin))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) normalizeLevelInputs(ctx context.Context, sysOrigin string, inputs []RechargeRewardLevelInput) ([]model.RechargeRewardLevel, error) {
|
func (s *Service) normalizeLevelInputs(inputs []RechargeRewardLevelInput) ([]model.RechargeRewardLevel, error) {
|
||||||
if len(inputs) == 0 {
|
if len(inputs) == 0 {
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_level_configs", "levelConfigs is required")
|
return nil, NewAppError(http.StatusBadRequest, "invalid_level_configs", "levelConfigs is required")
|
||||||
}
|
}
|
||||||
@ -152,7 +152,7 @@ func (s *Service) normalizeLevelInputs(ctx context.Context, sysOrigin string, in
|
|||||||
if amountCents <= 0 {
|
if amountCents <= 0 {
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_recharge_amount", "rechargeAmount must be greater than 0")
|
return nil, NewAppError(http.StatusBadRequest, "invalid_recharge_amount", "rechargeAmount must be greater than 0")
|
||||||
}
|
}
|
||||||
rewardGroupID := s.resolveRewardGroupID(ctx, sysOrigin, item.RewardGroupID.Int64Ptr(), item.RewardGroupName)
|
rewardGroupID := item.RewardGroupID.Int64Ptr()
|
||||||
if item.RewardGold <= 0 && (rewardGroupID == nil || *rewardGroupID <= 0) {
|
if item.RewardGold <= 0 && (rewardGroupID == nil || *rewardGroupID <= 0) {
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_reward", "rewardGold or rewardGroupId is required")
|
return nil, NewAppError(http.StatusBadRequest, "invalid_reward", "rewardGold or rewardGroupId is required")
|
||||||
}
|
}
|
||||||
@ -188,22 +188,6 @@ func (s *Service) normalizeLevelInputs(ctx context.Context, sysOrigin string, in
|
|||||||
return rows, nil
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) resolveRewardGroupID(ctx context.Context, sysOrigin string, rewardGroupID *int64, rewardGroupName string) *int64 {
|
|
||||||
normalized := normalizeRewardGroupID(rewardGroupID)
|
|
||||||
if normalized == nil || s.java == nil || strings.TrimSpace(rewardGroupName) == "" {
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
if detail, err := s.java.GetRewardGroupDetail(ctx, *normalized); err == nil && rewardGroupDetailHasItems(detail) {
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
detail, err := s.java.FindRewardGroupDetail(ctx, sysOrigin, rewardGroupName)
|
|
||||||
if err != nil || int64(detail.ID) <= 0 {
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
resolved := int64(detail.ID)
|
|
||||||
return &resolved
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) loadConfigBundle(ctx context.Context, sysOrigin string) (*rechargeRewardConfigBundle, error) {
|
func (s *Service) loadConfigBundle(ctx context.Context, sysOrigin string) (*rechargeRewardConfigBundle, error) {
|
||||||
sysOrigin = s.normalizeSysOrigin(sysOrigin)
|
sysOrigin = s.normalizeSysOrigin(sysOrigin)
|
||||||
|
|
||||||
|
|||||||
@ -224,46 +224,6 @@ INSERT INTO likei_wallet.user_freight_balance_running_water (accept_user_id, sys
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetHomeFallsBackToRewardGroupNameWhenStoredIDLostPrecision(t *testing.T) {
|
|
||||||
actualGroupID := int64(2056723577461338114)
|
|
||||||
roundedGroupID := int64(2056723577461338000)
|
|
||||||
service, _ := newTestService(t, rechargeRewardTestGateway{
|
|
||||||
groups: map[int64]integration.RewardGroupDetail{
|
|
||||||
actualGroupID: {
|
|
||||||
ID: integration.Int64Value(actualGroupID),
|
|
||||||
Name: "累充活动$50",
|
|
||||||
RewardConfigList: []integration.RewardGroupItem{
|
|
||||||
{ID: integration.Int64Value(1), Type: "BADGE", Name: "recharge50$", Quantity: integration.Int64Value(30), Cover: "https://example.com/badge.png"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if _, err := service.SaveConfig(context.Background(), SaveRechargeRewardConfigRequest{
|
|
||||||
SysOrigin: "LIKEI",
|
|
||||||
Enabled: true,
|
|
||||||
Timezone: "Asia/Riyadh",
|
|
||||||
LevelConfigs: []RechargeRewardLevelInput{
|
|
||||||
{Level: 1, RechargeAmount: mustAmount(t, "50"), RewardGroupID: ptrFlexibleInt64(roundedGroupID), RewardGroupName: "累充活动$50", Enabled: true},
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("SaveConfig() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := service.GetConfig(context.Background(), "LIKEI")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetConfig() error = %v", err)
|
|
||||||
}
|
|
||||||
if len(resp.LevelConfigs) != 1 || len(resp.LevelConfigs[0].RewardItems) != 1 {
|
|
||||||
t.Fatalf("reward items = %+v, want fallback by reward group name", resp.LevelConfigs)
|
|
||||||
}
|
|
||||||
if resp.LevelConfigs[0].RewardGroupID == nil || *resp.LevelConfigs[0].RewardGroupID != actualGroupID {
|
|
||||||
t.Fatalf("rewardGroupId = %v, want resolved actual group id %d", resp.LevelConfigs[0].RewardGroupID, actualGroupID)
|
|
||||||
}
|
|
||||||
if got := resp.LevelConfigs[0].RewardItems[0].Name; got != "recharge50$" {
|
|
||||||
t.Fatalf("reward item name = %q, want recharge50$", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPageClaimRecordsUsesActualReceiveRecords(t *testing.T) {
|
func TestPageClaimRecordsUsesActualReceiveRecords(t *testing.T) {
|
||||||
groupID := int64(9001)
|
groupID := int64(9001)
|
||||||
service, db := newTestService(t, rechargeRewardTestGateway{
|
service, db := newTestService(t, rechargeRewardTestGateway{
|
||||||
@ -448,15 +408,6 @@ func (g rechargeRewardTestGateway) GetRewardGroupDetail(_ context.Context, group
|
|||||||
return integration.RewardGroupDetail{}, fmt.Errorf("group not found")
|
return integration.RewardGroupDetail{}, fmt.Errorf("group not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g rechargeRewardTestGateway) FindRewardGroupDetail(_ context.Context, _ string, name string) (integration.RewardGroupDetail, error) {
|
|
||||||
for _, detail := range g.groups {
|
|
||||||
if detail.Name == name {
|
|
||||||
return detail, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return integration.RewardGroupDetail{}, fmt.Errorf("group not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g rechargeRewardTestGateway) MapUserTotalRecharge(context.Context, []int64) (map[int64][]integration.UserTotalRecharge, error) {
|
func (g rechargeRewardTestGateway) MapUserTotalRecharge(context.Context, []int64) (map[int64][]integration.UserTotalRecharge, error) {
|
||||||
return g.total, nil
|
return g.total, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -83,7 +83,7 @@ func (s *Service) GetHome(ctx context.Context, user AuthUser) (*RechargeRewardHo
|
|||||||
resp.NextRechargeAmount = formatAmountCents(next.RechargeAmountCents)
|
resp.NextRechargeAmount = formatAmountCents(next.RechargeAmountCents)
|
||||||
resp.NextRechargeAmountCents = next.RechargeAmountCents
|
resp.NextRechargeAmountCents = next.RechargeAmountCents
|
||||||
}
|
}
|
||||||
rewardItems := s.loadRewardItemsMap(ctx, resp.SysOrigin, bundle.Levels)
|
rewardItems := s.loadRewardItemsMap(ctx, bundle.Levels)
|
||||||
matchedLevel := 0
|
matchedLevel := 0
|
||||||
if matchedOK {
|
if matchedOK {
|
||||||
matchedLevel = matched.Level
|
matchedLevel = matched.Level
|
||||||
|
|||||||
@ -131,7 +131,6 @@ func sortLevels(levels []rechargeRewardLevelSnapshot) {
|
|||||||
|
|
||||||
func (s *Service) loadRewardItemsMap(
|
func (s *Service) loadRewardItemsMap(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sysOrigin string,
|
|
||||||
levels []rechargeRewardLevelSnapshot,
|
levels []rechargeRewardLevelSnapshot,
|
||||||
) map[int64][]RechargeRewardRewardItem {
|
) map[int64][]RechargeRewardRewardItem {
|
||||||
if s.java == nil {
|
if s.java == nil {
|
||||||
@ -139,14 +138,10 @@ func (s *Service) loadRewardItemsMap(
|
|||||||
}
|
}
|
||||||
groupIDs := make([]int64, 0)
|
groupIDs := make([]int64, 0)
|
||||||
seen := make(map[int64]struct{})
|
seen := make(map[int64]struct{})
|
||||||
groupNames := make(map[int64]string)
|
|
||||||
for _, level := range levels {
|
for _, level := range levels {
|
||||||
if level.RewardGroupID == nil || *level.RewardGroupID <= 0 {
|
if level.RewardGroupID == nil || *level.RewardGroupID <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(level.RewardGroupName) != "" {
|
|
||||||
groupNames[*level.RewardGroupID] = level.RewardGroupName
|
|
||||||
}
|
|
||||||
if _, exists := seen[*level.RewardGroupID]; exists {
|
if _, exists := seen[*level.RewardGroupID]; exists {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -158,23 +153,15 @@ func (s *Service) loadRewardItemsMap(
|
|||||||
result := make(map[int64][]RechargeRewardRewardItem, len(groupIDs))
|
result := make(map[int64][]RechargeRewardRewardItem, len(groupIDs))
|
||||||
for _, groupID := range groupIDs {
|
for _, groupID := range groupIDs {
|
||||||
detail, err := s.java.GetRewardGroupDetail(ctx, groupID)
|
detail, err := s.java.GetRewardGroupDetail(ctx, groupID)
|
||||||
if err != nil || !rewardGroupDetailHasItems(detail) {
|
if err != nil {
|
||||||
if fallback, fallbackErr := s.java.FindRewardGroupDetail(ctx, sysOrigin, groupNames[groupID]); fallbackErr == nil && rewardGroupDetailHasItems(fallback) {
|
|
||||||
detail = fallback
|
|
||||||
} else {
|
|
||||||
result[groupID] = []RechargeRewardRewardItem{}
|
result[groupID] = []RechargeRewardRewardItem{}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
|
||||||
result[groupID] = rewardItemsFromGroup(detail)
|
result[groupID] = rewardItemsFromGroup(detail)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func rewardGroupDetailHasItems(detail integration.RewardGroupDetail) bool {
|
|
||||||
return len(detail.RewardConfigList) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func rewardItemsFromGroup(detail integration.RewardGroupDetail) []RechargeRewardRewardItem {
|
func rewardItemsFromGroup(detail integration.RewardGroupDetail) []RechargeRewardRewardItem {
|
||||||
items := make([]RechargeRewardRewardItem, 0, len(detail.RewardConfigList))
|
items := make([]RechargeRewardRewardItem, 0, len(detail.RewardConfigList))
|
||||||
for _, item := range detail.RewardConfigList {
|
for _, item := range detail.RewardConfigList {
|
||||||
|
|||||||
@ -35,7 +35,6 @@ type rechargeRewardDB interface {
|
|||||||
type rechargeRewardJavaGateway interface {
|
type rechargeRewardJavaGateway interface {
|
||||||
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error)
|
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error)
|
||||||
GetRewardGroupDetail(ctx context.Context, groupID int64) (integration.RewardGroupDetail, error)
|
GetRewardGroupDetail(ctx context.Context, groupID int64) (integration.RewardGroupDetail, error)
|
||||||
FindRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (integration.RewardGroupDetail, error)
|
|
||||||
MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error)
|
MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error)
|
||||||
GetThisMonthTotalPersonalRecharge(ctx context.Context, userID int64) (integration.DecimalString, error)
|
GetThisMonthTotalPersonalRecharge(ctx context.Context, userID int64) (integration.DecimalString, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,11 +20,6 @@ import (
|
|||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
defaultTaskCenterArchiveBatchSize = 500
|
|
||||||
maxTaskCenterArchiveBatchSize = 1000
|
|
||||||
)
|
|
||||||
|
|
||||||
// StartArchiveWorker 启动任务中心事件 COS 归档 worker。
|
// StartArchiveWorker 启动任务中心事件 COS 归档 worker。
|
||||||
func (s *Service) StartArchiveWorker(ctx context.Context) error {
|
func (s *Service) StartArchiveWorker(ctx context.Context) error {
|
||||||
if !s.cfg.TaskCenter.Archive.Enabled {
|
if !s.cfg.TaskCenter.Archive.Enabled {
|
||||||
@ -73,7 +68,10 @@ func (s *Service) runArchiveWorker(ctx context.Context, workerIndex int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) archivePendingEvents(ctx context.Context) error {
|
func (s *Service) archivePendingEvents(ctx context.Context) error {
|
||||||
batchSize := normalizeTaskCenterArchiveBatchSize(s.cfg.TaskCenter.Archive.BatchSize)
|
batchSize := s.cfg.TaskCenter.Archive.BatchSize
|
||||||
|
if batchSize <= 0 {
|
||||||
|
batchSize = 10000
|
||||||
|
}
|
||||||
rows, err := s.claimArchiveRows(ctx, batchSize)
|
rows, err := s.claimArchiveRows(ctx, batchSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -99,47 +97,17 @@ func (s *Service) claimArchiveRows(ctx context.Context, batchSize int) ([]model.
|
|||||||
var rows []model.TaskCenterEventArchiveOutbox
|
var rows []model.TaskCenterEventArchiveOutbox
|
||||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
staleProcessingBefore := time.Now().Add(-10 * time.Minute)
|
staleProcessingBefore := time.Now().Add(-10 * time.Minute)
|
||||||
staleLimit, _ := taskCenterArchiveClaimLimits(batchSize)
|
|
||||||
if staleLimit > 0 {
|
|
||||||
var staleRows []model.TaskCenterEventArchiveOutbox
|
|
||||||
// PROCESSING 超时行保留固定接管额度,避免 PENDING/FAILED 持续堆积时旧锁永久饥饿。
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
|
||||||
Where("status = ? AND update_time < ?", ArchiveStatusProcessing, staleProcessingBefore).
|
Where("status IN ? OR (status = ? AND update_time < ?)",
|
||||||
Order("update_time asc, id asc").
|
[]string{ArchiveStatusPending, ArchiveStatusFailed},
|
||||||
Limit(staleLimit).
|
ArchiveStatusProcessing,
|
||||||
Find(&staleRows).Error; err != nil {
|
staleProcessingBefore,
|
||||||
return err
|
).
|
||||||
}
|
|
||||||
rows = append(rows, staleRows...)
|
|
||||||
}
|
|
||||||
if remaining := batchSize - len(rows); remaining > 0 {
|
|
||||||
pendingLimit := taskCenterArchivePendingClaimLimit(remaining)
|
|
||||||
for _, retryable := range []struct {
|
|
||||||
status string
|
|
||||||
limit int
|
|
||||||
}{
|
|
||||||
{status: ArchiveStatusPending, limit: pendingLimit},
|
|
||||||
{status: ArchiveStatusFailed, limit: remaining},
|
|
||||||
} {
|
|
||||||
if retryable.limit <= 0 || remaining <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if retryable.limit > remaining {
|
|
||||||
retryable.limit = remaining
|
|
||||||
}
|
|
||||||
var retryableRows []model.TaskCenterEventArchiveOutbox
|
|
||||||
// PENDING 和 FAILED 分开抢占,避免 IN 跨 status 排序触发 filesort,同时给 FAILED 保留重试额度。
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
|
|
||||||
Where("status = ?", retryable.status).
|
|
||||||
Order("create_time asc, id asc").
|
Order("create_time asc, id asc").
|
||||||
Limit(retryable.limit).
|
Limit(batchSize).
|
||||||
Find(&retryableRows).Error; err != nil {
|
Find(&rows).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
rows = append(rows, retryableRows...)
|
|
||||||
remaining -= len(retryableRows)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -156,44 +124,6 @@ func (s *Service) claimArchiveRows(ctx context.Context, batchSize int) ([]model.
|
|||||||
return rows, nil
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeTaskCenterArchiveBatchSize(batchSize int) int {
|
|
||||||
if batchSize <= 0 {
|
|
||||||
return defaultTaskCenterArchiveBatchSize
|
|
||||||
}
|
|
||||||
if batchSize > maxTaskCenterArchiveBatchSize {
|
|
||||||
return maxTaskCenterArchiveBatchSize
|
|
||||||
}
|
|
||||||
return batchSize
|
|
||||||
}
|
|
||||||
|
|
||||||
func taskCenterArchiveClaimLimits(batchSize int) (int, int) {
|
|
||||||
if batchSize <= 1 {
|
|
||||||
return batchSize, 0
|
|
||||||
}
|
|
||||||
staleLimit := batchSize / 10
|
|
||||||
if staleLimit < 1 {
|
|
||||||
staleLimit = 1
|
|
||||||
}
|
|
||||||
if staleLimit > 100 {
|
|
||||||
staleLimit = 100
|
|
||||||
}
|
|
||||||
return staleLimit, batchSize - staleLimit
|
|
||||||
}
|
|
||||||
|
|
||||||
func taskCenterArchivePendingClaimLimit(retryableLimit int) int {
|
|
||||||
if retryableLimit <= 1 {
|
|
||||||
return retryableLimit
|
|
||||||
}
|
|
||||||
failedReserve := retryableLimit / 10
|
|
||||||
if failedReserve < 1 {
|
|
||||||
failedReserve = 1
|
|
||||||
}
|
|
||||||
if failedReserve > 100 {
|
|
||||||
failedReserve = 100
|
|
||||||
}
|
|
||||||
return retryableLimit - failedReserve
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) newCOSClient() (*cos.Client, error) {
|
func (s *Service) newCOSClient() (*cos.Client, error) {
|
||||||
bucketURL, err := url.Parse(fmt.Sprintf("https://%s.cos.%s.myqcloud.com",
|
bucketURL, err := url.Parse(fmt.Sprintf("https://%s.cos.%s.myqcloud.com",
|
||||||
strings.TrimSpace(s.cfg.TaskCenter.Archive.Bucket),
|
strings.TrimSpace(s.cfg.TaskCenter.Archive.Bucket),
|
||||||
@ -215,13 +145,7 @@ func groupArchiveRows(rows []model.TaskCenterEventArchiveOutbox) [][]model.TaskC
|
|||||||
groupsByKey := make(map[string][]model.TaskCenterEventArchiveOutbox)
|
groupsByKey := make(map[string][]model.TaskCenterEventArchiveOutbox)
|
||||||
keys := make([]string, 0)
|
keys := make([]string, 0)
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
partitionHour := "UNKNOWN"
|
groupKey := row.SysOrigin + "\x00" + row.EventType
|
||||||
if !row.OccurredAt.IsZero() {
|
|
||||||
partitionHour = row.OccurredAt.UTC().Format("2006-01-02T15")
|
|
||||||
}
|
|
||||||
// COS key 带 UTC dt/hour 分区,所以同一抢占批次也必须先按小时拆组;否则跨小时
|
|
||||||
// 事件会被放进第一行的错误目录,后续按分区恢复会漏数。
|
|
||||||
groupKey := partitionHour + "\x00" + row.SysOrigin + "\x00" + row.EventType
|
|
||||||
if _, exists := groupsByKey[groupKey]; !exists {
|
if _, exists := groupsByKey[groupKey]; !exists {
|
||||||
keys = append(keys, groupKey)
|
keys = append(keys, groupKey)
|
||||||
}
|
}
|
||||||
@ -244,31 +168,15 @@ func (s *Service) uploadArchiveGroup(ctx context.Context, client *cos.Client, ro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
verified, err := putAndHeadArchiveObject(ctx, client, cosKey, body, map[string]string{
|
_, err = client.Object.Put(ctx, cosKey, bytes.NewReader(body), &cos.ObjectPutOptions{
|
||||||
"object-type": "archive",
|
ObjectPutHeaderOptions: &cos.ObjectPutHeaderOptions{
|
||||||
"row-count": fmt.Sprintf("%d", len(rows)),
|
ContentType: "application/gzip",
|
||||||
|
ContentEncoding: "gzip",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
verifiedAt := time.Now()
|
|
||||||
minID, maxID, minOccurredAt, maxOccurredAt := archiveGroupBounds(rows)
|
|
||||||
if err := s.upsertArchiveManifest(ctx, model.TaskCenterArchiveManifest{
|
|
||||||
ObjectType: archiveManifestObjectArchive,
|
|
||||||
COSKey: cosKey,
|
|
||||||
SHA256: verified.SHA256,
|
|
||||||
ETag: verified.ETag,
|
|
||||||
CompressedSize: verified.CompressedSize,
|
|
||||||
RowCount: len(rows),
|
|
||||||
MinOutboxID: minID,
|
|
||||||
MaxOutboxID: maxID,
|
|
||||||
MinOccurredAt: minOccurredAt,
|
|
||||||
MaxOccurredAt: maxOccurredAt,
|
|
||||||
VerificationStatus: archiveManifestVerified,
|
|
||||||
VerifiedAt: &verifiedAt,
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.markArchiveGroupUploaded(ctx, rows, cosKey)
|
return s.markArchiveGroupUploaded(ctx, rows, cosKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,264 +0,0 @@
|
|||||||
package taskcenter
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
"chatapp3-golang/internal/utils"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
archiveGCControlID = uint8(1)
|
|
||||||
defaultArchiveGCBatch = 200
|
|
||||||
minArchiveGCBatch = 200
|
|
||||||
maxArchiveGCBatch = 500
|
|
||||||
minimumArchiveGCAge = 30 * 24 * time.Hour
|
|
||||||
archiveGCClaimIndex = "idx_task_center_archive_claim_create"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ArchiveGCStatus 返回唯一控制行;总开关关闭时不要求迁移已经执行,便于新版本先安全上线。
|
|
||||||
func (s *Service) ArchiveGCStatus(ctx context.Context) (*ArchiveGCStatusView, error) {
|
|
||||||
row, err := s.loadArchiveGCRun(ctx)
|
|
||||||
if err != nil {
|
|
||||||
// 总开关关闭的新环境可能尚未执行 060;此时返回安全的 IDLE。若控制行已经存在,
|
|
||||||
// 即使关闭开关也展示真实 RUNNING/PAUSED 状态,避免运维误以为历史 run 不存在。
|
|
||||||
if !s.cfg.TaskCenter.Archive.GC.Enabled {
|
|
||||||
return &ArchiveGCStatusView{Enabled: false, Status: ArchiveGCStatusIdle, BatchSize: defaultArchiveGCBatch}, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
view := s.archiveGCStatusView(row)
|
|
||||||
return &view, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ArchiveGCDryRun 对索引支持的候选范围做有界预检,并把 cutoff 固定进控制行。
|
|
||||||
// 这里不做 COUNT(*),因为在大表上为了展示总数触发长扫描本身就违背清理降载目标。
|
|
||||||
func (s *Service) ArchiveGCDryRun(ctx context.Context, request ArchiveGCDryRunRequest) (*ArchiveGCDryRunResponse, error) {
|
|
||||||
if err := s.requireArchiveGCReady(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
current, err := s.loadArchiveGCRun(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if current.Status == ArchiveGCStatusRunning {
|
|
||||||
return nil, NewAppError(http.StatusConflict, "archive_gc_running", "pause the running archive GC before preparing another cutoff")
|
|
||||||
}
|
|
||||||
cutoff := time.UnixMilli(request.CutoffTime)
|
|
||||||
if request.CutoffTime <= 0 || cutoff.After(time.Now().Add(-minimumArchiveGCAge)) {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "archive_gc_cutoff_too_recent", "cutoffTime must retain at least 30 days of hot data")
|
|
||||||
}
|
|
||||||
batchSize, err := normalizeArchiveGCBatch(request.BatchSize)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
preview, err := s.findArchiveGCCandidates(ctx, cutoff, batchSize+1)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
hasMore := len(preview) > batchSize
|
|
||||||
if hasMore {
|
|
||||||
preview = preview[:batchSize]
|
|
||||||
}
|
|
||||||
runSnowflake, err := utils.NextID()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
runID := strconv.FormatInt(runSnowflake, 10)
|
|
||||||
if runID == "" {
|
|
||||||
return nil, NewAppError(http.StatusInternalServerError, "archive_gc_run_id_failed", "cannot create archive GC run id")
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
var updated model.TaskCenterArchiveGCRun
|
|
||||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
var locked model.TaskCenterArchiveGCRun
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", archiveGCControlID).First(&locked).Error; err != nil {
|
|
||||||
return archiveGCMigrationError(err)
|
|
||||||
}
|
|
||||||
if locked.Status == ArchiveGCStatusRunning {
|
|
||||||
return NewAppError(http.StatusConflict, "archive_gc_running", "pause the running archive GC before preparing another cutoff")
|
|
||||||
}
|
|
||||||
updates := map[string]any{
|
|
||||||
"run_id": runID, "status": ArchiveGCStatusDryRun, "cutoff_time": cutoff, "batch_size": batchSize,
|
|
||||||
"scanned_rows": 0, "eligible_rows": 0, "protected_rows": 0, "deleted_rows": 0,
|
|
||||||
"recovery_object_count": 0, "cursor_create_time": nil, "cursor_id": 0,
|
|
||||||
"lease_owner": "", "lease_until": nil, "last_error": "", "update_time": now,
|
|
||||||
}
|
|
||||||
if err := tx.Model(&model.TaskCenterArchiveGCRun{}).Where("id = ?", archiveGCControlID).Updates(updates).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tx.Where("id = ?", archiveGCControlID).First(&updated).Error
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
view := s.archiveGCStatusView(updated)
|
|
||||||
return &ArchiveGCDryRunResponse{ArchiveGCStatusView: view, PreviewRows: len(preview), HasMore: hasMore}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartArchiveGC 只允许启动刚刚预检过的控制行,确保真实删除沿用完全相同的固定 cutoff。
|
|
||||||
func (s *Service) StartArchiveGC(ctx context.Context) (*ArchiveGCStatusView, error) {
|
|
||||||
if err := s.requireArchiveGCReady(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s.transitionArchiveGC(ctx, []string{ArchiveGCStatusDryRun}, ArchiveGCStatusRunning)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) PauseArchiveGC(ctx context.Context) (*ArchiveGCStatusView, error) {
|
|
||||||
if err := s.requireArchiveGCEnabled(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s.transitionArchiveGC(ctx, []string{ArchiveGCStatusRunning}, ArchiveGCStatusPaused)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) ResumeArchiveGC(ctx context.Context) (*ArchiveGCStatusView, error) {
|
|
||||||
if err := s.requireArchiveGCReady(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s.transitionArchiveGC(ctx, []string{ArchiveGCStatusPaused, ArchiveGCStatusFailed}, ArchiveGCStatusRunning)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) transitionArchiveGC(ctx context.Context, from []string, to string) (*ArchiveGCStatusView, error) {
|
|
||||||
var updated model.TaskCenterArchiveGCRun
|
|
||||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
var locked model.TaskCenterArchiveGCRun
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", archiveGCControlID).First(&locked).Error; err != nil {
|
|
||||||
return archiveGCMigrationError(err)
|
|
||||||
}
|
|
||||||
allowed := false
|
|
||||||
for _, status := range from {
|
|
||||||
if locked.Status == status {
|
|
||||||
allowed = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !allowed {
|
|
||||||
return NewAppError(http.StatusConflict, "archive_gc_state_conflict", "archive GC state does not allow this operation")
|
|
||||||
}
|
|
||||||
if locked.CutoffTime == nil {
|
|
||||||
return NewAppError(http.StatusConflict, "archive_gc_cutoff_missing", "run dry-run before starting archive GC")
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
if err := tx.Model(&model.TaskCenterArchiveGCRun{}).Where("id = ?", archiveGCControlID).Updates(map[string]any{
|
|
||||||
"status": to, "lease_owner": "", "lease_until": nil, "last_error": "", "update_time": now,
|
|
||||||
}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tx.Where("id = ?", archiveGCControlID).First(&updated).Error
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
view := s.archiveGCStatusView(updated)
|
|
||||||
return &view, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) loadArchiveGCRun(ctx context.Context) (model.TaskCenterArchiveGCRun, error) {
|
|
||||||
var row model.TaskCenterArchiveGCRun
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", archiveGCControlID).First(&row).Error; err != nil {
|
|
||||||
return row, archiveGCMigrationError(err)
|
|
||||||
}
|
|
||||||
return row, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) requireArchiveGCReady(ctx context.Context) error {
|
|
||||||
if err := s.requireArchiveGCEnabled(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
archive := s.cfg.TaskCenter.Archive
|
|
||||||
if strings.TrimSpace(archive.Bucket) == "" || strings.TrimSpace(archive.Region) == "" ||
|
|
||||||
strings.TrimSpace(archive.SecretID) == "" || strings.TrimSpace(archive.SecretKey) == "" {
|
|
||||||
return NewAppError(http.StatusServiceUnavailable, "archive_gc_cos_not_configured", "task center archive COS is not configured")
|
|
||||||
}
|
|
||||||
return s.requireArchiveGCClaimIndex(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) requireArchiveGCEnabled() error {
|
|
||||||
if !s.cfg.TaskCenter.Archive.GC.Enabled {
|
|
||||||
return NewAppError(http.StatusServiceUnavailable, "archive_gc_disabled", "task center archive GC is disabled")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) requireArchiveGCClaimIndex(ctx context.Context) error {
|
|
||||||
type indexColumn struct {
|
|
||||||
ColumnName string `gorm:"column:column_name"`
|
|
||||||
Seq int `gorm:"column:seq_in_index"`
|
|
||||||
SubPart *int64 `gorm:"column:sub_part"`
|
|
||||||
IndexType string `gorm:"column:index_type"`
|
|
||||||
IsVisible string `gorm:"column:is_visible"`
|
|
||||||
}
|
|
||||||
var columns []indexColumn
|
|
||||||
if err := s.db.WithContext(ctx).Raw(`
|
|
||||||
-- MySQL 会把 information_schema 列标签原样返回为大写;显式使用与 GORM tag
|
|
||||||
-- 一致的小写别名,避免查询有三行但结构体字段全为零值而误判索引缺失。
|
|
||||||
SELECT COLUMN_NAME AS column_name,
|
|
||||||
SEQ_IN_INDEX AS seq_in_index,
|
|
||||||
SUB_PART AS sub_part,
|
|
||||||
INDEX_TYPE AS index_type,
|
|
||||||
IS_VISIBLE AS is_visible
|
|
||||||
FROM information_schema.STATISTICS
|
|
||||||
WHERE TABLE_SCHEMA = DATABASE()
|
|
||||||
AND TABLE_NAME = 'task_center_event_archive_outbox'
|
|
||||||
AND INDEX_NAME = ?
|
|
||||||
ORDER BY SEQ_IN_INDEX`, archiveGCClaimIndex).Scan(&columns).Error; err != nil {
|
|
||||||
return NewAppError(http.StatusServiceUnavailable, "archive_gc_index_check_failed", "cannot verify the archive GC claim index")
|
|
||||||
}
|
|
||||||
required := [...]string{"status", "create_time", "id"}
|
|
||||||
if len(columns) != len(required) {
|
|
||||||
return NewAppError(http.StatusServiceUnavailable, "archive_gc_index_required", "migration 056 claim index must be online before archive GC")
|
|
||||||
}
|
|
||||||
for index, expected := range required {
|
|
||||||
column := columns[index]
|
|
||||||
if column.Seq != index+1 || !strings.EqualFold(column.ColumnName, expected) || column.SubPart != nil ||
|
|
||||||
!strings.EqualFold(column.IndexType, "BTREE") || !strings.EqualFold(column.IsVisible, "YES") {
|
|
||||||
return NewAppError(http.StatusServiceUnavailable, "archive_gc_index_required", "migration 056 claim index must be online before archive GC")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeArchiveGCBatch(value int) (int, error) {
|
|
||||||
if value == 0 {
|
|
||||||
return defaultArchiveGCBatch, nil
|
|
||||||
}
|
|
||||||
if value < minArchiveGCBatch || value > maxArchiveGCBatch {
|
|
||||||
return 0, NewAppError(http.StatusBadRequest, "archive_gc_batch_invalid", "batchSize must be between 200 and 500")
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func archiveGCMigrationError(err error) error {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return NewAppError(http.StatusServiceUnavailable, "archive_gc_migration_required", "migration 060 archive GC control row is required")
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) archiveGCStatusView(row model.TaskCenterArchiveGCRun) ArchiveGCStatusView {
|
|
||||||
view := ArchiveGCStatusView{
|
|
||||||
Enabled: s.cfg.TaskCenter.Archive.GC.Enabled, RunID: row.RunID, Status: row.Status,
|
|
||||||
BatchSize: row.BatchSize, ScannedRows: row.ScannedRows, EligibleRows: row.EligibleRows,
|
|
||||||
ProtectedRows: row.ProtectedRows, DeletedRows: row.DeletedRows,
|
|
||||||
RecoveryObjectCount: row.RecoveryObjectCount, CursorID: row.CursorID,
|
|
||||||
LastError: row.LastError, CreateTime: row.CreateTime.UnixMilli(), UpdateTime: row.UpdateTime.UnixMilli(),
|
|
||||||
}
|
|
||||||
if row.CutoffTime != nil {
|
|
||||||
view.CutoffTime = row.CutoffTime.UnixMilli()
|
|
||||||
}
|
|
||||||
if row.CursorCreateTime != nil {
|
|
||||||
view.CursorCreateTime = row.CursorCreateTime.UnixMilli()
|
|
||||||
}
|
|
||||||
if row.LeaseUntil != nil {
|
|
||||||
view.LeaseUntil = row.LeaseUntil.UnixMilli()
|
|
||||||
}
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
package taskcenter
|
|
||||||
|
|
||||||
const (
|
|
||||||
ArchiveGCStatusIdle = "IDLE"
|
|
||||||
ArchiveGCStatusDryRun = "DRY_RUN"
|
|
||||||
ArchiveGCStatusRunning = "RUNNING"
|
|
||||||
ArchiveGCStatusPaused = "PAUSED"
|
|
||||||
ArchiveGCStatusCompleted = "COMPLETED"
|
|
||||||
ArchiveGCStatusFailed = "FAILED"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ArchiveGCDryRunRequest 冻结一次清理的截止时间和批量;start 不再接收这些字段,避免
|
|
||||||
// 预检与真实执行使用不同范围。
|
|
||||||
type ArchiveGCDryRunRequest struct {
|
|
||||||
CutoffTime int64 `json:"cutoffTime"`
|
|
||||||
BatchSize int `json:"batchSize"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ArchiveGCStatusView struct {
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
RunID string `json:"runId"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
CutoffTime int64 `json:"cutoffTime,omitempty"`
|
|
||||||
BatchSize int `json:"batchSize"`
|
|
||||||
ScannedRows int64 `json:"scannedRows"`
|
|
||||||
EligibleRows int64 `json:"eligibleRows"`
|
|
||||||
ProtectedRows int64 `json:"protectedRows"`
|
|
||||||
DeletedRows int64 `json:"deletedRows"`
|
|
||||||
RecoveryObjectCount int64 `json:"recoveryObjectCount"`
|
|
||||||
CursorCreateTime int64 `json:"cursorCreateTime,omitempty"`
|
|
||||||
CursorID int64 `json:"cursorId,string,omitempty"`
|
|
||||||
LeaseUntil int64 `json:"leaseUntil,omitempty"`
|
|
||||||
LastError string `json:"lastError,omitempty"`
|
|
||||||
CreateTime int64 `json:"createTime,omitempty"`
|
|
||||||
UpdateTime int64 `json:"updateTime,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ArchiveGCDryRunResponse struct {
|
|
||||||
ArchiveGCStatusView
|
|
||||||
PreviewRows int `json:"previewRows"`
|
|
||||||
HasMore bool `json:"hasMore"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ArchiveGCTickResponse struct {
|
|
||||||
RunID string `json:"runId"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
ProcessedRows int `json:"processedRows"`
|
|
||||||
DeletedRows int `json:"deletedRows"`
|
|
||||||
ProtectedRows int `json:"protectedRows"`
|
|
||||||
RecoveryCOSKey string `json:"recoveryCosKey,omitempty"`
|
|
||||||
Finished bool `json:"finished"`
|
|
||||||
SkippedReason string `json:"skippedReason,omitempty"`
|
|
||||||
}
|
|
||||||
@ -1,533 +0,0 @@
|
|||||||
package taskcenter
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"compress/gzip"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
|
|
||||||
"github.com/tencentyun/cos-go-sdk-v5"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
archiveGCLeaseDuration = 5 * time.Minute
|
|
||||||
maxArchiveGCSourceObjectsPerRun = 10
|
|
||||||
)
|
|
||||||
|
|
||||||
var archiveGCWorkerIdentity = buildArchiveGCWorkerIdentity()
|
|
||||||
|
|
||||||
type archiveGCRecoveryRow struct {
|
|
||||||
ID int64 `json:"id,string"`
|
|
||||||
SysOrigin string `json:"sysOrigin"`
|
|
||||||
EventID string `json:"eventId"`
|
|
||||||
EventType string `json:"eventType"`
|
|
||||||
UserID int64 `json:"userId,string"`
|
|
||||||
OccurredAt time.Time `json:"occurredAt"`
|
|
||||||
RawJSON string `json:"rawJson"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
RetryCount int `json:"retryCount"`
|
|
||||||
COSKey string `json:"cosKey"`
|
|
||||||
FailureReason string `json:"failureReason"`
|
|
||||||
CreateTime time.Time `json:"createTime"`
|
|
||||||
UpdateTime time.Time `json:"updateTime"`
|
|
||||||
ArchivedAt *time.Time `json:"archivedAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ArchiveGCTick 由 chatapp-cron 显式触发且每次只处理一个小批次。COS 回读和恢复包上传都
|
|
||||||
// 位于数据库删除之前;任一步失败都会暂停本次 run,绝不继续“尽力删除”。
|
|
||||||
func (s *Service) ArchiveGCTick(ctx context.Context) (*ArchiveGCTickResponse, error) {
|
|
||||||
if err := s.requireArchiveGCReady(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
leaseToken := fmt.Sprintf("%s:%d", archiveGCWorkerIdentity, time.Now().UnixNano())
|
|
||||||
run, acquired, reason, err := s.acquireArchiveGCLease(ctx, leaseToken)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !acquired {
|
|
||||||
return &ArchiveGCTickResponse{RunID: run.RunID, Status: run.Status, SkippedReason: reason, Finished: run.Status == ArchiveGCStatusCompleted}, nil
|
|
||||||
}
|
|
||||||
if run.CutoffTime == nil {
|
|
||||||
err := NewAppError(http.StatusConflict, "archive_gc_cutoff_missing", "archive GC cutoff is missing")
|
|
||||||
s.pauseArchiveGCTick(run.RunID, leaseToken, err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := s.findArchiveGCCandidates(ctx, *run.CutoffTime, run.BatchSize)
|
|
||||||
if err != nil {
|
|
||||||
s.pauseArchiveGCTick(run.RunID, leaseToken, err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(rows) == 0 {
|
|
||||||
if err := s.completeArchiveGCTick(ctx, run.RunID, leaseToken); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &ArchiveGCTickResponse{RunID: run.RunID, Status: ArchiveGCStatusCompleted, Finished: true}, nil
|
|
||||||
}
|
|
||||||
rows = limitArchiveGCSourceObjects(rows, maxArchiveGCSourceObjectsPerRun)
|
|
||||||
client, err := s.newCOSClient()
|
|
||||||
if err != nil {
|
|
||||||
s.pauseArchiveGCTick(run.RunID, leaseToken, err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := s.verifyArchiveGCSources(ctx, client, rows); err != nil {
|
|
||||||
s.pauseArchiveGCTick(run.RunID, leaseToken, err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
recoveryKey, recoverySHA, err := s.createAndVerifyArchiveGCRecovery(ctx, client, run.RunID, rows)
|
|
||||||
if err != nil {
|
|
||||||
s.pauseArchiveGCTick(run.RunID, leaseToken, err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
deleted, protected, err := s.deleteVerifiedArchiveGCRows(ctx, run, leaseToken, recoveryKey, recoverySHA, rows)
|
|
||||||
if err != nil {
|
|
||||||
s.pauseArchiveGCTick(run.RunID, leaseToken, err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &ArchiveGCTickResponse{
|
|
||||||
RunID: run.RunID, Status: ArchiveGCStatusRunning, ProcessedRows: len(rows), DeletedRows: deleted,
|
|
||||||
ProtectedRows: protected, RecoveryCOSKey: recoveryKey,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) findArchiveGCCandidates(ctx context.Context, cutoff time.Time, limit int) ([]model.TaskCenterEventArchiveOutbox, error) {
|
|
||||||
if limit <= 0 || limit > maxArchiveGCBatch+1 {
|
|
||||||
limit = defaultArchiveGCBatch
|
|
||||||
}
|
|
||||||
var rows []model.TaskCenterEventArchiveOutbox
|
|
||||||
// FORCE INDEX 将扫描固定在 (status,create_time,id);NOT EXISTS 只针对命中的小范围行
|
|
||||||
// 查询活动小表。MAX_EXECUTION_TIME 防止长期活动保护了大量最旧行时,为凑满一批而
|
|
||||||
// 在生产大表持续扫描;超时会让 tick 暂停且不产生 DELETE。
|
|
||||||
err := s.db.WithContext(ctx).Raw(`
|
|
||||||
SELECT /*+ MAX_EXECUTION_TIME(5000) */ archive_row.*
|
|
||||||
FROM task_center_event_archive_outbox AS archive_row
|
|
||||||
FORCE INDEX (idx_task_center_archive_claim_create)
|
|
||||||
WHERE archive_row.status = ?
|
|
||||||
AND archive_row.create_time < ?
|
|
||||||
AND archive_row.archived_at IS NOT NULL
|
|
||||||
AND archive_row.archived_at < ?
|
|
||||||
AND archive_row.occurred_at IS NOT NULL
|
|
||||||
AND archive_row.occurred_at < ?
|
|
||||||
AND archive_row.cos_key <> ''
|
|
||||||
AND (
|
|
||||||
archive_row.event_type <> ?
|
|
||||||
OR NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM yumi_game_king_activity AS activity
|
|
||||||
WHERE activity.enabled = 1
|
|
||||||
AND activity.settlement_status <> 'COMPLETED'
|
|
||||||
AND activity.sys_origin = archive_row.sys_origin
|
|
||||||
AND archive_row.occurred_at >= activity.start_time
|
|
||||||
AND archive_row.occurred_at < activity.end_time
|
|
||||||
)
|
|
||||||
)
|
|
||||||
ORDER BY archive_row.create_time ASC, archive_row.id ASC
|
|
||||||
LIMIT ?`, ArchiveStatusUploaded, cutoff, cutoff, cutoff, EventTypeGameConsumeGold, limit).Scan(&rows).Error
|
|
||||||
return rows, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) acquireArchiveGCLease(ctx context.Context, leaseToken string) (model.TaskCenterArchiveGCRun, bool, string, error) {
|
|
||||||
var run model.TaskCenterArchiveGCRun
|
|
||||||
claimed := false
|
|
||||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", archiveGCControlID).First(&run).Error; err != nil {
|
|
||||||
return archiveGCMigrationError(err)
|
|
||||||
}
|
|
||||||
if run.Status != ArchiveGCStatusRunning {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var databaseClock struct {
|
|
||||||
Now time.Time `gorm:"column:now_time"`
|
|
||||||
}
|
|
||||||
if err := tx.Raw("SELECT NOW(3) AS now_time").Scan(&databaseClock).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
now := databaseClock.Now
|
|
||||||
if now.IsZero() {
|
|
||||||
return fmt.Errorf("database clock returned zero time")
|
|
||||||
}
|
|
||||||
if run.LeaseUntil != nil && run.LeaseUntil.After(now) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
leaseUntil := now.Add(archiveGCLeaseDuration)
|
|
||||||
if err := tx.Model(&model.TaskCenterArchiveGCRun{}).Where("id = ?", archiveGCControlID).Updates(map[string]any{
|
|
||||||
"lease_owner": leaseToken, "lease_until": leaseUntil, "update_time": now,
|
|
||||||
}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
run.LeaseOwner = leaseToken
|
|
||||||
run.LeaseUntil = &leaseUntil
|
|
||||||
claimed = true
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return run, false, "", err
|
|
||||||
}
|
|
||||||
if run.Status != ArchiveGCStatusRunning {
|
|
||||||
return run, false, "not_running", nil
|
|
||||||
}
|
|
||||||
if !claimed {
|
|
||||||
return run, false, "lease_busy", nil
|
|
||||||
}
|
|
||||||
return run, true, "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) verifyArchiveGCSources(ctx context.Context, client *cos.Client, rows []model.TaskCenterEventArchiveOutbox) error {
|
|
||||||
groups := make(map[string][]model.TaskCenterEventArchiveOutbox)
|
|
||||||
for _, row := range rows {
|
|
||||||
groups[row.COSKey] = append(groups[row.COSKey], row)
|
|
||||||
}
|
|
||||||
keys := make([]string, 0, len(groups))
|
|
||||||
for key := range groups {
|
|
||||||
keys = append(keys, key)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
for _, cosKey := range keys {
|
|
||||||
// HEAD 确认对象当前版本,GET+gunzip 再确认候选 raw_json 确实包含在对象内;
|
|
||||||
// 不能因为数据库已有 cos_key 就假设历史 PUT 完整成功。
|
|
||||||
head, err := headAndVerifyArchiveObject(ctx, client, cosKey, "", -1, false)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("verify source archive %s HEAD: %w", cosKey, err)
|
|
||||||
}
|
|
||||||
if head.CompressedSize > maxArchiveCOSObjectBytes {
|
|
||||||
return fmt.Errorf("source archive %s exceeds %d bytes", cosKey, maxArchiveCOSObjectBytes)
|
|
||||||
}
|
|
||||||
body, _, err := downloadArchiveObject(ctx, client, cosKey)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("verify source archive %s GET: %w", cosKey, err)
|
|
||||||
}
|
|
||||||
if int64(len(body)) != head.CompressedSize {
|
|
||||||
return fmt.Errorf("source archive %s changed between HEAD and GET", cosKey)
|
|
||||||
}
|
|
||||||
sha := archiveSHA256(body)
|
|
||||||
verifiedHead, err := headAndVerifyArchiveObject(ctx, client, cosKey, sha, int64(len(body)), false)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("verify source archive %s metadata: %w", cosKey, err)
|
|
||||||
}
|
|
||||||
lines, err := archiveObjectLines(body)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("verify source archive %s gzip: %w", cosKey, err)
|
|
||||||
}
|
|
||||||
available := make(map[string]int, len(lines))
|
|
||||||
for _, line := range lines {
|
|
||||||
available[archiveLineSHA256(line)]++
|
|
||||||
}
|
|
||||||
for _, row := range groups[cosKey] {
|
|
||||||
hash := archiveLineSHA256(row.RawJSON)
|
|
||||||
if available[hash] <= 0 {
|
|
||||||
return fmt.Errorf("source archive %s does not contain outbox row %d", cosKey, row.ID)
|
|
||||||
}
|
|
||||||
available[hash]--
|
|
||||||
}
|
|
||||||
var existing model.TaskCenterArchiveManifest
|
|
||||||
existingErr := s.db.WithContext(ctx).Where("cos_key = ?", cosKey).First(&existing).Error
|
|
||||||
if existingErr == nil && existing.SHA256 != "" && !strings.EqualFold(existing.SHA256, sha) {
|
|
||||||
return fmt.Errorf("source archive %s no longer matches its manifest", cosKey)
|
|
||||||
}
|
|
||||||
if existingErr != nil && !errors.Is(existingErr, gorm.ErrRecordNotFound) {
|
|
||||||
return existingErr
|
|
||||||
}
|
|
||||||
verifiedAt := time.Now()
|
|
||||||
if err := s.upsertArchiveManifest(ctx, model.TaskCenterArchiveManifest{
|
|
||||||
ObjectType: archiveManifestObjectArchive, COSKey: cosKey, SHA256: sha, ETag: verifiedHead.ETag,
|
|
||||||
CompressedSize: int64(len(body)), RowCount: len(lines), MinOutboxID: existing.MinOutboxID,
|
|
||||||
MaxOutboxID: existing.MaxOutboxID, MinOccurredAt: existing.MinOccurredAt,
|
|
||||||
MaxOccurredAt: existing.MaxOccurredAt, VerificationStatus: archiveManifestVerified,
|
|
||||||
VerifiedAt: &verifiedAt,
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) createAndVerifyArchiveGCRecovery(
|
|
||||||
ctx context.Context,
|
|
||||||
client *cos.Client,
|
|
||||||
runID string,
|
|
||||||
rows []model.TaskCenterEventArchiveOutbox,
|
|
||||||
) (string, string, error) {
|
|
||||||
body, err := gzipArchiveGCRecoveryRows(rows)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
cosKey := s.buildArchiveGCRecoveryKey(runID, rows)
|
|
||||||
verified, err := putAndHeadArchiveObject(ctx, client, cosKey, body, map[string]string{
|
|
||||||
"object-type": "gc-recovery", "run-id": runID, "row-count": strconv.Itoa(len(rows)),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
// 恢复包本身也必须 GET+gunzip 并逐行比对 ID/raw_json,HEAD 成功不等于内容可恢复。
|
|
||||||
downloaded, _, err := downloadArchiveObject(ctx, client, cosKey)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
if archiveSHA256(downloaded) != verified.SHA256 {
|
|
||||||
return "", "", fmt.Errorf("recovery object %s sha256 mismatch after upload", cosKey)
|
|
||||||
}
|
|
||||||
lines, err := archiveObjectLines(downloaded)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
if len(lines) != len(rows) {
|
|
||||||
return "", "", fmt.Errorf("recovery object %s row count %d does not match %d", cosKey, len(lines), len(rows))
|
|
||||||
}
|
|
||||||
expected := make(map[int64]string, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
expected[row.ID] = archiveLineSHA256(row.RawJSON)
|
|
||||||
}
|
|
||||||
for _, line := range lines {
|
|
||||||
var recovered archiveGCRecoveryRow
|
|
||||||
if err := json.Unmarshal([]byte(line), &recovered); err != nil {
|
|
||||||
return "", "", fmt.Errorf("decode recovery object %s: %w", cosKey, err)
|
|
||||||
}
|
|
||||||
expectedHash, ok := expected[recovered.ID]
|
|
||||||
if !ok || expectedHash != archiveLineSHA256(recovered.RawJSON) {
|
|
||||||
return "", "", fmt.Errorf("recovery object %s has unexpected row %d", cosKey, recovered.ID)
|
|
||||||
}
|
|
||||||
delete(expected, recovered.ID)
|
|
||||||
}
|
|
||||||
if len(expected) != 0 {
|
|
||||||
return "", "", fmt.Errorf("recovery object %s is missing rows", cosKey)
|
|
||||||
}
|
|
||||||
verifiedAt := time.Now()
|
|
||||||
minID, maxID, minOccurredAt, maxOccurredAt := archiveGroupBounds(rows)
|
|
||||||
parentCOSKey := ""
|
|
||||||
if rowsShareArchiveCOSKey(rows) {
|
|
||||||
parentCOSKey = rows[0].COSKey
|
|
||||||
}
|
|
||||||
if err := s.upsertArchiveManifest(ctx, model.TaskCenterArchiveManifest{
|
|
||||||
ObjectType: archiveManifestObjectRecovery, RunID: runID, COSKey: cosKey, ParentCOSKey: parentCOSKey,
|
|
||||||
SHA256: verified.SHA256, ETag: verified.ETag, CompressedSize: verified.CompressedSize,
|
|
||||||
RowCount: len(rows), MinOutboxID: minID, MaxOutboxID: maxID, MinOccurredAt: minOccurredAt,
|
|
||||||
MaxOccurredAt: maxOccurredAt, VerificationStatus: archiveManifestVerified, VerifiedAt: &verifiedAt,
|
|
||||||
}); err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
return cosKey, verified.SHA256, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) deleteVerifiedArchiveGCRows(
|
|
||||||
ctx context.Context,
|
|
||||||
run model.TaskCenterArchiveGCRun,
|
|
||||||
leaseToken, recoveryKey, recoverySHA string,
|
|
||||||
candidates []model.TaskCenterEventArchiveOutbox,
|
|
||||||
) (int, int, error) {
|
|
||||||
if run.CutoffTime == nil {
|
|
||||||
return 0, 0, NewAppError(http.StatusConflict, "archive_gc_cutoff_missing", "archive GC cutoff is missing")
|
|
||||||
}
|
|
||||||
candidateByID := make(map[int64]model.TaskCenterEventArchiveOutbox, len(candidates))
|
|
||||||
ids := make([]int64, 0, len(candidates))
|
|
||||||
for _, row := range candidates {
|
|
||||||
candidateByID[row.ID] = row
|
|
||||||
ids = append(ids, row.ID)
|
|
||||||
}
|
|
||||||
deleted, protected := 0, 0
|
|
||||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
var lockedRun model.TaskCenterArchiveGCRun
|
|
||||||
// 与 Game King 启用门禁锁同一行。GC 在这把锁下复核活动窗口并删行,启用请求
|
|
||||||
// 要么先完成并被本事务看到,要么等待删除提交后发现 GC 仍为 RUNNING 而失败。
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", archiveGCControlID).First(&lockedRun).Error; err != nil {
|
|
||||||
return archiveGCMigrationError(err)
|
|
||||||
}
|
|
||||||
if lockedRun.RunID != run.RunID || lockedRun.Status != ArchiveGCStatusRunning || lockedRun.LeaseOwner != leaseToken {
|
|
||||||
return NewAppError(http.StatusConflict, "archive_gc_lease_lost", "archive GC lease or state changed before delete")
|
|
||||||
}
|
|
||||||
var recoveryManifest model.TaskCenterArchiveManifest
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where(
|
|
||||||
"cos_key = ? AND run_id = ? AND object_type = ? AND verification_status = ? AND sha256 = ?",
|
|
||||||
recoveryKey, run.RunID, archiveManifestObjectRecovery, archiveManifestVerified, recoverySHA,
|
|
||||||
).First(&recoveryManifest).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return NewAppError(http.StatusConflict, "archive_gc_recovery_manifest_missing", "verified recovery manifest changed or is missing before delete")
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if recoveryManifest.RowCount != len(candidates) || recoveryManifest.VerifiedAt == nil {
|
|
||||||
return NewAppError(http.StatusConflict, "archive_gc_recovery_manifest_invalid", "recovery manifest row count or verification time is invalid")
|
|
||||||
}
|
|
||||||
|
|
||||||
var lockedRows []model.TaskCenterEventArchiveOutbox
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ?", ids).Order("create_time ASC, id ASC").Find(&lockedRows).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var protectedActivities []model.YumiGameKingActivity
|
|
||||||
if err := tx.Where("enabled = ? AND settlement_status <> ?", true, "COMPLETED").Find(&protectedActivities).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
deleteRows := make([]model.TaskCenterEventArchiveOutbox, 0, len(lockedRows))
|
|
||||||
for _, row := range lockedRows {
|
|
||||||
original, exists := candidateByID[row.ID]
|
|
||||||
if !exists || row.Status != ArchiveStatusUploaded || row.COSKey == "" || row.COSKey != original.COSKey ||
|
|
||||||
archiveLineSHA256(row.RawJSON) != archiveLineSHA256(original.RawJSON) || row.ArchivedAt == nil ||
|
|
||||||
!row.CreateTime.Before(*run.CutoffTime) || !row.ArchivedAt.Before(*run.CutoffTime) ||
|
|
||||||
row.OccurredAt.IsZero() || !row.OccurredAt.Before(*run.CutoffTime) || archiveGCActivityProtects(row, protectedActivities) {
|
|
||||||
protected++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
deleteRows = append(deleteRows, row)
|
|
||||||
}
|
|
||||||
if missing := len(candidates) - len(lockedRows); missing > 0 {
|
|
||||||
// 另一个写路径删除/改动候选属于异常状态;恢复包仍完整,但本次只统计为保护跳过。
|
|
||||||
protected += missing
|
|
||||||
}
|
|
||||||
deleteIDs := archiveRowIDs(deleteRows)
|
|
||||||
if len(deleteIDs) > 0 {
|
|
||||||
result := tx.Where("id IN ? AND status = ?", deleteIDs, ArchiveStatusUploaded).Delete(&model.TaskCenterEventArchiveOutbox{})
|
|
||||||
if result.Error != nil {
|
|
||||||
return result.Error
|
|
||||||
}
|
|
||||||
deleted = int(result.RowsAffected)
|
|
||||||
if deleted != len(deleteIDs) {
|
|
||||||
return fmt.Errorf("archive GC deleted %d rows, expected %d", deleted, len(deleteIDs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
updates := map[string]any{
|
|
||||||
"scanned_rows": gorm.Expr("scanned_rows + ?", len(candidates)),
|
|
||||||
"eligible_rows": gorm.Expr("eligible_rows + ?", len(deleteRows)),
|
|
||||||
"protected_rows": gorm.Expr("protected_rows + ?", protected),
|
|
||||||
"deleted_rows": gorm.Expr("deleted_rows + ?", deleted),
|
|
||||||
"recovery_object_count": gorm.Expr("recovery_object_count + 1"),
|
|
||||||
"lease_owner": "", "lease_until": nil, "last_error": "", "update_time": now,
|
|
||||||
}
|
|
||||||
if deleted > 0 {
|
|
||||||
last := deleteRows[len(deleteRows)-1]
|
|
||||||
updates["cursor_create_time"] = last.CreateTime
|
|
||||||
updates["cursor_id"] = last.ID
|
|
||||||
}
|
|
||||||
if err := tx.Model(&model.TaskCenterArchiveGCRun{}).Where("id = ?", archiveGCControlID).Updates(updates).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return deleted, protected, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) completeArchiveGCTick(ctx context.Context, runID, leaseToken string) error {
|
|
||||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
var locked model.TaskCenterArchiveGCRun
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", archiveGCControlID).First(&locked).Error; err != nil {
|
|
||||||
return archiveGCMigrationError(err)
|
|
||||||
}
|
|
||||||
if locked.RunID != runID || locked.Status != ArchiveGCStatusRunning || locked.LeaseOwner != leaseToken {
|
|
||||||
return NewAppError(http.StatusConflict, "archive_gc_lease_lost", "archive GC lease or state changed before completion")
|
|
||||||
}
|
|
||||||
return tx.Model(&model.TaskCenterArchiveGCRun{}).Where("id = ?", archiveGCControlID).Updates(map[string]any{
|
|
||||||
"status": ArchiveGCStatusCompleted, "lease_owner": "", "lease_until": nil,
|
|
||||||
"last_error": "", "update_time": time.Now(),
|
|
||||||
}).Error
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) pauseArchiveGCTick(runID, leaseToken string, workErr error) {
|
|
||||||
// HTTP/cron 超时往往已取消传入 ctx;失败状态必须用独立短上下文落库,否则控制行会
|
|
||||||
// 继续显示 RUNNING,直到租约过期后再次触发同一批 COS 工作。
|
|
||||||
failureCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
message := "archive GC tick failed"
|
|
||||||
if workErr != nil {
|
|
||||||
message = truncateTaskCenterFailure(workErr.Error())
|
|
||||||
}
|
|
||||||
_ = s.db.WithContext(failureCtx).Model(&model.TaskCenterArchiveGCRun{}).
|
|
||||||
Where("id = ? AND run_id = ? AND status = ? AND lease_owner = ?", archiveGCControlID, runID, ArchiveGCStatusRunning, leaseToken).
|
|
||||||
Updates(map[string]any{
|
|
||||||
"status": ArchiveGCStatusPaused, "lease_owner": "", "lease_until": nil,
|
|
||||||
"last_error": message, "update_time": time.Now(),
|
|
||||||
}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func gzipArchiveGCRecoveryRows(rows []model.TaskCenterEventArchiveOutbox) ([]byte, error) {
|
|
||||||
var buffer bytes.Buffer
|
|
||||||
writer := gzip.NewWriter(&buffer)
|
|
||||||
encoder := json.NewEncoder(writer)
|
|
||||||
for _, row := range rows {
|
|
||||||
if err := encoder.Encode(archiveGCRecoveryRow{
|
|
||||||
ID: row.ID, SysOrigin: row.SysOrigin, EventID: row.EventID, EventType: row.EventType,
|
|
||||||
UserID: row.UserID, OccurredAt: row.OccurredAt, RawJSON: row.RawJSON, Status: row.Status,
|
|
||||||
RetryCount: row.RetryCount, COSKey: row.COSKey, FailureReason: row.FailureReason,
|
|
||||||
CreateTime: row.CreateTime, UpdateTime: row.UpdateTime, ArchivedAt: row.ArchivedAt,
|
|
||||||
}); err != nil {
|
|
||||||
_ = writer.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := writer.Close(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return buffer.Bytes(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) buildArchiveGCRecoveryKey(runID string, rows []model.TaskCenterEventArchiveOutbox) string {
|
|
||||||
prefix := strings.Trim(strings.TrimSpace(s.cfg.TaskCenter.Archive.Prefix), "/")
|
|
||||||
if prefix == "" {
|
|
||||||
prefix = "task-center-events/v1"
|
|
||||||
}
|
|
||||||
return path.Join(prefix, "gc-recovery", "run="+sanitizeArchivePartition(runID), fmt.Sprintf(
|
|
||||||
"part-%s-%d-%d.jsonl.gz", time.Now().UTC().Format("20060102T150405.000000000Z"), rows[0].ID, len(rows),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func archiveGCActivityProtects(row model.TaskCenterEventArchiveOutbox, activities []model.YumiGameKingActivity) bool {
|
|
||||||
if row.EventType != EventTypeGameConsumeGold {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, activity := range activities {
|
|
||||||
if activity.SysOrigin == row.SysOrigin && !row.OccurredAt.Before(activity.StartTime) && row.OccurredAt.Before(activity.EndTime) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func limitArchiveGCSourceObjects(rows []model.TaskCenterEventArchiveOutbox, maximum int) []model.TaskCenterEventArchiveOutbox {
|
|
||||||
if maximum <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
seen := make(map[string]struct{}, maximum)
|
|
||||||
for index, row := range rows {
|
|
||||||
if _, exists := seen[row.COSKey]; exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(seen) == maximum {
|
|
||||||
return rows[:index]
|
|
||||||
}
|
|
||||||
seen[row.COSKey] = struct{}{}
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
func rowsShareArchiveCOSKey(rows []model.TaskCenterEventArchiveOutbox) bool {
|
|
||||||
if len(rows) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
key := rows[0].COSKey
|
|
||||||
for _, row := range rows[1:] {
|
|
||||||
if row.COSKey != key {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildArchiveGCWorkerIdentity() string {
|
|
||||||
hostname, _ := os.Hostname()
|
|
||||||
if strings.TrimSpace(hostname) == "" {
|
|
||||||
hostname = "unknown-host"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s:%d", hostname, os.Getpid())
|
|
||||||
}
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
package taskcenter
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
archiveManifestObjectArchive = "ARCHIVE"
|
|
||||||
archiveManifestObjectRecovery = "GC_RECOVERY"
|
|
||||||
archiveManifestVerified = "VERIFIED"
|
|
||||||
)
|
|
||||||
|
|
||||||
// upsertArchiveManifest 以 COS key 为不可变对象身份。重验历史对象时只刷新校验结果,
|
|
||||||
// 不创建重复清单;GC 删除前可据此确认看到的是同一个对象版本和摘要。
|
|
||||||
func (s *Service) upsertArchiveManifest(ctx context.Context, row model.TaskCenterArchiveManifest) error {
|
|
||||||
now := time.Now()
|
|
||||||
if row.CreateTime.IsZero() {
|
|
||||||
row.CreateTime = now
|
|
||||||
}
|
|
||||||
row.UpdateTime = now
|
|
||||||
return s.db.WithContext(ctx).Clauses(clause.OnConflict{
|
|
||||||
Columns: []clause.Column{{Name: "cos_key"}},
|
|
||||||
DoUpdates: clause.Assignments(map[string]any{
|
|
||||||
"object_type": row.ObjectType,
|
|
||||||
"run_id": row.RunID,
|
|
||||||
"parent_cos_key": row.ParentCOSKey,
|
|
||||||
"sha256": row.SHA256,
|
|
||||||
"etag": row.ETag,
|
|
||||||
"compressed_size": row.CompressedSize,
|
|
||||||
"row_count": row.RowCount,
|
|
||||||
"min_outbox_id": row.MinOutboxID,
|
|
||||||
"max_outbox_id": row.MaxOutboxID,
|
|
||||||
"min_occurred_at": row.MinOccurredAt,
|
|
||||||
"max_occurred_at": row.MaxOccurredAt,
|
|
||||||
"verification_status": row.VerificationStatus,
|
|
||||||
"verified_at": row.VerifiedAt,
|
|
||||||
"update_time": row.UpdateTime,
|
|
||||||
}),
|
|
||||||
}).Create(&row).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func archiveGroupBounds(rows []model.TaskCenterEventArchiveOutbox) (*int64, *int64, *time.Time, *time.Time) {
|
|
||||||
if len(rows) == 0 {
|
|
||||||
return nil, nil, nil, nil
|
|
||||||
}
|
|
||||||
minID, maxID := rows[0].ID, rows[0].ID
|
|
||||||
var minOccurredAt, maxOccurredAt *time.Time
|
|
||||||
for _, row := range rows {
|
|
||||||
if row.ID < minID {
|
|
||||||
minID = row.ID
|
|
||||||
}
|
|
||||||
if row.ID > maxID {
|
|
||||||
maxID = row.ID
|
|
||||||
}
|
|
||||||
if row.OccurredAt.IsZero() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
value := row.OccurredAt
|
|
||||||
if minOccurredAt == nil || value.Before(*minOccurredAt) {
|
|
||||||
copyValue := value
|
|
||||||
minOccurredAt = ©Value
|
|
||||||
}
|
|
||||||
if maxOccurredAt == nil || value.After(*maxOccurredAt) {
|
|
||||||
copyValue := value
|
|
||||||
maxOccurredAt = ©Value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &minID, &maxID, minOccurredAt, maxOccurredAt
|
|
||||||
}
|
|
||||||
@ -1,180 +0,0 @@
|
|||||||
package taskcenter
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"compress/gzip"
|
|
||||||
"context"
|
|
||||||
"crypto/md5"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/tencentyun/cos-go-sdk-v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
maxArchiveCOSObjectBytes = 128 << 20
|
|
||||||
maxArchiveCOSUncompressedBytes = 256 << 20
|
|
||||||
)
|
|
||||||
|
|
||||||
type verifiedArchiveObject struct {
|
|
||||||
COSKey string
|
|
||||||
SHA256 string
|
|
||||||
ETag string
|
|
||||||
CompressedSize int64
|
|
||||||
Header http.Header
|
|
||||||
}
|
|
||||||
|
|
||||||
// putAndHeadArchiveObject 将本地摘要同时写入 COS metadata,并在 PUT 后重新 HEAD。
|
|
||||||
// 只有服务端返回的长度和摘要都与本地一致才继续写 manifest/outbox,防止网关成功响应、
|
|
||||||
// 错误对象 key 或被并发覆盖造成“数据库显示已归档但对象不可恢复”。
|
|
||||||
func putAndHeadArchiveObject(
|
|
||||||
ctx context.Context,
|
|
||||||
client *cos.Client,
|
|
||||||
cosKey string,
|
|
||||||
body []byte,
|
|
||||||
metadata map[string]string,
|
|
||||||
) (verifiedArchiveObject, error) {
|
|
||||||
sha := archiveSHA256(body)
|
|
||||||
headers := make(http.Header)
|
|
||||||
headers.Set("x-cos-meta-sha256", sha)
|
|
||||||
for key, value := range metadata {
|
|
||||||
headers.Set("x-cos-meta-"+strings.ToLower(strings.TrimSpace(key)), strings.TrimSpace(value))
|
|
||||||
}
|
|
||||||
response, err := client.Object.Put(ctx, cosKey, bytes.NewReader(body), &cos.ObjectPutOptions{
|
|
||||||
ObjectPutHeaderOptions: &cos.ObjectPutHeaderOptions{
|
|
||||||
ContentType: "application/gzip",
|
|
||||||
ContentMD5: archiveContentMD5(body),
|
|
||||||
ContentLength: int64(len(body)),
|
|
||||||
XCosMetaXXX: &headers,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if response != nil && response.Body != nil {
|
|
||||||
_ = response.Body.Close()
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return verifiedArchiveObject{}, err
|
|
||||||
}
|
|
||||||
verified, err := headAndVerifyArchiveObject(ctx, client, cosKey, sha, int64(len(body)), true)
|
|
||||||
if err != nil {
|
|
||||||
return verifiedArchiveObject{}, err
|
|
||||||
}
|
|
||||||
for key, expected := range metadata {
|
|
||||||
headerName := "x-cos-meta-" + strings.ToLower(strings.TrimSpace(key))
|
|
||||||
if actual := strings.TrimSpace(verified.Header.Get(headerName)); actual != strings.TrimSpace(expected) {
|
|
||||||
return verifiedArchiveObject{}, fmt.Errorf("COS HEAD %s metadata %s does not match", cosKey, headerName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return verified, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func headAndVerifyArchiveObject(
|
|
||||||
ctx context.Context,
|
|
||||||
client *cos.Client,
|
|
||||||
cosKey, expectedSHA string,
|
|
||||||
expectedSize int64,
|
|
||||||
requireSHAMetadata bool,
|
|
||||||
) (verifiedArchiveObject, error) {
|
|
||||||
response, err := client.Object.Head(ctx, cosKey, nil)
|
|
||||||
if response != nil && response.Body != nil {
|
|
||||||
defer response.Body.Close()
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return verifiedArchiveObject{}, err
|
|
||||||
}
|
|
||||||
if response == nil || response.Response == nil {
|
|
||||||
return verifiedArchiveObject{}, fmt.Errorf("COS HEAD %s returned no response", cosKey)
|
|
||||||
}
|
|
||||||
size, err := strconv.ParseInt(strings.TrimSpace(response.Header.Get("Content-Length")), 10, 64)
|
|
||||||
if err != nil || size < 0 {
|
|
||||||
return verifiedArchiveObject{}, fmt.Errorf("COS HEAD %s has invalid content length", cosKey)
|
|
||||||
}
|
|
||||||
if expectedSize >= 0 && size != expectedSize {
|
|
||||||
return verifiedArchiveObject{}, fmt.Errorf("COS HEAD %s size %d does not match %d", cosKey, size, expectedSize)
|
|
||||||
}
|
|
||||||
remoteSHA := strings.TrimSpace(response.Header.Get("x-cos-meta-sha256"))
|
|
||||||
if requireSHAMetadata && remoteSHA == "" {
|
|
||||||
return verifiedArchiveObject{}, fmt.Errorf("COS HEAD %s is missing required sha256 metadata", cosKey)
|
|
||||||
}
|
|
||||||
if remoteSHA != "" && expectedSHA != "" && !strings.EqualFold(remoteSHA, expectedSHA) {
|
|
||||||
return verifiedArchiveObject{}, fmt.Errorf("COS HEAD %s sha256 metadata mismatch", cosKey)
|
|
||||||
}
|
|
||||||
return verifiedArchiveObject{
|
|
||||||
COSKey: cosKey,
|
|
||||||
SHA256: expectedSHA,
|
|
||||||
ETag: strings.Trim(strings.TrimSpace(response.Header.Get("ETag")), `"`),
|
|
||||||
CompressedSize: size,
|
|
||||||
Header: response.Header.Clone(),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// downloadArchiveObject 强制 identity 传输并限制压缩对象大小。清理路径必须拿到对象原始
|
|
||||||
// gzip 字节才能同时验证 SHA256 和 JSONL,不能只相信 HEAD/ETag。
|
|
||||||
func downloadArchiveObject(ctx context.Context, client *cos.Client, cosKey string) ([]byte, http.Header, error) {
|
|
||||||
headers := make(http.Header)
|
|
||||||
headers.Set("Accept-Encoding", "identity")
|
|
||||||
response, err := client.Object.Get(ctx, cosKey, &cos.ObjectGetOptions{XOptionHeader: &headers})
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
if response == nil || response.Response == nil || response.Body == nil {
|
|
||||||
return nil, nil, fmt.Errorf("COS GET %s returned no body", cosKey)
|
|
||||||
}
|
|
||||||
defer response.Body.Close()
|
|
||||||
body, err := io.ReadAll(io.LimitReader(response.Body, maxArchiveCOSObjectBytes+1))
|
|
||||||
if err != nil {
|
|
||||||
return nil, response.Header, err
|
|
||||||
}
|
|
||||||
if len(body) > maxArchiveCOSObjectBytes {
|
|
||||||
return nil, response.Header, fmt.Errorf("COS object %s exceeds %d bytes", cosKey, maxArchiveCOSObjectBytes)
|
|
||||||
}
|
|
||||||
return body, response.Header, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// archiveObjectLines 解压并返回规范化 JSONL 行。历史对象与新对象都由同一个编码器写入;
|
|
||||||
// 空行被忽略,但单行上限和总解压大小都有硬门槛,避免损坏对象造成内存放大。
|
|
||||||
func archiveObjectLines(body []byte) ([]string, error) {
|
|
||||||
if len(body) < 2 || body[0] != 0x1f || body[1] != 0x8b {
|
|
||||||
return nil, fmt.Errorf("archive object is not gzip encoded")
|
|
||||||
}
|
|
||||||
reader, err := gzip.NewReader(bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer reader.Close()
|
|
||||||
uncompressed, err := io.ReadAll(io.LimitReader(reader, maxArchiveCOSUncompressedBytes+1))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(uncompressed) > maxArchiveCOSUncompressedBytes {
|
|
||||||
return nil, fmt.Errorf("archive object expands beyond %d bytes", maxArchiveCOSUncompressedBytes)
|
|
||||||
}
|
|
||||||
parts := bytes.Split(uncompressed, []byte{'\n'})
|
|
||||||
lines := make([]string, 0, len(parts))
|
|
||||||
for _, part := range parts {
|
|
||||||
line := strings.TrimSpace(string(part))
|
|
||||||
if line != "" {
|
|
||||||
lines = append(lines, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return lines, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func archiveSHA256(value []byte) string {
|
|
||||||
sum := sha256.Sum256(value)
|
|
||||||
return hex.EncodeToString(sum[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
func archiveContentMD5(value []byte) string {
|
|
||||||
sum := md5.Sum(value)
|
|
||||||
return base64.StdEncoding.EncodeToString(sum[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
func archiveLineSHA256(value string) string {
|
|
||||||
return archiveSHA256([]byte(strings.TrimSpace(value)))
|
|
||||||
}
|
|
||||||
@ -67,22 +67,6 @@ func (s *Service) ProcessEvent(ctx context.Context, req EventRequest) (*EventRes
|
|||||||
}
|
}
|
||||||
occurredAt := parseOccurredAt(req.OccurredAt, time.Now())
|
occurredAt := parseOccurredAt(req.OccurredAt, time.Now())
|
||||||
receivedAt := time.Now()
|
receivedAt := time.Now()
|
||||||
// 游戏王以任务中心已经归一化的 GAME_CONSUME_GOLD 为唯一事实来源。这里必须位于
|
|
||||||
// task-center 自身去重事务之前:即使当前没有配置任务,活动仍要统计;sink 失败时也
|
|
||||||
// 不能提前写入任务去重表,否则 MQ 重投会永久跳过活动入账。
|
|
||||||
if eventType == EventTypeGameConsumeGold && s.gameConsumeSink != nil {
|
|
||||||
if err := s.gameConsumeSink.ConsumeGameEvent(ctx, ValidatedEvent{
|
|
||||||
SysOrigin: sysOrigin,
|
|
||||||
EventID: eventID,
|
|
||||||
EventType: eventType,
|
|
||||||
UserID: userID,
|
|
||||||
DeltaValue: req.DeltaValue,
|
|
||||||
OccurredAt: occurredAt,
|
|
||||||
Payload: req.Payload,
|
|
||||||
}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rawJSON := buildArchiveRawJSON(req, sysOrigin, eventID, eventType, userID, req.DeltaValue, occurredAt, receivedAt, "", "")
|
rawJSON := buildArchiveRawJSON(req, sysOrigin, eventID, eventType, userID, req.DeltaValue, occurredAt, receivedAt, "", "")
|
||||||
|
|
||||||
bundle, err := s.loadEnabledBundle(ctx, sysOrigin)
|
bundle, err := s.loadEnabledBundle(ctx, sysOrigin)
|
||||||
|
|||||||
@ -42,73 +42,6 @@ func newTestService(t *testing.T, java taskCenterJavaGateway) (*Service, *gorm.D
|
|||||||
return service, db
|
return service, db
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeTaskCenterArchiveBatchSize(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input int
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{name: "default", input: 0, want: defaultTaskCenterArchiveBatchSize},
|
|
||||||
{name: "negative default", input: -1, want: defaultTaskCenterArchiveBatchSize},
|
|
||||||
{name: "keeps configured value", input: 200, want: 200},
|
|
||||||
{name: "caps large batch", input: 10000, want: maxTaskCenterArchiveBatchSize},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if got := normalizeTaskCenterArchiveBatchSize(tt.input); got != tt.want {
|
|
||||||
t.Fatalf("normalizeTaskCenterArchiveBatchSize(%d) = %d, want %d", tt.input, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTaskCenterArchiveClaimLimitsReserveStaleRows(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
batchSize int
|
|
||||||
wantStale int
|
|
||||||
wantRetryable int
|
|
||||||
}{
|
|
||||||
{name: "single row", batchSize: 1, wantStale: 1, wantRetryable: 0},
|
|
||||||
{name: "small batch keeps one stale slot", batchSize: 5, wantStale: 1, wantRetryable: 4},
|
|
||||||
{name: "normal batch uses ten percent", batchSize: 500, wantStale: 50, wantRetryable: 450},
|
|
||||||
{name: "large batch caps stale slot", batchSize: 1000, wantStale: 100, wantRetryable: 900},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
gotStale, gotRetryable := taskCenterArchiveClaimLimits(tt.batchSize)
|
|
||||||
if gotStale != tt.wantStale || gotRetryable != tt.wantRetryable {
|
|
||||||
t.Fatalf("taskCenterArchiveClaimLimits(%d) = (%d, %d), want (%d, %d)",
|
|
||||||
tt.batchSize, gotStale, gotRetryable, tt.wantStale, tt.wantRetryable)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTaskCenterArchivePendingClaimLimitReservesFailedRows(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
retryableLimit int
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{name: "empty", retryableLimit: 0, want: 0},
|
|
||||||
{name: "single row stays pending first", retryableLimit: 1, want: 1},
|
|
||||||
{name: "small retry batch keeps one failed slot", retryableLimit: 4, want: 3},
|
|
||||||
{name: "normal retry batch reserves ten percent", retryableLimit: 450, want: 405},
|
|
||||||
{name: "large retry batch caps failed reserve", retryableLimit: 900, want: 810},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if got := taskCenterArchivePendingClaimLimit(tt.retryableLimit); got != tt.want {
|
|
||||||
t.Fatalf("taskCenterArchivePendingClaimLimit(%d) = %d, want %d", tt.retryableLimit, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDailyTaskConfigEventListAndClaim(t *testing.T) {
|
func TestDailyTaskConfigEventListAndClaim(t *testing.T) {
|
||||||
java := &taskCenterTestGateway{events: map[string]bool{}}
|
java := &taskCenterTestGateway{events: map[string]bool{}}
|
||||||
service, db := newTestService(t, java)
|
service, db := newTestService(t, java)
|
||||||
|
|||||||
@ -62,24 +62,6 @@ type taskCenterJavaGateway interface {
|
|||||||
ExistsGoldEvent(ctx context.Context, eventID string) (bool, error)
|
ExistsGoldEvent(ctx context.Context, eventID string) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidatedEvent 是任务中心完成身份、事件类型和正数金额校验后的稳定事件视图。
|
|
||||||
// 下游活动只依赖该视图,避免重复解释 HTTP/MQ 两种输入格式。
|
|
||||||
type ValidatedEvent struct {
|
|
||||||
SysOrigin string
|
|
||||||
EventID string
|
|
||||||
EventType string
|
|
||||||
UserID int64
|
|
||||||
DeltaValue int64
|
|
||||||
OccurredAt time.Time
|
|
||||||
Payload map[string]any
|
|
||||||
}
|
|
||||||
|
|
||||||
// GameConsumeSink 接收权威 GAME_CONSUME_GOLD 事件。
|
|
||||||
// sink 自己必须通过业务唯一键幂等,因为 HTTP 与 MQ 可能同时送达同一事件。
|
|
||||||
type GameConsumeSink interface {
|
|
||||||
ConsumeGameEvent(context.Context, ValidatedEvent) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Service 负责任务配置、进度、领取和事件入库。
|
// Service 负责任务配置、进度、领取和事件入库。
|
||||||
type Service struct {
|
type Service struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
@ -87,7 +69,6 @@ type Service struct {
|
|||||||
java taskCenterJavaGateway
|
java taskCenterJavaGateway
|
||||||
location *time.Location
|
location *time.Location
|
||||||
rocketConsumer rmq.PushConsumer
|
rocketConsumer rmq.PushConsumer
|
||||||
gameConsumeSink GameConsumeSink
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(cfg config.Config, db taskCenterDB, java taskCenterJavaGateway) *Service {
|
func NewService(cfg config.Config, db taskCenterDB, java taskCenterJavaGateway) *Service {
|
||||||
@ -102,13 +83,6 @@ func NewService(cfg config.Config, db taskCenterDB, java taskCenterJavaGateway)
|
|||||||
return &Service{cfg: cfg, db: db, java: java, location: location}
|
return &Service{cfg: cfg, db: db, java: java, location: location}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetGameConsumeSink 把固定游戏消耗事件扇出给活动模块。
|
|
||||||
func (s *Service) SetGameConsumeSink(sink GameConsumeSink) {
|
|
||||||
if s != nil {
|
|
||||||
s.gameConsumeSink = sink
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TaskConfigPayload 是后台配置页的任务读模型。
|
// TaskConfigPayload 是后台配置页的任务读模型。
|
||||||
type TaskConfigPayload struct {
|
type TaskConfigPayload struct {
|
||||||
ID int64 `json:"id,string"`
|
ID int64 `json:"id,string"`
|
||||||
|
|||||||
@ -73,16 +73,16 @@ func pointerTimeValue(value *time.Time) time.Time {
|
|||||||
return *value
|
return *value
|
||||||
}
|
}
|
||||||
|
|
||||||
func decimalPercent(current, need int64) (string, float64) {
|
func decimalPercent(current, need int64) (string, int) {
|
||||||
if need <= 0 || current <= 0 {
|
if need <= 0 || current <= 0 {
|
||||||
return "0.0000", 0
|
return "0.0000", 0
|
||||||
}
|
}
|
||||||
if current >= need {
|
percent := float64(current) * 100 / float64(need)
|
||||||
return "1.0000", 1
|
display := int(math.Floor(percent))
|
||||||
|
if display > 100 {
|
||||||
|
display = 100
|
||||||
}
|
}
|
||||||
// App 端按 0-1 比例换算百分比;截断而非四舍五入,避免未充满时提前显示 100%。
|
return fmt.Sprintf("%.4f", percent), display
|
||||||
ratio := math.Floor(float64(current)/float64(need)*10000) / 10000
|
|
||||||
return fmt.Sprintf("%.4f", ratio), ratio
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func parsePercent(value string) float64 {
|
func parsePercent(value string) float64 {
|
||||||
|
|||||||
@ -422,7 +422,7 @@ type StatusResponse struct {
|
|||||||
CurrentEnergy int64 `json:"currentEnergy"`
|
CurrentEnergy int64 `json:"currentEnergy"`
|
||||||
NeedEnergy int64 `json:"needEnergy"`
|
NeedEnergy int64 `json:"needEnergy"`
|
||||||
EnergyPercent string `json:"energyPercent"`
|
EnergyPercent string `json:"energyPercent"`
|
||||||
DisplayPercent float64 `json:"displayPercent"`
|
DisplayPercent int `json:"displayPercent"`
|
||||||
LaunchCountdownSeconds int `json:"launchCountdownSeconds,omitempty"`
|
LaunchCountdownSeconds int `json:"launchCountdownSeconds,omitempty"`
|
||||||
Shake bool `json:"shake"`
|
Shake bool `json:"shake"`
|
||||||
PreviewRocketURL string `json:"previewRocketUrl,omitempty"`
|
PreviewRocketURL string `json:"previewRocketUrl,omitempty"`
|
||||||
|
|||||||
@ -132,8 +132,6 @@ func (s *Service) PageAdminRecords(
|
|||||||
Size: limit,
|
Size: limit,
|
||||||
TotalPaidGold: summary.TotalPaidGold,
|
TotalPaidGold: summary.TotalPaidGold,
|
||||||
TotalRewardGold: summary.TotalRewardGold,
|
TotalRewardGold: summary.TotalRewardGold,
|
||||||
TotalGiftRewardGold: summary.TotalGiftRewardGold,
|
|
||||||
TotalDecorationRewardGold: summary.TotalDecorationRewardGold,
|
|
||||||
TotalDrawTimes: summary.TotalDrawTimes,
|
TotalDrawTimes: summary.TotalDrawTimes,
|
||||||
ParticipantCount: summary.ParticipantCount,
|
ParticipantCount: summary.ParticipantCount,
|
||||||
StartTime: formatDateTime(filter.StartTime),
|
StartTime: formatDateTime(filter.StartTime),
|
||||||
@ -254,8 +252,6 @@ type wheelAdminRecordFilter struct {
|
|||||||
type wheelAdminRecordSummary struct {
|
type wheelAdminRecordSummary struct {
|
||||||
TotalPaidGold int64
|
TotalPaidGold int64
|
||||||
TotalRewardGold int64
|
TotalRewardGold int64
|
||||||
TotalGiftRewardGold int64
|
|
||||||
TotalDecorationRewardGold int64
|
|
||||||
TotalDrawTimes int64
|
TotalDrawTimes int64
|
||||||
ParticipantCount int64
|
ParticipantCount int64
|
||||||
}
|
}
|
||||||
@ -352,16 +348,9 @@ func (s *Service) adminRecordQuery(ctx context.Context, filter wheelAdminRecordF
|
|||||||
|
|
||||||
func (s *Service) adminRecordSummary(ctx context.Context, filter wheelAdminRecordFilter) (wheelAdminRecordSummary, error) {
|
func (s *Service) adminRecordSummary(ctx context.Context, filter wheelAdminRecordFilter) (wheelAdminRecordSummary, error) {
|
||||||
var summary wheelAdminRecordSummary
|
var summary wheelAdminRecordSummary
|
||||||
// totalRewardGold keeps the historical dashboard number, including direct gold rewards.
|
|
||||||
// The two split fields only count RESOURCE rewards: GIFT is the gift value, every other
|
|
||||||
// resourceType is treated as decoration value for the admin cards.
|
|
||||||
if err := s.adminRecordQuery(ctx, filter).
|
if err := s.adminRecordQuery(ctx, filter).
|
||||||
Select(`
|
Select("COALESCE(SUM(CASE WHEN status = ? THEN CASE WHEN reward_type = ? THEN gold_amount ELSE display_gold_amount END ELSE 0 END), 0)", drawStatusSuccess, rewardTypeGold).
|
||||||
COALESCE(SUM(CASE WHEN status = ? THEN CASE WHEN reward_type = ? THEN gold_amount ELSE display_gold_amount END ELSE 0 END), 0) AS total_reward_gold,
|
Scan(&summary.TotalRewardGold).Error; err != nil {
|
||||||
COALESCE(SUM(CASE WHEN status = ? AND reward_type = ? AND UPPER(resource_type) = ? THEN display_gold_amount ELSE 0 END), 0) AS total_gift_reward_gold,
|
|
||||||
COALESCE(SUM(CASE WHEN status = ? AND reward_type = ? AND UPPER(resource_type) <> ? THEN display_gold_amount ELSE 0 END), 0) AS total_decoration_reward_gold
|
|
||||||
`, drawStatusSuccess, rewardTypeGold, drawStatusSuccess, rewardTypeResource, resourceTypeGift, drawStatusSuccess, rewardTypeResource, resourceTypeGift).
|
|
||||||
Scan(&summary).Error; err != nil {
|
|
||||||
return summary, err
|
return summary, err
|
||||||
}
|
}
|
||||||
if err := s.adminRecordQuery(ctx, filter).
|
if err := s.adminRecordQuery(ctx, filter).
|
||||||
|
|||||||
@ -104,8 +104,8 @@ func TestPageAdminRecordsIncludesUserInfoAndRangeSummary(t *testing.T) {
|
|||||||
PaidGold: 200,
|
PaidGold: 200,
|
||||||
RewardType: rewardTypeResource,
|
RewardType: rewardTypeResource,
|
||||||
ResourceID: &giftID,
|
ResourceID: &giftID,
|
||||||
ResourceType: "AVATAR_FRAME",
|
ResourceType: resourceTypeGift,
|
||||||
ResourceName: "Frame B",
|
ResourceName: "Gift B",
|
||||||
DisplayGoldAmount: 30,
|
DisplayGoldAmount: 30,
|
||||||
Probability: 500000,
|
Probability: 500000,
|
||||||
Status: drawStatusSuccess,
|
Status: drawStatusSuccess,
|
||||||
@ -149,12 +149,6 @@ func TestPageAdminRecordsIncludesUserInfoAndRangeSummary(t *testing.T) {
|
|||||||
if resp.TotalRewardGold != 150 {
|
if resp.TotalRewardGold != 150 {
|
||||||
t.Fatalf("total reward = %d, want 150", resp.TotalRewardGold)
|
t.Fatalf("total reward = %d, want 150", resp.TotalRewardGold)
|
||||||
}
|
}
|
||||||
if resp.TotalGiftRewardGold != 50 {
|
|
||||||
t.Fatalf("total gift reward = %d, want 50", resp.TotalGiftRewardGold)
|
|
||||||
}
|
|
||||||
if resp.TotalDecorationRewardGold != 30 {
|
|
||||||
t.Fatalf("total decoration reward = %d, want 30", resp.TotalDecorationRewardGold)
|
|
||||||
}
|
|
||||||
if resp.TotalDrawTimes != 12 {
|
if resp.TotalDrawTimes != 12 {
|
||||||
t.Fatalf("total draw times = %d, want 12", resp.TotalDrawTimes)
|
t.Fatalf("total draw times = %d, want 12", resp.TotalDrawTimes)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -313,8 +313,6 @@ type RecordPageResponse struct {
|
|||||||
Size int `json:"size"`
|
Size int `json:"size"`
|
||||||
TotalPaidGold int64 `json:"totalPaidGold,omitempty"`
|
TotalPaidGold int64 `json:"totalPaidGold,omitempty"`
|
||||||
TotalRewardGold int64 `json:"totalRewardGold,omitempty"`
|
TotalRewardGold int64 `json:"totalRewardGold,omitempty"`
|
||||||
TotalGiftRewardGold int64 `json:"totalGiftRewardGold,omitempty"`
|
|
||||||
TotalDecorationRewardGold int64 `json:"totalDecorationRewardGold,omitempty"`
|
|
||||||
TotalDrawTimes int64 `json:"totalDrawTimes,omitempty"`
|
TotalDrawTimes int64 `json:"totalDrawTimes,omitempty"`
|
||||||
ParticipantCount int64 `json:"participantCount,omitempty"`
|
ParticipantCount int64 `json:"participantCount,omitempty"`
|
||||||
StartTime string `json:"startTime,omitempty"`
|
StartTime string `json:"startTime,omitempty"`
|
||||||
|
|||||||
@ -1,274 +0,0 @@
|
|||||||
package yumigiftchallenge
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *Service) normalizeSysOrigin(value string) string {
|
|
||||||
value = strings.ToUpper(strings.TrimSpace(value))
|
|
||||||
if value == "" {
|
|
||||||
value = strings.ToUpper(strings.TrimSpace(s.cfg.YumiGiftChallenge.DefaultSysOrigin))
|
|
||||||
}
|
|
||||||
if value == "" {
|
|
||||||
value = defaultSysOrigin
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) requireYumiOrigin(value string) (string, error) {
|
|
||||||
origin := s.normalizeSysOrigin(value)
|
|
||||||
// Yumi 是产品品牌,真实 Java SysOriginPlatformEnum 为 LIKEI。显式拒绝其他来源,
|
|
||||||
// 防止后台误建一个永远收不到 Yumi 送礼事件的孤立活动。
|
|
||||||
if origin != defaultSysOrigin {
|
|
||||||
return "", NewAppError(http.StatusBadRequest, "invalid_sys_origin", "Yumi gift challenge sysOrigin must be LIKEI")
|
|
||||||
}
|
|
||||||
return origin, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveLocation(value string) (*time.Location, error) {
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
if value == "" {
|
|
||||||
value = defaultTimezone
|
|
||||||
}
|
|
||||||
location, err := time.LoadLocation(value)
|
|
||||||
if err != nil {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error())
|
|
||||||
}
|
|
||||||
return location, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func dateOnly(value time.Time, location *time.Location) time.Time {
|
|
||||||
local := value.In(location)
|
|
||||||
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
|
|
||||||
}
|
|
||||||
|
|
||||||
func dateKey(value time.Time, location *time.Location) string {
|
|
||||||
return value.In(location).Format("2006-01-02")
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseDateKey(value string, location *time.Location) (time.Time, error) {
|
|
||||||
parsed, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(value), location)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, NewAppError(http.StatusBadRequest, "invalid_stat_date", "statDate must be yyyy-MM-dd")
|
|
||||||
}
|
|
||||||
return parsed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func activityStatus(activity model.YumiGiftChallengeActivity, now time.Time) string {
|
|
||||||
if !activity.Enabled {
|
|
||||||
return "DISABLED"
|
|
||||||
}
|
|
||||||
if now.Before(activity.StartTime) {
|
|
||||||
return "NOT_STARTED"
|
|
||||||
}
|
|
||||||
if !now.Before(activity.EndTime) {
|
|
||||||
return "ENDED"
|
|
||||||
}
|
|
||||||
return "ONGOING"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) loadActivity(ctx context.Context, id int64) (*model.YumiGiftChallengeActivity, error) {
|
|
||||||
if id <= 0 {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_activity_id", "activityId is required")
|
|
||||||
}
|
|
||||||
var row model.YumiGiftChallengeActivity
|
|
||||||
err := s.db.WithContext(ctx).Where("id = ?", id).First(&row).Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, NewAppError(http.StatusNotFound, "activity_not_found", "activity not found")
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &row, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) selectActivity(ctx context.Context, origin string, id int64, enabledOnly bool) (*model.YumiGiftChallengeActivity, error) {
|
|
||||||
if id > 0 {
|
|
||||||
row, err := s.loadActivity(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if row.SysOrigin != origin || (enabledOnly && !row.Enabled) {
|
|
||||||
return nil, NewAppError(http.StatusNotFound, "activity_not_found", "activity not found")
|
|
||||||
}
|
|
||||||
return row, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
query := s.db.WithContext(ctx).Model(&model.YumiGiftChallengeActivity{}).
|
|
||||||
Where("sys_origin = ?", origin)
|
|
||||||
if enabledOnly {
|
|
||||||
query = query.Where("enabled = ?", true)
|
|
||||||
}
|
|
||||||
// 进行中优先,其次最近待开始,最后最近结束;CASE 只在单一来源的少量活动配置上执行,
|
|
||||||
// 热榜和送礼链路不会走该排序。
|
|
||||||
var row model.YumiGiftChallengeActivity
|
|
||||||
err := query.Order(clause.Expr{SQL: "CASE WHEN start_time <= ? AND end_time > ? THEN 0 WHEN start_time > ? THEN 1 ELSE 2 END", Vars: []any{now, now, now}}).
|
|
||||||
Order(clause.Expr{SQL: "CASE WHEN start_time > ? THEN start_time END ASC", Vars: []any{now}}).
|
|
||||||
Order("end_time DESC").First(&row).Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, NewAppError(http.StatusNotFound, "activity_not_found", "activity not found")
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &row, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) loadActivityChildren(ctx context.Context, activityID int64) ([]model.YumiGiftChallengeTaskConfig, []model.YumiGiftChallengeRankReward, error) {
|
|
||||||
var tasks []model.YumiGiftChallengeTaskConfig
|
|
||||||
if err := s.db.WithContext(ctx).Where("activity_id = ?", activityID).Order("sort_order ASC, id ASC").Find(&tasks).Error; err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
var rewards []model.YumiGiftChallengeRankReward
|
|
||||||
if err := s.db.WithContext(ctx).Where("activity_id = ?", activityID).
|
|
||||||
Order("period_type ASC, start_rank ASC, id ASC").Find(&rewards).Error; err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
return tasks, rewards, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) loadRewardSnapshots(ctx context.Context, activityID int64) (map[int64][]model.YumiGiftChallengeRewardSnapshot, error) {
|
|
||||||
var rows []model.YumiGiftChallengeRewardSnapshot
|
|
||||||
if err := s.db.WithContext(ctx).Where("activity_id = ?", activityID).
|
|
||||||
Order("resource_group_id ASC, sort_order ASC, reward_config_id ASC").Find(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result := make(map[int64][]model.YumiGiftChallengeRewardSnapshot)
|
|
||||||
for _, row := range rows {
|
|
||||||
result[row.ResourceGroupID] = append(result[row.ResourceGroupID], row)
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rewardItemViews(rows []model.YumiGiftChallengeRewardSnapshot) []RewardItemView {
|
|
||||||
items := make([]RewardItemView, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
items = append(items, RewardItemView{
|
|
||||||
ID: row.RewardConfigID, Type: row.RewardType, DetailType: row.DetailType,
|
|
||||||
Content: row.Content, Quantity: row.Quantity, Cover: row.Cover,
|
|
||||||
SourceURL: row.SourceURL, Name: row.DisplayName, Sort: row.SortOrder,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildActivityView(row model.YumiGiftChallengeActivity) ActivityView {
|
|
||||||
return ActivityView{
|
|
||||||
ID: row.ID, ActivityCode: row.ActivityCode, ActivityName: row.ActivityName,
|
|
||||||
ActivityDesc: row.ActivityDesc, SysOrigin: row.SysOrigin, Timezone: row.Timezone,
|
|
||||||
StartTime: row.StartTime.UnixMilli(), EndTime: row.EndTime.UnixMilli(),
|
|
||||||
DailySettlementDelayMinutes: row.DailySettlementDelayMinutes,
|
|
||||||
OverallSettlementDelayMinutes: row.OverallSettlementDelayMinutes,
|
|
||||||
OverallSettlementTime: row.OverallSettlementTime.UnixMilli(), DisplayTopN: row.DisplayTopN,
|
|
||||||
Enabled: row.Enabled, OverallSettlementStatus: row.OverallSettlementStatus,
|
|
||||||
Version: row.Version, CreateTime: row.CreateTime.UnixMilli(), UpdateTime: row.UpdateTime.UnixMilli(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) buildDetail(ctx context.Context, activity model.YumiGiftChallengeActivity) (*DetailResponse, error) {
|
|
||||||
tasks, rewards, err := s.loadActivityChildren(ctx, activity.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
snapshots, err := s.loadRewardSnapshots(ctx, activity.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
taskViews := make([]TaskView, 0, len(tasks))
|
|
||||||
for _, row := range tasks {
|
|
||||||
view := TaskView{
|
|
||||||
ID: row.ID, ActivityID: row.ActivityID, TaskCode: row.TaskCode, TaskType: row.TaskType,
|
|
||||||
TaskTitle: row.TaskTitle, TaskDesc: row.TaskDesc, TargetValue: row.TargetValue,
|
|
||||||
ResourceGroupID: row.ResourceGroupID, Enabled: row.Enabled, SortOrder: row.SortOrder,
|
|
||||||
RewardItems: []RewardItemView{},
|
|
||||||
}
|
|
||||||
if row.ResourceGroupID != nil {
|
|
||||||
view.RewardItems = rewardItemViews(snapshots[*row.ResourceGroupID])
|
|
||||||
}
|
|
||||||
taskViews = append(taskViews, view)
|
|
||||||
}
|
|
||||||
rewardViews := make([]RankRewardView, 0, len(rewards))
|
|
||||||
for _, row := range rewards {
|
|
||||||
rewardViews = append(rewardViews, RankRewardView{
|
|
||||||
ID: row.ID, ActivityID: row.ActivityID, PeriodType: row.PeriodType,
|
|
||||||
StartRank: row.StartRank, EndRank: row.EndRank, ResourceGroupID: row.ResourceGroupID,
|
|
||||||
RewardName: row.RewardName, RewardItems: rewardItemViews(snapshots[row.ResourceGroupID]),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return &DetailResponse{
|
|
||||||
Activity: buildActivityView(activity), Tasks: taskViews, RankRewards: rewardViews,
|
|
||||||
SupportedPeriods: []string{PeriodDaily, PeriodOverall},
|
|
||||||
SupportedTaskTypes: []string{TaskEnterPage, TaskSendGiftGold},
|
|
||||||
ActivityStatus: activityStatus(activity, time.Now()), ServerTime: time.Now().UnixMilli(),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDetail 返回 Yumi H5 活动详情,用户身份只用于来源隔离,不接受客户端伪造 userId。
|
|
||||||
func (s *Service) GetDetail(ctx context.Context, user AuthUser, activityID int64) (*DetailResponse, error) {
|
|
||||||
origin, err := s.requireYumiOrigin(user.SysOrigin)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
activity, err := s.selectActivity(ctx, origin, activityID, true)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s.buildDetail(ctx, *activity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAdminDetail 返回后台完整配置详情。
|
|
||||||
func (s *Service) GetAdminDetail(ctx context.Context, activityID int64) (*DetailResponse, error) {
|
|
||||||
activity, err := s.loadActivity(ctx, activityID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s.buildDetail(ctx, *activity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListActivities 分页读取配置;查询始终命中 sys_origin+时间索引,不扫描送礼明细。
|
|
||||||
func (s *Service) ListActivities(ctx context.Context, origin string, page, size int) (map[string]any, error) {
|
|
||||||
origin, err := s.requireYumiOrigin(origin)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if size < 1 || size > 100 {
|
|
||||||
size = 20
|
|
||||||
}
|
|
||||||
query := s.db.WithContext(ctx).Model(&model.YumiGiftChallengeActivity{}).Where("sys_origin = ?", origin)
|
|
||||||
var total int64
|
|
||||||
if err := query.Count(&total).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var rows []model.YumiGiftChallengeActivity
|
|
||||||
if err := query.Order("start_time DESC, id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
records := make([]ActivityView, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
records = append(records, buildActivityView(row))
|
|
||||||
}
|
|
||||||
return map[string]any{"records": records, "total": total, "current": page, "size": size}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortTaskRows(rows []model.YumiGiftChallengeTaskConfig) {
|
|
||||||
sort.Slice(rows, func(i, j int) bool {
|
|
||||||
if rows[i].SortOrder == rows[j].SortOrder {
|
|
||||||
return rows[i].ID < rows[j].ID
|
|
||||||
}
|
|
||||||
return rows[i].SortOrder < rows[j].SortOrder
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -1,800 +0,0 @@
|
|||||||
package yumigiftchallenge
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
"chatapp3-golang/internal/utils"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
|
||||||
|
|
||||||
var taskCodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
|
||||||
|
|
||||||
// SaveActivity 保存完整配置;活动开始后只允许修改名称和描述,不触碰已生效统计口径。
|
|
||||||
func (s *Service) SaveActivity(ctx context.Context, req SaveRequest) (*DetailResponse, error) {
|
|
||||||
activityID := req.ID.Int64()
|
|
||||||
if activityID > 0 {
|
|
||||||
current, err := s.loadActivity(ctx, activityID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !time.Now().Before(current.StartTime) {
|
|
||||||
return s.saveStartedActivityMetadata(ctx, *current, req)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
origin, err := s.requireYumiOrigin(req.SysOrigin)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
location, startAt, endAt, err := validateActivityInput(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
tasks, err := normalizeTaskInputs(req.Tasks)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
rewards, err := normalizeRankRewardInputs(req.RankRewards, req.DisplayTopN)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if activityID <= 0 {
|
|
||||||
activityID, err = utils.NextID()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
proposed := model.YumiGiftChallengeActivity{
|
|
||||||
ID: activityID, ActivityCode: strings.TrimSpace(req.ActivityCode), ActivityName: strings.TrimSpace(req.ActivityName),
|
|
||||||
ActivityDesc: strings.TrimSpace(req.ActivityDesc), SysOrigin: origin, Timezone: location.String(),
|
|
||||||
StartTime: startAt, EndTime: endAt,
|
|
||||||
DailySettlementDelayMinutes: req.DailySettlementDelayMinutes,
|
|
||||||
OverallSettlementDelayMinutes: req.OverallSettlementDelayMinutes,
|
|
||||||
OverallSettlementTime: endAt.Add(time.Duration(req.OverallSettlementDelayMinutes) * time.Minute),
|
|
||||||
DisplayTopN: req.DisplayTopN, Enabled: req.Enabled, OverallSettlementStatus: StatusNotStarted,
|
|
||||||
CreateTime: now, UpdateTime: now,
|
|
||||||
}
|
|
||||||
for i := range tasks {
|
|
||||||
tasks[i].ActivityID, tasks[i].CreateTime, tasks[i].UpdateTime = activityID, now, now
|
|
||||||
}
|
|
||||||
for i := range rewards {
|
|
||||||
rewards[i].ActivityID, rewards[i].CreateTime, rewards[i].UpdateTime = activityID, now, now
|
|
||||||
}
|
|
||||||
var snapshots []model.YumiGiftChallengeRewardSnapshot
|
|
||||||
var headers []model.YumiGiftChallengePeriodSettlement
|
|
||||||
if req.Enabled {
|
|
||||||
// 所有外部奖励组读取与校验都在事务前完成;任何失败都不会先禁用或删除旧配置。
|
|
||||||
snapshots, err = s.buildRewardSnapshots(ctx, proposed, tasks, rewards)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
headers, err = buildPeriodHeaders(proposed)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if req.ID.Int64() <= 0 {
|
|
||||||
if req.Enabled {
|
|
||||||
if !proposed.StartTime.After(time.Now()) || !proposed.EndTime.After(time.Now()) {
|
|
||||||
return NewAppError(http.StatusConflict, "invalid_enable_window", "enabled activity must start in the future")
|
|
||||||
}
|
|
||||||
if err := ensureNoEnabledOverlap(tx, proposed); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := tx.Create(&proposed).Error; err != nil {
|
|
||||||
if isDuplicateKey(err) {
|
|
||||||
return NewAppError(http.StatusConflict, "duplicate_activity_code", "activityCode already exists")
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if req.Version == nil {
|
|
||||||
return NewAppError(http.StatusBadRequest, "version_required", "version is required when updating")
|
|
||||||
}
|
|
||||||
var current model.YumiGiftChallengeActivity
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", activityID).First(¤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)
|
|
||||||
}
|
|
||||||
@ -1,305 +0,0 @@
|
|||||||
package yumigiftchallenge
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/integration"
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
"chatapp3-golang/internal/utils"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *Service) createDeliveryItemsTx(tx *gorm.DB, ownerType string, ownerID, activityID, userID int64, resourceGroupID *int64) error {
|
|
||||||
if resourceGroupID == nil || *resourceGroupID <= 0 {
|
|
||||||
return NewAppError(http.StatusConflict, "reward_group_missing", "reward group is missing")
|
|
||||||
}
|
|
||||||
var count int64
|
|
||||||
if err := tx.Model(&model.YumiGiftChallengeDeliveryItem{}).
|
|
||||||
Where("owner_type = ? AND owner_id = ?", ownerType, ownerID).Count(&count).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if count > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var snapshots []model.YumiGiftChallengeRewardSnapshot
|
|
||||||
if err := tx.Where("activity_id = ? AND resource_group_id = ?", activityID, *resourceGroupID).
|
|
||||||
Order("sort_order ASC, reward_config_id ASC").Find(&snapshots).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(snapshots) == 0 {
|
|
||||||
return NewAppError(http.StatusConflict, "reward_snapshot_missing", "frozen reward snapshot is missing")
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
items := make([]model.YumiGiftChallengeDeliveryItem, 0, len(snapshots))
|
|
||||||
for _, snapshot := range snapshots {
|
|
||||||
id, err := utils.NextID()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
items = append(items, model.YumiGiftChallengeDeliveryItem{
|
|
||||||
ID: id, OwnerType: ownerType, OwnerID: ownerID, ActivityID: activityID, UserID: userID,
|
|
||||||
RewardConfigID: snapshot.RewardConfigID, ResourceGroupID: snapshot.ResourceGroupID,
|
|
||||||
RewardType: snapshot.RewardType, DetailType: snapshot.DetailType, Content: snapshot.Content,
|
|
||||||
Quantity: snapshot.Quantity, SortOrder: snapshot.SortOrder, Remark: snapshot.Remark,
|
|
||||||
DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// owner_type+owner_id+reward_config_id 唯一键是逐奖励项幂等边界;若并发已经物化,整个事务回滚后调用方重读即可。
|
|
||||||
if err := tx.Create(&items).Error; err != nil && !isDuplicateKey(err) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) deliverOwnerItems(ctx context.Context, ownerType string, ownerID int64) error {
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := s.expireOwnerProcessing(ctx, ownerType, ownerID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for {
|
|
||||||
// 请求或 worker 取消后必须在下一次 claim 前停住,未抢占的奖励项保持 PENDING,
|
|
||||||
// 不能拿已取消的 context 批量调用 Java 并把整期奖励污染成 UNKNOWN。
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
item, err := s.claimNextDeliveryItem(ctx, ownerType, ownerID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if item == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
request := integration.SendFrozenActivityRewardRequest{
|
|
||||||
// 外层 trackId 是当前 delivery item 主键,保证不同用户和不同奖励项互不冲突。
|
|
||||||
TrackID: item.ID, AcceptUserID: item.UserID, Origin: "ACTIVITY_REWARD", SysOrigin: defaultSysOrigin,
|
|
||||||
Prizes: []integration.FrozenActivityReward{{
|
|
||||||
// Java PrizeDescribe.trackId 沿用资源组(商品)ID;真正的发奖幂等号是外层 delivery item ID。
|
|
||||||
TrackID: item.ResourceGroupID, Type: item.RewardType, DetailType: item.DetailType,
|
|
||||||
Content: item.Content, Quantity: item.Quantity, Remark: item.Remark,
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
deliveryErr := s.gateway.SendFrozenActivityReward(ctx, request)
|
|
||||||
// 外部调用一旦发出,即使原 context 已取消也必须用独立短 context 落下 SUCCESS/UNKNOWN;
|
|
||||||
// 状态落库失败时停止后续发送,避免失去可核账的幂等边界。
|
|
||||||
stateCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
finishErr := s.finishDeliveryAttempt(stateCtx, *item, deliveryErr)
|
|
||||||
cancel()
|
|
||||||
if finishErr != nil {
|
|
||||||
return finishErr
|
|
||||||
}
|
|
||||||
if deliveryErr != nil && (errors.Is(deliveryErr, context.Canceled) || errors.Is(deliveryErr, context.DeadlineExceeded) || ctx.Err() != nil) {
|
|
||||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
|
||||||
return ctxErr
|
|
||||||
}
|
|
||||||
return deliveryErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stateCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
err := s.refreshOwnerAndPeriod(stateCtx, ownerType, ownerID)
|
|
||||||
cancel()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) lockOwner(tx *gorm.DB, ownerType string, ownerID int64) error {
|
|
||||||
if ownerType == OwnerTask {
|
|
||||||
var row model.YumiGiftChallengeUserTaskDaily
|
|
||||||
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", ownerID).First(&row).Error
|
|
||||||
}
|
|
||||||
if ownerType == OwnerSettlement {
|
|
||||||
var row model.YumiGiftChallengeSettlement
|
|
||||||
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", ownerID).First(&row).Error
|
|
||||||
}
|
|
||||||
return NewAppError(http.StatusBadRequest, "invalid_owner_type", "invalid delivery owner type")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) expireOwnerProcessing(ctx context.Context, ownerType string, ownerID int64) error {
|
|
||||||
leaseSeconds := s.cfg.YumiGiftChallenge.DeliveryProcessingLeaseSeconds
|
|
||||||
if leaseSeconds < 30 {
|
|
||||||
leaseSeconds = 300
|
|
||||||
}
|
|
||||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
// 所有状态聚合统一 owner→item 锁序,避免补发、核账和正常发送互相死锁。
|
|
||||||
if err := s.lockOwner(tx, ownerType, ownerID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tx.Model(&model.YumiGiftChallengeDeliveryItem{}).
|
|
||||||
Where("owner_type = ? AND owner_id = ? AND delivery_status = ? AND update_time < ?",
|
|
||||||
ownerType, ownerID, DeliveryProcessing, time.Now().Add(-time.Duration(leaseSeconds)*time.Second)).
|
|
||||||
Updates(map[string]any{
|
|
||||||
"delivery_status": DeliveryUnknown, "failure_reason": "DELIVERY_RESULT_UNKNOWN: processing lease expired; reconcile downstream before resolving",
|
|
||||||
"update_time": time.Now(),
|
|
||||||
}).Error
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) claimNextDeliveryItem(ctx context.Context, ownerType string, ownerID int64) (*model.YumiGiftChallengeDeliveryItem, error) {
|
|
||||||
var claimed *model.YumiGiftChallengeDeliveryItem
|
|
||||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := s.lockOwner(tx, ownerType, ownerID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var item model.YumiGiftChallengeDeliveryItem
|
|
||||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
|
|
||||||
Where("owner_type = ? AND owner_id = ? AND delivery_status = ?", ownerType, ownerID, DeliveryPending).
|
|
||||||
Order("sort_order ASC, id ASC").First(&item).Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
result := tx.Model(&model.YumiGiftChallengeDeliveryItem{}).
|
|
||||||
Where("id = ? AND delivery_status = ?", item.ID, DeliveryPending).
|
|
||||||
Updates(map[string]any{"delivery_status": DeliveryProcessing, "retry_count": gorm.Expr("retry_count + 1"), "update_time": now})
|
|
||||||
if result.Error != nil || result.RowsAffected != 1 {
|
|
||||||
return result.Error
|
|
||||||
}
|
|
||||||
item.DeliveryStatus = DeliveryProcessing
|
|
||||||
claimed = &item
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return claimed, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) finishDeliveryAttempt(ctx context.Context, candidate model.YumiGiftChallengeDeliveryItem, deliveryErr error) error {
|
|
||||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := s.lockOwner(tx, candidate.OwnerType, candidate.OwnerID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var item model.YumiGiftChallengeDeliveryItem
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", candidate.ID).First(&item).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if item.DeliveryStatus != DeliveryProcessing {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
updates := map[string]any{"update_time": now}
|
|
||||||
if deliveryErr == nil {
|
|
||||||
updates["delivery_status"], updates["failure_reason"], updates["deliver_time"] = DeliverySuccess, "", now
|
|
||||||
} else {
|
|
||||||
// 外部调用超时、断连或返回异常时无法证明未到账,统一 UNKNOWN;自动流程绝不盲目重发。
|
|
||||||
updates["delivery_status"] = DeliveryUnknown
|
|
||||||
updates["failure_reason"] = truncate(fmt.Sprintf("DELIVERY_RESULT_UNKNOWN: %v", deliveryErr), 500)
|
|
||||||
}
|
|
||||||
if err := tx.Model(&model.YumiGiftChallengeDeliveryItem{}).Where("id = ?", item.ID).Updates(updates).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.finishOwnerStatusLocked(tx, candidate.OwnerType, candidate.OwnerID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) finishOwnerStatusLocked(tx *gorm.DB, ownerType string, ownerID int64) error {
|
|
||||||
var total, unfinished, unknown int64
|
|
||||||
base := tx.Model(&model.YumiGiftChallengeDeliveryItem{}).Where("owner_type = ? AND owner_id = ?", ownerType, ownerID)
|
|
||||||
if err := base.Count(&total).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if total == 0 {
|
|
||||||
return NewAppError(http.StatusConflict, "delivery_items_missing", "delivery items are missing")
|
|
||||||
}
|
|
||||||
if err := tx.Model(&model.YumiGiftChallengeDeliveryItem{}).
|
|
||||||
Where("owner_type = ? AND owner_id = ? AND delivery_status <> ?", ownerType, ownerID, DeliverySuccess).Count(&unfinished).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Model(&model.YumiGiftChallengeDeliveryItem{}).
|
|
||||||
Where("owner_type = ? AND owner_id = ? AND delivery_status = ?", ownerType, ownerID, DeliveryUnknown).Count(&unknown).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
status, reason := DeliveryProcessing, ""
|
|
||||||
updates := map[string]any{"update_time": time.Now()}
|
|
||||||
if unfinished == 0 {
|
|
||||||
status, updates["deliver_time"] = DeliverySuccess, time.Now()
|
|
||||||
} else if unknown > 0 {
|
|
||||||
status, reason = DeliveryUnknown, "MANUAL_RECONCILIATION_REQUIRED: resolve UNKNOWN items after downstream audit"
|
|
||||||
}
|
|
||||||
updates["delivery_status"], updates["failure_reason"] = status, reason
|
|
||||||
if ownerType == OwnerTask {
|
|
||||||
return tx.Model(&model.YumiGiftChallengeUserTaskDaily{}).Where("id = ?", ownerID).Updates(updates).Error
|
|
||||||
}
|
|
||||||
return tx.Model(&model.YumiGiftChallengeSettlement{}).Where("id = ?", ownerID).Updates(updates).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) refreshOwnerAndPeriod(ctx context.Context, ownerType string, ownerID int64) error {
|
|
||||||
var activityID int64
|
|
||||||
var period, periodKey string
|
|
||||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := s.lockOwner(tx, ownerType, ownerID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := s.finishOwnerStatusLocked(tx, ownerType, ownerID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if ownerType == OwnerSettlement {
|
|
||||||
var row model.YumiGiftChallengeSettlement
|
|
||||||
if err := tx.Where("id = ?", ownerID).First(&row).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
activityID, period, periodKey = row.ActivityID, row.PeriodType, row.PeriodKey
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil || ownerType != OwnerSettlement {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.refreshPeriodStatus(ctx, activityID, period, periodKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveUnknownDeliveryItem 只有运营核账后才能把 UNKNOWN 标成到账或重置为 PENDING 补发。
|
|
||||||
func (s *Service) ResolveUnknownDeliveryItem(ctx context.Context, itemID int64, delivered bool) error {
|
|
||||||
var candidate model.YumiGiftChallengeDeliveryItem
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", itemID).First(&candidate).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return NewAppError(http.StatusNotFound, "delivery_item_not_found", "delivery item not found")
|
|
||||||
} else if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := s.lockOwner(tx, candidate.OwnerType, candidate.OwnerID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var item model.YumiGiftChallengeDeliveryItem
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", itemID).First(&item).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if item.DeliveryStatus != DeliveryUnknown {
|
|
||||||
return NewAppError(http.StatusConflict, "delivery_item_not_unknown", "only UNKNOWN item can be reconciled")
|
|
||||||
}
|
|
||||||
status := DeliveryPending
|
|
||||||
updates := map[string]any{"delivery_status": status, "failure_reason": "", "update_time": time.Now()}
|
|
||||||
if delivered {
|
|
||||||
status = DeliverySuccess
|
|
||||||
updates["delivery_status"], updates["deliver_time"] = status, time.Now()
|
|
||||||
}
|
|
||||||
if err := tx.Model(&model.YumiGiftChallengeDeliveryItem{}).Where("id = ?", itemID).Updates(updates).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.finishOwnerStatusLocked(tx, candidate.OwnerType, candidate.OwnerID)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !delivered {
|
|
||||||
if err := s.deliverOwnerItems(ctx, candidate.OwnerType, candidate.OwnerID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s.refreshOwnerAndPeriod(ctx, candidate.OwnerType, candidate.OwnerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func truncate(value string, max int) string {
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
if len(value) <= max {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return value[:max]
|
|
||||||
}
|
|
||||||
@ -1,221 +0,0 @@
|
|||||||
package yumigiftchallenge
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
"chatapp3-golang/internal/utils"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
|
||||||
|
|
||||||
var errMalformedGiftPayload = errors.New("malformed yumi gift challenge payload")
|
|
||||||
|
|
||||||
// ProcessGiftPayload 解析 RocketMQ 原始消息并复用活动入账逻辑。
|
|
||||||
func (s *Service) ProcessGiftPayload(ctx context.Context, payload string) error {
|
|
||||||
payload = strings.TrimSpace(payload)
|
|
||||||
if payload == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
event, err := decodeGiftEvent(payload, s.cfg.YumiGiftChallenge.RocketMQ.Tag)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%w: %v", errMalformedGiftPayload, err)
|
|
||||||
}
|
|
||||||
return s.ProcessGiftEvent(ctx, event)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProcessGiftEvent 只接受普通 GOLD 非背包礼物;幸运礼物和 MAGIC 在金额判断前强制排除。
|
|
||||||
func (s *Service) ProcessGiftEvent(ctx context.Context, event giftEvent) error {
|
|
||||||
trackID := strings.TrimSpace(event.TrackID.String())
|
|
||||||
userID := event.SendUserID.Int64()
|
|
||||||
giftID := event.GiftConfig.ID.Int64()
|
|
||||||
if trackID == "" || userID <= 0 || giftID <= 0 || event.CreateTime.Int64() <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
origin := strings.ToUpper(strings.TrimSpace(event.SysOrigin))
|
|
||||||
if origin == "" || origin != defaultSysOrigin {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
giftType := strings.ToUpper(strings.TrimSpace(event.GiftConfig.Type))
|
|
||||||
giftTab := strings.ToUpper(strings.TrimSpace(event.GiftConfig.GiftTab))
|
|
||||||
acceptCount, acceptsValid := countDistinctAcceptUsers(event.Accepts)
|
|
||||||
quantity, giftCandy := event.Quantity.Int64(), event.GiftConfig.GiftCandy
|
|
||||||
// give_gift_v3 不保证存在可直接信任的 actualAmount;礼物主流水总消耗口径是
|
|
||||||
// 单价 × 数量 × 去重有效收礼人数。缺失收礼人必须忽略,不能默认按 1 人计分。
|
|
||||||
if event.BagGift == nil || *event.BagGift || (event.GiftValue.Bag != nil && *event.GiftValue.Bag) || strings.TrimSpace(giftTab) == "" ||
|
|
||||||
!acceptsValid || quantity <= 0 || !giftCandy.Positive() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
amount, err := giftCandy.MultiplyInt(quantity)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
amount, err = amount.MultiplyInt(int64(acceptCount))
|
|
||||||
if err != nil || !amount.Positive() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if giftType != "GOLD" || giftTab == "LUCKY_GIFT" || giftTab == "MAGIC" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
eventTime := time.UnixMilli(event.CreateTime.Int64())
|
|
||||||
var activity model.YumiGiftChallengeActivity
|
|
||||||
err = s.db.WithContext(ctx).Where(
|
|
||||||
"sys_origin = ? AND enabled = ? AND start_time <= ? AND end_time > ?",
|
|
||||||
origin, true, eventTime, eventTime,
|
|
||||||
).Order("start_time DESC").First(&activity).Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
location, err := resolveLocation(activity.Timezone)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
statDate := dateOnly(eventTime, location)
|
|
||||||
periodKey := dateKey(statDate, location)
|
|
||||||
|
|
||||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
// 热链路只持有共享门闩锁,同一活动的送礼事件可以并发;结算使用排他锁,
|
|
||||||
// 会等待已经进入事务的事件提交,再冻结确定的边界。
|
|
||||||
var dailyHeader model.YumiGiftChallengePeriodSettlement
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where(
|
|
||||||
"activity_id = ? AND period_type = ? AND period_key = ?", activity.ID, PeriodDaily, periodKey,
|
|
||||||
).First(&dailyHeader).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var overallHeader model.YumiGiftChallengePeriodSettlement
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where(
|
|
||||||
"activity_id = ? AND period_type = ? AND period_key = ?", activity.ID, PeriodOverall, PeriodOverall,
|
|
||||||
).First(&overallHeader).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// 总榜门闩关闭后整笔拒绝;日榜门闩关闭只阻止日榜和当日任务,合法迟到礼物仍计入尚未冻结的总榜。
|
|
||||||
if overallHeader.Status != StatusNotStarted {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ledgerID, err := utils.NextID()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
ledger := model.YumiGiftChallengeGiftLedger{
|
|
||||||
ID: ledgerID, ActivityID: activity.ID, SourceEventTrackID: trackID, UserID: userID,
|
|
||||||
GiftID: giftID, GiftTab: giftTab, Amount: amount, EventTime: eventTime,
|
|
||||||
StatDate: periodKey, CreateTime: time.Now(),
|
|
||||||
}
|
|
||||||
if err := tx.Create(&ledger).Error; err != nil {
|
|
||||||
if isDuplicateKey(err) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
// score_reached_time 取已消费事件时间的最大值;乱序迟到事件不能把并列分数的到达时间倒拨,
|
|
||||||
// 从而保证 score DESC、reached_time ASC、user_id ASC 的排序稳定。
|
|
||||||
if err := tx.Exec(`
|
|
||||||
INSERT INTO yumi_gift_challenge_user_score
|
|
||||||
(activity_id, user_id, score, score_reached_time, create_time, update_time)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
score = score + VALUES(score),
|
|
||||||
score_reached_time = GREATEST(score_reached_time, VALUES(score_reached_time)),
|
|
||||||
update_time = VALUES(update_time)`,
|
|
||||||
activity.ID, userID, amount, eventTime, now, now).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if dailyHeader.Status == StatusNotStarted {
|
|
||||||
if err := tx.Exec(`
|
|
||||||
INSERT INTO yumi_gift_challenge_user_daily_score
|
|
||||||
(activity_id, stat_date, user_id, score, score_reached_time, create_time, update_time)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
score = score + VALUES(score),
|
|
||||||
score_reached_time = GREATEST(score_reached_time, VALUES(score_reached_time)),
|
|
||||||
update_time = VALUES(update_time)`,
|
|
||||||
activity.ID, periodKey, userID, amount, eventTime, now, now).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := s.ensureTaskSnapshotsTx(tx, activity, periodKey, userID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// 每个事件只执行一次本更新,因为 ledger 唯一键已在同一事务内成功占位。
|
|
||||||
if err := tx.Exec(`
|
|
||||||
UPDATE yumi_gift_challenge_user_task_daily
|
|
||||||
SET progress_value = LEAST(target_value, progress_value + ?),
|
|
||||||
completed_time = CASE
|
|
||||||
WHEN completed_time IS NULL AND progress_value + ? >= target_value THEN ?
|
|
||||||
ELSE completed_time
|
|
||||||
END,
|
|
||||||
update_time = ?
|
|
||||||
WHERE activity_id = ? AND stat_date = ? AND user_id = ? AND task_type = ?`,
|
|
||||||
amount, amount, eventTime, now, activity.ID, periodKey, userID, TaskSendGiftGold).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func countDistinctAcceptUsers(users []*giftEventUser) (int, bool) {
|
|
||||||
if len(users) == 0 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
seen := make(map[int64]struct{}, len(users))
|
|
||||||
for _, user := range users {
|
|
||||||
if user == nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
id := user.AcceptUserID.Int64()
|
|
||||||
if id <= 0 {
|
|
||||||
// 任一收礼元素不完整都说明事件口径不可信,整条拒绝而不是静默少算人数。
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
seen[id] = struct{}{}
|
|
||||||
}
|
|
||||||
return len(seen), len(seen) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeGiftEvent(payload, expectedTag string) (giftEvent, error) {
|
|
||||||
var event giftEvent
|
|
||||||
if err := json.Unmarshal([]byte(payload), &event); err != nil {
|
|
||||||
return event, err
|
|
||||||
}
|
|
||||||
if event.TrackID.String() != "" || event.SendUserID.Int64() > 0 || event.GiftConfig.ID.Int64() > 0 {
|
|
||||||
return event, nil
|
|
||||||
}
|
|
||||||
var envelope messageEnvelope
|
|
||||||
if err := json.Unmarshal([]byte(payload), &envelope); err != nil {
|
|
||||||
return event, err
|
|
||||||
}
|
|
||||||
if tag := strings.TrimSpace(envelope.Tag); tag != "" && strings.TrimSpace(expectedTag) != "" && tag != strings.TrimSpace(expectedTag) {
|
|
||||||
return giftEvent{}, nil
|
|
||||||
}
|
|
||||||
body := strings.TrimSpace(string(envelope.Body))
|
|
||||||
if body == "" || body == "null" {
|
|
||||||
return giftEvent{}, nil
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(body, `"`) {
|
|
||||||
if err := json.Unmarshal(envelope.Body, &body); err != nil {
|
|
||||||
return event, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(body), &event); err != nil {
|
|
||||||
return event, err
|
|
||||||
}
|
|
||||||
return event, nil
|
|
||||||
}
|
|
||||||
@ -1,113 +0,0 @@
|
|||||||
package yumigiftchallenge
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
rmq "github.com/apache/rocketmq-clients/golang/v5"
|
|
||||||
"github.com/apache/rocketmq-clients/golang/v5/credentials"
|
|
||||||
)
|
|
||||||
|
|
||||||
// StartMessageConsumer 异步启动 Yumi 独立消费组;MQ 临时不可用不能阻断 API 启动。
|
|
||||||
func (s *Service) StartMessageConsumer(ctx context.Context) error {
|
|
||||||
if !s.cfg.YumiGiftChallenge.RocketMQ.Enabled {
|
|
||||||
log.Printf("yumi gift challenge rocketmq consumer disabled")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
mqConfig := s.cfg.YumiGiftChallenge.RocketMQ
|
|
||||||
if strings.TrimSpace(mqConfig.Endpoint) == "" || strings.TrimSpace(mqConfig.AccessKey) == "" || strings.TrimSpace(mqConfig.AccessSecret) == "" {
|
|
||||||
log.Printf("yumi gift challenge rocketmq consumer skipped: endpoint or credentials missing")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
go s.startRocketMQConsumerWithRetry(ctx)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) startRocketMQConsumerWithRetry(ctx context.Context) {
|
|
||||||
retryDelay := 5 * time.Second
|
|
||||||
for ctx.Err() == nil {
|
|
||||||
if err := s.startRocketMQConsumer(ctx); err == nil {
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
log.Printf("start yumi gift challenge rocketmq consumer failed: %v; retry in %s", err, retryDelay)
|
|
||||||
}
|
|
||||||
timer := time.NewTimer(retryDelay)
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
timer.Stop()
|
|
||||||
return
|
|
||||||
case <-timer.C:
|
|
||||||
}
|
|
||||||
if retryDelay < time.Minute {
|
|
||||||
retryDelay *= 2
|
|
||||||
if retryDelay > time.Minute {
|
|
||||||
retryDelay = time.Minute
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) startRocketMQConsumer(ctx context.Context) error {
|
|
||||||
mqConfig := s.cfg.YumiGiftChallenge.RocketMQ
|
|
||||||
filter := rmq.SUB_ALL
|
|
||||||
if tag := strings.TrimSpace(mqConfig.Tag); tag != "" {
|
|
||||||
filter = rmq.NewFilterExpression(tag)
|
|
||||||
}
|
|
||||||
consumer, err := rmq.NewPushConsumer(&rmq.Config{
|
|
||||||
Endpoint: mqConfig.Endpoint, NameSpace: mqConfig.Namespace, ConsumerGroup: mqConfig.ConsumerGroup,
|
|
||||||
Credentials: &credentials.SessionCredentials{
|
|
||||||
AccessKey: mqConfig.AccessKey, AccessSecret: mqConfig.AccessSecret, SecurityToken: mqConfig.SecurityToken,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rmq.WithPushSubscriptionExpressions(map[string]*rmq.FilterExpression{mqConfig.Topic: filter}),
|
|
||||||
rmq.WithPushConsumptionThreadCount(8),
|
|
||||||
rmq.WithPushMaxCacheMessageCount(256),
|
|
||||||
rmq.WithPushMessageListener(&rmq.FuncMessageListener{Consume: func(messageView *rmq.MessageView) rmq.ConsumerResult {
|
|
||||||
if messageView == nil {
|
|
||||||
return rmq.SUCCESS
|
|
||||||
}
|
|
||||||
if err := s.processRocketMQMessage(context.Background(), messageView); err != nil {
|
|
||||||
log.Printf("yumi gift challenge mq process failed. messageId=%s err=%v", messageView.GetMessageId(), err)
|
|
||||||
return rmq.FAILURE
|
|
||||||
}
|
|
||||||
return rmq.SUCCESS
|
|
||||||
}}),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := consumer.Start(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
s.rocketConsumer = consumer
|
|
||||||
log.Printf("yumi gift challenge rocketmq consumer started. topic=%s group=%s tag=%s", mqConfig.Topic, mqConfig.ConsumerGroup, mqConfig.Tag)
|
|
||||||
go func() {
|
|
||||||
<-ctx.Done()
|
|
||||||
if s.rocketConsumer != nil {
|
|
||||||
_ = s.rocketConsumer.GracefulStop()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) processRocketMQMessage(ctx context.Context, messageView *rmq.MessageView) error {
|
|
||||||
payload := strings.TrimSpace(string(messageView.GetBody()))
|
|
||||||
if payload == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := s.ProcessGiftPayload(ctx, payload); err != nil {
|
|
||||||
var syntaxErr *json.SyntaxError
|
|
||||||
var typeErr *json.UnmarshalTypeError
|
|
||||||
if errors.Is(err, errMalformedGiftPayload) || errors.As(err, &syntaxErr) || errors.As(err, &typeErr) {
|
|
||||||
// 毒消息重试不会自愈;记录 messageId 后 ACK,数据库或下游错误仍返回 FAILURE 重试。
|
|
||||||
log.Printf("drop malformed yumi gift challenge message. messageId=%s err=%v", messageView.GetMessageId(), err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -1,320 +0,0 @@
|
|||||||
package yumigiftchallenge
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type rawRankRow struct {
|
|
||||||
UserID int64
|
|
||||||
Score model.Decimal24_2
|
|
||||||
ScoreReachedTime time.Time
|
|
||||||
Rank int
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRanking 返回 H5 排行榜;神秘人不出现在他人的公开榜,但本人仍可查看真实名次。
|
|
||||||
func (s *Service) GetRanking(ctx context.Context, user AuthUser, activityID int64, period, statDate string, limit int) (*RankingResponse, error) {
|
|
||||||
origin, err := s.requireYumiOrigin(user.SysOrigin)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
activity, err := s.selectActivity(ctx, origin, activityID, true)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s.ranking(ctx, *activity, user.UserID, period, statDate, limit, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAdminRanking 返回运营榜单,不应用用户公开隐身过滤,但仍保持同一确定性排序。
|
|
||||||
func (s *Service) GetAdminRanking(ctx context.Context, activityID int64, period, statDate string, limit int) (*RankingResponse, error) {
|
|
||||||
activity, err := s.loadActivity(ctx, activityID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s.ranking(ctx, *activity, 0, period, statDate, limit, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) ranking(ctx context.Context, activity model.YumiGiftChallengeActivity, viewerID int64, period, statDate string, limit int, admin bool) (*RankingResponse, error) {
|
|
||||||
period = strings.ToUpper(strings.TrimSpace(period))
|
|
||||||
if period == "" {
|
|
||||||
period = PeriodDaily
|
|
||||||
}
|
|
||||||
if period != PeriodDaily && period != PeriodOverall {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_period", "period must be DAILY or OVERALL")
|
|
||||||
}
|
|
||||||
if limit < 1 {
|
|
||||||
if admin {
|
|
||||||
limit = 100
|
|
||||||
} else {
|
|
||||||
limit = maxPublicRankingLimit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if limit > activity.DisplayTopN {
|
|
||||||
limit = activity.DisplayTopN
|
|
||||||
}
|
|
||||||
if limit > maxDisplayTopN {
|
|
||||||
limit = maxDisplayTopN
|
|
||||||
}
|
|
||||||
if !admin && limit > maxPublicRankingLimit {
|
|
||||||
// Java 目前只提供单用户 VIP ability 接口,公开榜必须逐人按隐私优先核验;
|
|
||||||
// 因而在服务端而非仅 H5 端限流,避免恶意 limit=500 放大为 500 次下游 HTTP。
|
|
||||||
limit = maxPublicRankingLimit
|
|
||||||
}
|
|
||||||
location, err := resolveLocation(activity.Timezone)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var date *time.Time
|
|
||||||
periodKey := PeriodOverall
|
|
||||||
if period == PeriodDaily {
|
|
||||||
selected, err := selectRankingDate(activity, statDate, location)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
date, periodKey = &selected, dateKey(selected, location)
|
|
||||||
}
|
|
||||||
var header model.YumiGiftChallengePeriodSettlement
|
|
||||||
err = s.db.WithContext(ctx).Where("activity_id = ? AND period_type = ? AND period_key = ?", activity.ID, period, periodKey).First(&header).Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, NewAppError(http.StatusConflict, "settlement_gate_missing", "period settlement gate is missing")
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
settled := header.Status != StatusNotStarted
|
|
||||||
// 与 Aslan 一致:只读取请求的原始 Top N,再过滤神秘人;被隐藏的名次不从 N 之后补位。
|
|
||||||
fetchLimit := limit
|
|
||||||
rows, err := s.loadRankRows(ctx, activity.ID, period, periodKey, date, settled, fetchLimit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
entries := make([]RankEntryView, 0, limit)
|
|
||||||
var my *RankEntryView
|
|
||||||
decorated := s.decorateRankEntries(ctx, activity.SysOrigin, rows, viewerID, admin)
|
|
||||||
for index, row := range rows {
|
|
||||||
view, visible := decorated[index].view, decorated[index].visible
|
|
||||||
if row.UserID == viewerID {
|
|
||||||
copy := view
|
|
||||||
my = ©
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@ -1,129 +0,0 @@
|
|||||||
package yumigiftchallenge
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
defaultSettleDueBatch = 20
|
|
||||||
maxSettleDueBatch = 100
|
|
||||||
settleDueItemTimeout = 30 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
// SettleDue 是 chatapp-cron 的单轮幂等 runner:冻结到期周期,并恢复 PENDING/过租期 PROCESSING。
|
|
||||||
// 本仓库不启动 ticker;同一端点可被多个实例重复调用,数据库门闩和 delivery item 唯一键负责并发幂等。
|
|
||||||
func (s *Service) SettleDue(ctx context.Context, batch int) (*SettleDueResult, error) {
|
|
||||||
batch = normalizeSettleDueBatch(batch)
|
|
||||||
result := &SettleDueResult{FailedKeys: []string{}}
|
|
||||||
var periods []model.YumiGiftChallengePeriodSettlement
|
|
||||||
if err := s.db.WithContext(ctx).
|
|
||||||
Where("status IN ? AND snapshot_due_time <= ?", []string{StatusNotStarted, StatusProcessing}, time.Now()).
|
|
||||||
Order("snapshot_due_time ASC, id ASC").Limit(batch).Find(&periods).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, period := range periods {
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
result.PeriodsScanned++
|
|
||||||
request := SettlementRequest{PeriodType: period.PeriodType}
|
|
||||||
if period.StatDate != nil {
|
|
||||||
request.StatDate = *period.StatDate
|
|
||||||
}
|
|
||||||
itemCtx, cancel := context.WithTimeout(ctx, settleDueItemTimeout)
|
|
||||||
target, err := s.resolveSettlementTarget(itemCtx, period.ActivityID, request)
|
|
||||||
if err == nil {
|
|
||||||
err = s.freezeSettlementTarget(itemCtx, target)
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
// 空榜直接完成;有获奖 parent 时保持 PROCESSING,后面的有界 delivery 恢复负责逐项发放。
|
|
||||||
err = s.refreshPeriodStatus(itemCtx, target.activity.ID, target.period, target.periodKey)
|
|
||||||
}
|
|
||||||
cancel()
|
|
||||||
if err != nil {
|
|
||||||
result.FailedKeys = append(result.FailedKeys, fmt.Sprintf("PERIOD:%d:%s:%s", period.ActivityID, period.PeriodType, period.PeriodKey))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result.PeriodsFrozen++
|
|
||||||
}
|
|
||||||
if err := s.recoverTaskOwners(ctx, batch, result); err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
if err := s.recoverSettlementOwners(ctx, batch, result); err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) recoverTaskOwners(ctx context.Context, batch int, result *SettleDueResult) error {
|
|
||||||
var rows []model.YumiGiftChallengeUserTaskDaily
|
|
||||||
if err := s.db.WithContext(ctx).Select("id").
|
|
||||||
Where("delivery_status = ? OR (delivery_status = ? AND update_time <= ?)", DeliveryPending, DeliveryProcessing, s.deliveryLeaseCutoff()).
|
|
||||||
Order("update_time ASC, id ASC").Limit(batch).Find(&rows).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, row := range rows {
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
result.TaskOwnersScanned++
|
|
||||||
itemCtx, cancel := context.WithTimeout(ctx, settleDueItemTimeout)
|
|
||||||
err := s.deliverOwnerItems(itemCtx, OwnerTask, row.ID)
|
|
||||||
cancel()
|
|
||||||
if err != nil {
|
|
||||||
result.FailedKeys = append(result.FailedKeys, fmt.Sprintf("TASK:%d", row.ID))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result.OwnersProcessed++
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) recoverSettlementOwners(ctx context.Context, batch int, result *SettleDueResult) error {
|
|
||||||
var rows []model.YumiGiftChallengeSettlement
|
|
||||||
if err := s.db.WithContext(ctx).Select("id").
|
|
||||||
Where("delivery_status = ? OR (delivery_status = ? AND update_time <= ?)", DeliveryPending, DeliveryProcessing, s.deliveryLeaseCutoff()).
|
|
||||||
Order("update_time ASC, id ASC").Limit(batch).Find(&rows).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, row := range rows {
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
result.SettlementOwnersScanned++
|
|
||||||
itemCtx, cancel := context.WithTimeout(ctx, settleDueItemTimeout)
|
|
||||||
err := s.ensureSettlementDeliveryItems(itemCtx, row.ID)
|
|
||||||
if err == nil {
|
|
||||||
err = s.deliverOwnerItems(itemCtx, OwnerSettlement, row.ID)
|
|
||||||
}
|
|
||||||
cancel()
|
|
||||||
if err != nil {
|
|
||||||
result.FailedKeys = append(result.FailedKeys, fmt.Sprintf("SETTLEMENT:%d", row.ID))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result.OwnersProcessed++
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeSettleDueBatch(batch int) int {
|
|
||||||
if batch <= 0 {
|
|
||||||
return defaultSettleDueBatch
|
|
||||||
}
|
|
||||||
if batch > maxSettleDueBatch {
|
|
||||||
return maxSettleDueBatch
|
|
||||||
}
|
|
||||||
return batch
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) deliveryLeaseCutoff() time.Time {
|
|
||||||
leaseSeconds := s.cfg.YumiGiftChallenge.DeliveryProcessingLeaseSeconds
|
|
||||||
if leaseSeconds < 30 {
|
|
||||||
leaseSeconds = 300
|
|
||||||
}
|
|
||||||
return time.Now().Add(-time.Duration(leaseSeconds) * time.Second)
|
|
||||||
}
|
|
||||||
@ -1,314 +0,0 @@
|
|||||||
package yumigiftchallenge
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
"chatapp3-golang/internal/utils"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Settle 使用两阶段提交:先冻结门闩和获奖 parent,提交后再物化并发送奖励项。
|
|
||||||
func (s *Service) Settle(ctx context.Context, activityID int64, req SettlementRequest) error {
|
|
||||||
target, err := s.resolveSettlementTarget(ctx, activityID, req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := s.freezeSettlementTarget(ctx, target); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.resumeSettlementTarget(ctx, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
type settlementTarget struct {
|
|
||||||
activity *model.YumiGiftChallengeActivity
|
|
||||||
period string
|
|
||||||
periodKey string
|
|
||||||
statDate *string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) resolveSettlementTarget(ctx context.Context, activityID int64, req SettlementRequest) (settlementTarget, error) {
|
|
||||||
activity, err := s.loadActivity(ctx, activityID)
|
|
||||||
if err != nil {
|
|
||||||
return settlementTarget{}, err
|
|
||||||
}
|
|
||||||
if !activity.Enabled {
|
|
||||||
return settlementTarget{}, NewAppError(http.StatusConflict, "activity_disabled", "activity is disabled")
|
|
||||||
}
|
|
||||||
period := strings.ToUpper(strings.TrimSpace(req.PeriodType))
|
|
||||||
if period != PeriodDaily && period != PeriodOverall {
|
|
||||||
return settlementTarget{}, NewAppError(http.StatusBadRequest, "invalid_period", "periodType must be DAILY or OVERALL")
|
|
||||||
}
|
|
||||||
location, err := resolveLocation(activity.Timezone)
|
|
||||||
if err != nil {
|
|
||||||
return settlementTarget{}, err
|
|
||||||
}
|
|
||||||
periodKey := PeriodOverall
|
|
||||||
var statDate *string
|
|
||||||
if period == PeriodDaily {
|
|
||||||
if strings.TrimSpace(req.StatDate) == "" {
|
|
||||||
return settlementTarget{}, NewAppError(http.StatusBadRequest, "stat_date_required", "statDate is required for DAILY settlement")
|
|
||||||
}
|
|
||||||
date, err := parseDateKey(req.StatDate, location)
|
|
||||||
if err != nil {
|
|
||||||
return settlementTarget{}, err
|
|
||||||
}
|
|
||||||
periodKey = dateKey(date, location)
|
|
||||||
statDate = &periodKey
|
|
||||||
}
|
|
||||||
return settlementTarget{activity: activity, period: period, periodKey: periodKey, statDate: statDate}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) freezeSettlementTarget(ctx context.Context, target settlementTarget) error {
|
|
||||||
// 阶段一独立事务只负责关闭写门闩并冻结获奖 parent;阶段二失败不会重新开放榜单或重算获奖人。
|
|
||||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
var header model.YumiGiftChallengePeriodSettlement
|
|
||||||
queryErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where(
|
|
||||||
"activity_id = ? AND period_type = ? AND period_key = ?", target.activity.ID, target.period, target.periodKey,
|
|
||||||
).First(&header).Error
|
|
||||||
if errors.Is(queryErr, gorm.ErrRecordNotFound) {
|
|
||||||
return NewAppError(http.StatusNotFound, "settlement_gate_not_found", "settlement gate not found")
|
|
||||||
}
|
|
||||||
if queryErr != nil {
|
|
||||||
return queryErr
|
|
||||||
}
|
|
||||||
if time.Now().Before(header.SnapshotDueTime) {
|
|
||||||
return NewAppError(http.StatusConflict, "settlement_not_due", "settlement delay window has not elapsed")
|
|
||||||
}
|
|
||||||
if header.Status != StatusNotStarted {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := tx.Model(&model.YumiGiftChallengePeriodSettlement{}).Where("id = ?", header.ID).
|
|
||||||
Updates(map[string]any{"status": StatusProcessing, "update_time": time.Now()}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if target.period == PeriodOverall {
|
|
||||||
if err := tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ?", target.activity.ID).
|
|
||||||
Updates(map[string]any{"overall_settlement_status": StatusProcessing, "update_time": time.Now()}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s.freezeSettlementParentsTx(tx, *target.activity, target.period, target.periodKey, target.statDate)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) resumeSettlementTarget(ctx context.Context, target settlementTarget) error {
|
|
||||||
var parents []model.YumiGiftChallengeSettlement
|
|
||||||
if err := s.db.WithContext(ctx).Where("activity_id = ? AND period_type = ? AND period_key = ?", target.activity.ID, target.period, target.periodKey).
|
|
||||||
Order("rank_no ASC").Find(&parents).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, parent := range parents {
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := s.ensureSettlementDeliveryItems(ctx, parent.ID); err != nil {
|
|
||||||
// parent 和门闩已经提交,返回错误让运营看到;再次执行同一结算会从固定 parent 继续,不重算榜单。
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := s.deliverOwnerItems(ctx, OwnerSettlement, parent.ID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s.refreshPeriodStatus(ctx, target.activity.ID, target.period, target.periodKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) freezeSettlementParentsTx(tx *gorm.DB, activity model.YumiGiftChallengeActivity, period, periodKey string, statDate *string) error {
|
|
||||||
var rewardConfigs []model.YumiGiftChallengeRankReward
|
|
||||||
if err := tx.Where("activity_id = ? AND period_type = ?", activity.ID, period).
|
|
||||||
Order("start_rank ASC").Find(&rewardConfigs).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
maxRank := 0
|
|
||||||
for _, reward := range rewardConfigs {
|
|
||||||
if reward.EndRank > maxRank {
|
|
||||||
maxRank = reward.EndRank
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if maxRank == 0 {
|
|
||||||
return NewAppError(http.StatusConflict, "rank_reward_missing", "rank reward configuration is missing")
|
|
||||||
}
|
|
||||||
rows, err := s.loadRankRowsTx(tx, activity.ID, period, statDate, maxRank)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
for _, row := range rows {
|
|
||||||
reward := matchRankReward(rewardConfigs, row.Rank)
|
|
||||||
if reward == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
id, err := utils.NextID()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
parent := model.YumiGiftChallengeSettlement{
|
|
||||||
ID: id, ActivityID: activity.ID, PeriodType: period, PeriodKey: periodKey, StatDate: statDate,
|
|
||||||
UserID: row.UserID, RankNo: row.Rank, Score: row.Score, RankRewardID: reward.ID,
|
|
||||||
ResourceGroupID: reward.ResourceGroupID,
|
|
||||||
BusinessNo: fmt.Sprintf("YUMI_GIFT_CHALLENGE:RANK:%d:%s:%s:%d", activity.ID, period, periodKey, row.UserID),
|
|
||||||
DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now,
|
|
||||||
}
|
|
||||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&parent).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) loadRankRowsTx(tx *gorm.DB, activityID int64, period string, statDate *string, limit int) ([]rawRankRow, error) {
|
|
||||||
result := make([]rawRankRow, 0, limit)
|
|
||||||
if period == PeriodDaily {
|
|
||||||
var rows []model.YumiGiftChallengeUserDailyScore
|
|
||||||
if err := tx.Where("activity_id = ? AND stat_date = ? AND score > 0", activityID, *statDate).
|
|
||||||
Order("score DESC, score_reached_time ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for index, row := range rows {
|
|
||||||
result = append(result, rawRankRow{UserID: row.UserID, Score: row.Score, Rank: index + 1})
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
var rows []model.YumiGiftChallengeUserScore
|
|
||||||
if err := tx.Where("activity_id = ? AND score > 0", activityID).
|
|
||||||
Order("score DESC, score_reached_time ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for index, row := range rows {
|
|
||||||
result = append(result, rawRankRow{UserID: row.UserID, Score: row.Score, Rank: index + 1})
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func matchRankReward(rows []model.YumiGiftChallengeRankReward, rank int) *model.YumiGiftChallengeRankReward {
|
|
||||||
for index := range rows {
|
|
||||||
if rank >= rows[index].StartRank && rank <= rows[index].EndRank {
|
|
||||||
return &rows[index]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) ensureSettlementDeliveryItems(ctx context.Context, settlementID int64) error {
|
|
||||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
var parent model.YumiGiftChallengeSettlement
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", settlementID).First(&parent).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
groupID := parent.ResourceGroupID
|
|
||||||
return s.createDeliveryItemsTx(tx, OwnerSettlement, parent.ID, parent.ActivityID, parent.UserID, &groupID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) refreshPeriodStatus(ctx context.Context, activityID int64, period, periodKey string) error {
|
|
||||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
var header model.YumiGiftChallengePeriodSettlement
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where(
|
|
||||||
"activity_id = ? AND period_type = ? AND period_key = ?", activityID, period, periodKey,
|
|
||||||
).First(&header).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var total, unfinished, unknown int64
|
|
||||||
base := tx.Model(&model.YumiGiftChallengeSettlement{}).
|
|
||||||
Where("activity_id = ? AND period_type = ? AND period_key = ?", activityID, period, periodKey)
|
|
||||||
if err := base.Count(&total).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if total > 0 {
|
|
||||||
if err := tx.Model(&model.YumiGiftChallengeSettlement{}).
|
|
||||||
Where("activity_id = ? AND period_type = ? AND period_key = ? AND delivery_status <> ?", activityID, period, periodKey, DeliverySuccess).
|
|
||||||
Count(&unfinished).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Model(&model.YumiGiftChallengeSettlement{}).
|
|
||||||
Where("activity_id = ? AND period_type = ? AND period_key = ? AND delivery_status = ?", activityID, period, periodKey, DeliveryUnknown).
|
|
||||||
Count(&unknown).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
status := StatusCompleted
|
|
||||||
if unfinished > 0 {
|
|
||||||
status = StatusProcessing
|
|
||||||
if unknown > 0 {
|
|
||||||
status = StatusReconciliationRequired
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := tx.Model(&model.YumiGiftChallengePeriodSettlement{}).Where("id = ?", header.ID).
|
|
||||||
Updates(map[string]any{"status": status, "update_time": time.Now()}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if period == PeriodOverall {
|
|
||||||
return tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ?", activityID).
|
|
||||||
Updates(map[string]any{"overall_settlement_status": status, "update_time": time.Now()}).Error
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// PageSettlementRecords 分页查询获奖 parent 及逐奖励项状态,供运营核账。
|
|
||||||
func (s *Service) PageSettlementRecords(ctx context.Context, activityID int64, period, statDate, status string, page, size int) (map[string]any, error) {
|
|
||||||
if _, err := s.loadActivity(ctx, activityID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if size < 1 || size > 100 {
|
|
||||||
size = 20
|
|
||||||
}
|
|
||||||
query := s.db.WithContext(ctx).Model(&model.YumiGiftChallengeSettlement{}).Where("activity_id = ?", activityID)
|
|
||||||
if period = strings.ToUpper(strings.TrimSpace(period)); period != "" {
|
|
||||||
query = query.Where("period_type = ?", period)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(statDate) != "" {
|
|
||||||
query = query.Where("period_key = ?", strings.TrimSpace(statDate))
|
|
||||||
}
|
|
||||||
if status = strings.ToUpper(strings.TrimSpace(status)); status != "" {
|
|
||||||
query = query.Where("delivery_status = ?", status)
|
|
||||||
}
|
|
||||||
var total int64
|
|
||||||
if err := query.Count(&total).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var rows []model.YumiGiftChallengeSettlement
|
|
||||||
if err := query.Order("create_time DESC, rank_no ASC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
records := make([]SettlementRecordView, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
var items []model.YumiGiftChallengeDeliveryItem
|
|
||||||
if err := s.db.WithContext(ctx).Where("owner_type = ? AND owner_id = ?", OwnerSettlement, row.ID).
|
|
||||||
Order("sort_order ASC, id ASC").Find(&items).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
itemViews := make([]DeliveryItemView, 0, len(items))
|
|
||||||
for _, item := range items {
|
|
||||||
itemViews = append(itemViews, DeliveryItemView{
|
|
||||||
ID: item.ID, OwnerType: item.OwnerType, OwnerID: item.OwnerID, RewardConfigID: item.RewardConfigID,
|
|
||||||
ResourceGroupID: item.ResourceGroupID, RewardType: item.RewardType, DetailType: item.DetailType,
|
|
||||||
Content: item.Content, Quantity: item.Quantity, SortOrder: item.SortOrder, Remark: item.Remark,
|
|
||||||
DeliveryStatus: item.DeliveryStatus, RetryCount: item.RetryCount,
|
|
||||||
FailureReason: item.FailureReason, DeliverTime: millis(item.DeliverTime),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
view := SettlementRecordView{
|
|
||||||
ID: row.ID, ActivityID: row.ActivityID, PeriodType: row.PeriodType, PeriodKey: row.PeriodKey,
|
|
||||||
UserID: row.UserID, Rank: row.RankNo, Score: row.Score, RankRewardID: row.RankRewardID,
|
|
||||||
ResourceGroupID: row.ResourceGroupID, BusinessNo: row.BusinessNo, DeliveryStatus: row.DeliveryStatus,
|
|
||||||
RetryCount: row.RetryCount, FailureReason: row.FailureReason, CreateTime: row.CreateTime.UnixMilli(),
|
|
||||||
DeliverTime: millis(row.DeliverTime), DeliveryItems: itemViews,
|
|
||||||
}
|
|
||||||
if row.StatDate != nil {
|
|
||||||
view.StatDate = *row.StatDate
|
|
||||||
}
|
|
||||||
records = append(records, view)
|
|
||||||
}
|
|
||||||
return map[string]any{"records": records, "total": total, "current": page, "size": size}, nil
|
|
||||||
}
|
|
||||||
@ -1,241 +0,0 @@
|
|||||||
package yumigiftchallenge
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
"chatapp3-golang/internal/utils"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Enter 在活动时区创建当天任务快照,并幂等完成进入页面任务。
|
|
||||||
func (s *Service) Enter(ctx context.Context, user AuthUser, req EnterRequest) (*UserStateResponse, error) {
|
|
||||||
origin, err := s.requireYumiOrigin(user.SysOrigin)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if user.UserID <= 0 {
|
|
||||||
return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "authenticated user is required")
|
|
||||||
}
|
|
||||||
activity, err := s.selectActivity(ctx, origin, req.ActivityID.Int64(), true)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if activityStatus(*activity, time.Now()) != "ONGOING" {
|
|
||||||
return nil, NewAppError(http.StatusConflict, "activity_not_ongoing", "activity is not ongoing")
|
|
||||||
}
|
|
||||||
location, err := resolveLocation(activity.Timezone)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
statDate := dateKey(time.Now(), location)
|
|
||||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
var header model.YumiGiftChallengePeriodSettlement
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where(
|
|
||||||
"activity_id = ? AND period_type = ? AND period_key = ?", activity.ID, PeriodDaily, statDate,
|
|
||||||
).First(&header).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if header.Status != StatusNotStarted {
|
|
||||||
return NewAppError(http.StatusConflict, "daily_period_closed", "daily task period is already frozen")
|
|
||||||
}
|
|
||||||
if err := s.ensureTaskSnapshotsTx(tx, *activity, statDate, user.UserID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
return tx.Model(&model.YumiGiftChallengeUserTaskDaily{}).
|
|
||||||
Where("activity_id = ? AND stat_date = ? AND user_id = ? AND task_type = ? AND completed_time IS NULL",
|
|
||||||
activity.ID, statDate, user.UserID, TaskEnterPage).
|
|
||||||
Updates(map[string]any{"progress_value": model.Decimal24_2("1.00"), "completed_time": now, "update_time": now}).Error
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s.buildUserState(ctx, *activity, statDate, user.UserID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) ensureTaskSnapshotsTx(tx *gorm.DB, activity model.YumiGiftChallengeActivity, statDate string, userID int64) error {
|
|
||||||
var configs []model.YumiGiftChallengeTaskConfig
|
|
||||||
if err := tx.Where("activity_id = ? AND enabled = ?", activity.ID, true).
|
|
||||||
Order("sort_order ASC, id ASC").Find(&configs).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(configs) != 3 {
|
|
||||||
return NewAppError(http.StatusConflict, "invalid_activity_tasks", "enabled activity must contain exactly three tasks")
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
for _, config := range configs {
|
|
||||||
id, err := utils.NextID()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
row := model.YumiGiftChallengeUserTaskDaily{
|
|
||||||
ID: id, ActivityID: activity.ID, StatDate: statDate, UserID: userID,
|
|
||||||
TaskConfigID: config.ID, TaskCode: config.TaskCode, TaskType: config.TaskType,
|
|
||||||
TaskTitle: config.TaskTitle, SortOrder: config.SortOrder, TargetValue: config.TargetValue,
|
|
||||||
ResourceGroupID: config.ResourceGroupID,
|
|
||||||
BusinessNo: fmt.Sprintf("YUMI_GIFT_CHALLENGE:TASK:%d:%s:%d:%s", activity.ID, statDate, userID, config.TaskCode),
|
|
||||||
DeliveryStatus: DeliveryNotClaimed, CreateTime: now, UpdateTime: now,
|
|
||||||
}
|
|
||||||
// 活动、统计日、用户和 taskCode 唯一键同时承接页面重进和送礼并发,只有第一份任务配置成为当天快照。
|
|
||||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustLocation(value string) *time.Location {
|
|
||||||
location, err := time.LoadLocation(strings.TrimSpace(value))
|
|
||||||
if err != nil {
|
|
||||||
return time.UTC
|
|
||||||
}
|
|
||||||
return location
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClaimTask 先在本地事务抢占任务 parent 并物化 delivery item,再逐项调用外部发奖。
|
|
||||||
func (s *Service) ClaimTask(ctx context.Context, user AuthUser, taskCode string, req ClaimRequest) (*UserStateResponse, error) {
|
|
||||||
origin, err := s.requireYumiOrigin(user.SysOrigin)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
taskCode = strings.TrimSpace(taskCode)
|
|
||||||
if !taskCodePattern.MatchString(taskCode) {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_task_code", "invalid taskCode")
|
|
||||||
}
|
|
||||||
requestID := strings.TrimSpace(req.RequestID)
|
|
||||||
if requestID == "" || len(requestID) > 128 {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_request_id", "requestId is required and max length is 128")
|
|
||||||
}
|
|
||||||
activity, err := s.selectActivity(ctx, origin, req.ActivityID.Int64(), true)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if activityStatus(*activity, time.Now()) != "ONGOING" {
|
|
||||||
return nil, NewAppError(http.StatusConflict, "activity_not_ongoing", "activity is not ongoing")
|
|
||||||
}
|
|
||||||
location, err := resolveLocation(activity.Timezone)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
statDate := dateKey(time.Now(), location)
|
|
||||||
var ownerID int64
|
|
||||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := s.ensureTaskSnapshotsTx(tx, *activity, statDate, user.UserID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var task model.YumiGiftChallengeUserTaskDaily
|
|
||||||
queryErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
||||||
Where("activity_id = ? AND stat_date = ? AND user_id = ? AND task_code = ?", activity.ID, statDate, user.UserID, taskCode).
|
|
||||||
First(&task).Error
|
|
||||||
if errors.Is(queryErr, gorm.ErrRecordNotFound) {
|
|
||||||
return NewAppError(http.StatusNotFound, "task_not_found", "task not found")
|
|
||||||
}
|
|
||||||
if queryErr != nil {
|
|
||||||
return queryErr
|
|
||||||
}
|
|
||||||
ownerID = task.ID
|
|
||||||
if task.CompletedTime == nil || task.ProgressValue.Compare(task.TargetValue) < 0 {
|
|
||||||
return NewAppError(http.StatusConflict, "task_not_completed", "task is not completed")
|
|
||||||
}
|
|
||||||
switch task.DeliveryStatus {
|
|
||||||
case DeliverySuccess:
|
|
||||||
return nil
|
|
||||||
case DeliveryUnknown:
|
|
||||||
return NewAppError(http.StatusConflict, "delivery_unknown", "reward result is unknown; contact support for reconciliation")
|
|
||||||
case DeliveryProcessing, DeliveryPending:
|
|
||||||
// 已经抢占的领取由同一 owner/item 幂等边界继续处理,重复 requestId 不会创建第二份奖励。
|
|
||||||
case DeliveryNotClaimed:
|
|
||||||
now := time.Now()
|
|
||||||
if err := tx.Model(&model.YumiGiftChallengeUserTaskDaily{}).Where("id = ?", task.ID).
|
|
||||||
Updates(map[string]any{"delivery_status": DeliveryPending, "claim_request_id": requestID, "claimed_time": now, "update_time": now}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return NewAppError(http.StatusConflict, "invalid_delivery_status", "task reward state is invalid")
|
|
||||||
}
|
|
||||||
return s.createDeliveryItemsTx(tx, OwnerTask, task.ID, activity.ID, user.UserID, task.ResourceGroupID)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if ownerID > 0 {
|
|
||||||
if err := s.deliverOwnerItems(ctx, OwnerTask, ownerID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s.buildUserState(ctx, *activity, statDate, user.UserID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) buildUserState(ctx context.Context, activity model.YumiGiftChallengeActivity, statDate string, userID int64) (*UserStateResponse, error) {
|
|
||||||
var total model.YumiGiftChallengeUserScore
|
|
||||||
if err := s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", activity.ID, userID).First(&total).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var daily model.YumiGiftChallengeUserDailyScore
|
|
||||||
if err := s.db.WithContext(ctx).Where("activity_id = ? AND stat_date = ? AND user_id = ?", activity.ID, statDate, userID).First(&daily).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
overallRank, err := s.currentUserRank(ctx, activity.ID, nil, userID, total.Score, total.ScoreReachedTime)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
dailyRank, err := s.currentUserRank(ctx, activity.ID, &statDate, userID, daily.Score, daily.ScoreReachedTime)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var tasks []model.YumiGiftChallengeUserTaskDaily
|
|
||||||
if err := s.db.WithContext(ctx).Where("activity_id = ? AND stat_date = ? AND user_id = ?", activity.ID, statDate, userID).
|
|
||||||
Order("sort_order ASC, id ASC").Find(&tasks).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
snapshots, err := s.loadRewardSnapshots(ctx, activity.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
views := make([]UserTaskView, 0, len(tasks))
|
|
||||||
for _, row := range tasks {
|
|
||||||
items := []RewardItemView{}
|
|
||||||
if row.ResourceGroupID != nil {
|
|
||||||
items = rewardItemViews(snapshots[*row.ResourceGroupID])
|
|
||||||
}
|
|
||||||
views = append(views, UserTaskView{
|
|
||||||
ID: row.ID, TaskConfigID: row.TaskConfigID, StatDate: row.StatDate,
|
|
||||||
TaskCode: row.TaskCode, TaskType: row.TaskType, TaskTitle: row.TaskTitle, SortOrder: row.SortOrder,
|
|
||||||
TargetValue: row.TargetValue, ProgressValue: row.ProgressValue,
|
|
||||||
Completed: row.CompletedTime != nil, CompletedTime: millis(row.CompletedTime), ResourceGroupID: row.ResourceGroupID,
|
|
||||||
DeliveryStatus: row.DeliveryStatus, ClaimedTime: millis(row.ClaimedTime), DeliverTime: millis(row.DeliverTime), RewardItems: items,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return &UserStateResponse{
|
|
||||||
ActivityID: activity.ID, StatDate: statDate,
|
|
||||||
TotalScore: total.Score, DailyScore: daily.Score, OverallRank: overallRank, DailyRank: dailyRank, Tasks: views,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) currentUserRank(ctx context.Context, activityID int64, statDate *string, userID int64, score model.Decimal24_2, reached time.Time) (*int, error) {
|
|
||||||
if !score.Positive() || reached.IsZero() {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
var count int64
|
|
||||||
query := s.db.WithContext(ctx)
|
|
||||||
if statDate == nil {
|
|
||||||
query = query.Model(&model.YumiGiftChallengeUserScore{}).Where("activity_id = ?", activityID)
|
|
||||||
} else {
|
|
||||||
query = query.Model(&model.YumiGiftChallengeUserDailyScore{}).Where("activity_id = ? AND stat_date = ?", activityID, *statDate)
|
|
||||||
}
|
|
||||||
err := query.Where("score > ? OR (score = ? AND score_reached_time < ?) OR (score = ? AND score_reached_time = ? AND user_id < ?)",
|
|
||||||
score, score, reached, score, reached, userID).Count(&count).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
rank := int(count) + 1
|
|
||||||
return &rank, nil
|
|
||||||
}
|
|
||||||
@ -1,448 +0,0 @@
|
|||||||
package yumigiftchallenge
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"chatapp3-golang/internal/common"
|
|
||||||
"chatapp3-golang/internal/config"
|
|
||||||
"chatapp3-golang/internal/integration"
|
|
||||||
"chatapp3-golang/internal/model"
|
|
||||||
|
|
||||||
rmq "github.com/apache/rocketmq-clients/golang/v5"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
PeriodDaily = "DAILY"
|
|
||||||
PeriodOverall = "OVERALL"
|
|
||||||
|
|
||||||
TaskEnterPage = "ENTER_PAGE"
|
|
||||||
TaskSendGiftGold = "SEND_GIFT_GOLD"
|
|
||||||
|
|
||||||
StatusNotStarted = "NOT_STARTED"
|
|
||||||
StatusProcessing = "PROCESSING"
|
|
||||||
StatusCompleted = "COMPLETED"
|
|
||||||
StatusReconciliationRequired = "RECONCILIATION_REQUIRED"
|
|
||||||
|
|
||||||
DeliveryNotClaimed = "NOT_CLAIMED"
|
|
||||||
DeliveryPending = "PENDING"
|
|
||||||
DeliveryProcessing = "PROCESSING"
|
|
||||||
DeliverySuccess = "SUCCESS"
|
|
||||||
DeliveryUnknown = "UNKNOWN"
|
|
||||||
|
|
||||||
OwnerTask = "TASK"
|
|
||||||
OwnerSettlement = "SETTLEMENT"
|
|
||||||
|
|
||||||
// Yumi 客户端的业务来源仍是 Java SysOriginPlatformEnum.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()
|
|
||||||
}
|
|
||||||
@ -2,7 +2,6 @@ CREATE TABLE IF NOT EXISTS `first_recharge_reward_config` (
|
|||||||
`id` bigint NOT NULL COMMENT '主键ID',
|
`id` bigint NOT NULL COMMENT '主键ID',
|
||||||
`sys_origin` varchar(32) NOT NULL COMMENT '来源系统',
|
`sys_origin` varchar(32) NOT NULL COMMENT '来源系统',
|
||||||
`enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用',
|
`enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用',
|
||||||
`min_app_version` varchar(32) NOT NULL DEFAULT '1.5.0' COMMENT '最低生效App版本',
|
|
||||||
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
|
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
|
||||||
`update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
|
`update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
@ -15,7 +14,6 @@ CREATE TABLE IF NOT EXISTS `first_recharge_reward_level` (
|
|||||||
`config_id` bigint NOT NULL COMMENT '首冲奖励主配置ID',
|
`config_id` bigint NOT NULL COMMENT '首冲奖励主配置ID',
|
||||||
`level` int NOT NULL COMMENT '档位',
|
`level` int NOT NULL COMMENT '档位',
|
||||||
`recharge_amount_cents` bigint NOT NULL DEFAULT '0' COMMENT '充值金额门槛,单位为分',
|
`recharge_amount_cents` bigint NOT NULL DEFAULT '0' COMMENT '充值金额门槛,单位为分',
|
||||||
`google_product_id` varchar(128) NOT NULL DEFAULT '' COMMENT 'Google商品ID',
|
|
||||||
`reward_group_id` bigint NOT NULL DEFAULT '0' COMMENT '奖励资源组ID',
|
`reward_group_id` bigint NOT NULL DEFAULT '0' COMMENT '奖励资源组ID',
|
||||||
`reward_group_name` varchar(255) NOT NULL DEFAULT '' COMMENT '奖励资源组名称快照',
|
`reward_group_name` varchar(255) NOT NULL DEFAULT '' COMMENT '奖励资源组名称快照',
|
||||||
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用',
|
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用',
|
||||||
@ -24,7 +22,6 @@ CREATE TABLE IF NOT EXISTS `first_recharge_reward_level` (
|
|||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `uk_first_recharge_reward_level` (`config_id`, `level`),
|
UNIQUE KEY `uk_first_recharge_reward_level` (`config_id`, `level`),
|
||||||
UNIQUE KEY `uk_first_recharge_reward_amount` (`config_id`, `recharge_amount_cents`),
|
UNIQUE KEY `uk_first_recharge_reward_amount` (`config_id`, `recharge_amount_cents`),
|
||||||
KEY `idx_first_recharge_reward_level_google_product` (`google_product_id`),
|
|
||||||
KEY `idx_first_recharge_reward_level_config` (`config_id`)
|
KEY `idx_first_recharge_reward_level_config` (`config_id`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励充值档位';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励充值档位';
|
||||||
|
|
||||||
@ -45,7 +42,6 @@ CREATE TABLE IF NOT EXISTS `first_recharge_reward_grant_record` (
|
|||||||
`level` int NOT NULL COMMENT '命中档位',
|
`level` int NOT NULL COMMENT '命中档位',
|
||||||
`pay_platform` varchar(64) NOT NULL DEFAULT '' COMMENT '支付平台:GOOGLE/MIFA_PAY/PAYER_MAX等',
|
`pay_platform` varchar(64) NOT NULL DEFAULT '' COMMENT '支付平台:GOOGLE/MIFA_PAY/PAYER_MAX等',
|
||||||
`payment_method` varchar(32) NOT NULL DEFAULT '' COMMENT '充值方式:GOOGLE/THIRD_PARTY',
|
`payment_method` varchar(32) NOT NULL DEFAULT '' COMMENT '充值方式:GOOGLE/THIRD_PARTY',
|
||||||
`google_product_id` varchar(128) NOT NULL DEFAULT '' COMMENT 'Google商品ID',
|
|
||||||
`source_order_id` varchar(128) NOT NULL DEFAULT '' COMMENT '来源订单ID',
|
`source_order_id` varchar(128) NOT NULL DEFAULT '' COMMENT '来源订单ID',
|
||||||
`reward_group_id` bigint NOT NULL DEFAULT '0' COMMENT '奖励资源组ID',
|
`reward_group_id` bigint NOT NULL DEFAULT '0' COMMENT '奖励资源组ID',
|
||||||
`reward_group_name` varchar(255) NOT NULL DEFAULT '' COMMENT '奖励资源组名称快照',
|
`reward_group_name` varchar(255) NOT NULL DEFAULT '' COMMENT '奖励资源组名称快照',
|
||||||
@ -62,6 +58,5 @@ CREATE TABLE IF NOT EXISTS `first_recharge_reward_grant_record` (
|
|||||||
UNIQUE KEY `uk_first_recharge_reward_event` (`event_id`),
|
UNIQUE KEY `uk_first_recharge_reward_event` (`event_id`),
|
||||||
UNIQUE KEY `uk_first_recharge_reward_user` (`sys_origin`, `user_id`),
|
UNIQUE KEY `uk_first_recharge_reward_user` (`sys_origin`, `user_id`),
|
||||||
KEY `idx_first_recharge_reward_status` (`sys_origin`, `user_id`, `status`),
|
KEY `idx_first_recharge_reward_status` (`sys_origin`, `user_id`, `status`),
|
||||||
KEY `idx_first_recharge_reward_grant_google_product` (`google_product_id`),
|
|
||||||
KEY `idx_first_recharge_reward_order` (`source_order_id`)
|
KEY `idx_first_recharge_reward_order` (`source_order_id`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励发放记录';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励发放记录';
|
||||||
|
|||||||
@ -1,71 +0,0 @@
|
|||||||
SET @current_schema := DATABASE();
|
|
||||||
|
|
||||||
SET @add_first_recharge_reward_min_app_version_sql := IF(
|
|
||||||
NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = @current_schema
|
|
||||||
AND table_name = 'first_recharge_reward_config'
|
|
||||||
AND column_name = 'min_app_version'
|
|
||||||
),
|
|
||||||
'ALTER TABLE `first_recharge_reward_config` ADD COLUMN `min_app_version` varchar(32) NOT NULL DEFAULT ''1.5.0'' COMMENT ''最低生效App版本'' AFTER `enabled`',
|
|
||||||
'SELECT 1'
|
|
||||||
);
|
|
||||||
PREPARE add_first_recharge_reward_min_app_version_stmt FROM @add_first_recharge_reward_min_app_version_sql;
|
|
||||||
EXECUTE add_first_recharge_reward_min_app_version_stmt;
|
|
||||||
DEALLOCATE PREPARE add_first_recharge_reward_min_app_version_stmt;
|
|
||||||
|
|
||||||
SET @add_first_recharge_reward_level_google_product_sql := IF(
|
|
||||||
NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = @current_schema
|
|
||||||
AND table_name = 'first_recharge_reward_level'
|
|
||||||
AND column_name = 'google_product_id'
|
|
||||||
),
|
|
||||||
'ALTER TABLE `first_recharge_reward_level` ADD COLUMN `google_product_id` varchar(128) NOT NULL DEFAULT '''' COMMENT ''Google商品ID'' AFTER `recharge_amount_cents`',
|
|
||||||
'SELECT 1'
|
|
||||||
);
|
|
||||||
PREPARE add_first_recharge_reward_level_google_product_stmt FROM @add_first_recharge_reward_level_google_product_sql;
|
|
||||||
EXECUTE add_first_recharge_reward_level_google_product_stmt;
|
|
||||||
DEALLOCATE PREPARE add_first_recharge_reward_level_google_product_stmt;
|
|
||||||
|
|
||||||
SET @add_first_recharge_reward_record_google_product_sql := IF(
|
|
||||||
NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = @current_schema
|
|
||||||
AND table_name = 'first_recharge_reward_grant_record'
|
|
||||||
AND column_name = 'google_product_id'
|
|
||||||
),
|
|
||||||
'ALTER TABLE `first_recharge_reward_grant_record` ADD COLUMN `google_product_id` varchar(128) NOT NULL DEFAULT '''' COMMENT ''Google商品ID'' AFTER `payment_method`',
|
|
||||||
'SELECT 1'
|
|
||||||
);
|
|
||||||
PREPARE add_first_recharge_reward_record_google_product_stmt FROM @add_first_recharge_reward_record_google_product_sql;
|
|
||||||
EXECUTE add_first_recharge_reward_record_google_product_stmt;
|
|
||||||
DEALLOCATE PREPARE add_first_recharge_reward_record_google_product_stmt;
|
|
||||||
|
|
||||||
SET @add_first_recharge_reward_level_google_product_idx_sql := IF(
|
|
||||||
NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.statistics
|
|
||||||
WHERE table_schema = @current_schema
|
|
||||||
AND table_name = 'first_recharge_reward_level'
|
|
||||||
AND index_name = 'idx_first_recharge_reward_level_google_product'
|
|
||||||
),
|
|
||||||
'ALTER TABLE `first_recharge_reward_level` ADD INDEX `idx_first_recharge_reward_level_google_product` (`google_product_id`)',
|
|
||||||
'SELECT 1'
|
|
||||||
);
|
|
||||||
PREPARE add_first_recharge_reward_level_google_product_idx_stmt FROM @add_first_recharge_reward_level_google_product_idx_sql;
|
|
||||||
EXECUTE add_first_recharge_reward_level_google_product_idx_stmt;
|
|
||||||
DEALLOCATE PREPARE add_first_recharge_reward_level_google_product_idx_stmt;
|
|
||||||
|
|
||||||
SET @add_first_recharge_reward_record_google_product_idx_sql := IF(
|
|
||||||
NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.statistics
|
|
||||||
WHERE table_schema = @current_schema
|
|
||||||
AND table_name = 'first_recharge_reward_grant_record'
|
|
||||||
AND index_name = 'idx_first_recharge_reward_grant_google_product'
|
|
||||||
),
|
|
||||||
'ALTER TABLE `first_recharge_reward_grant_record` ADD INDEX `idx_first_recharge_reward_grant_google_product` (`google_product_id`)',
|
|
||||||
'SELECT 1'
|
|
||||||
);
|
|
||||||
PREPARE add_first_recharge_reward_record_google_product_idx_stmt FROM @add_first_recharge_reward_record_google_product_idx_sql;
|
|
||||||
EXECUTE add_first_recharge_reward_record_google_product_idx_stmt;
|
|
||||||
DEALLOCATE PREPARE add_first_recharge_reward_record_google_product_idx_stmt;
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
-- Limit LIKEI app game-list rows to the Turkey region.
|
|
||||||
-- Production Turkey Region observed in sys_region_config: 2046066409959649281.
|
|
||||||
-- Re-check sys_region_config before production execution if region data has been rebuilt.
|
|
||||||
-- After direct SQL execution, clear Redis GAME_LIST* cache or wait for its 1-hour TTL.
|
|
||||||
|
|
||||||
START TRANSACTION;
|
|
||||||
|
|
||||||
SET @sys_origin := 'LIKEI';
|
|
||||||
SET @turkey_region_id := '2046066409959649281';
|
|
||||||
|
|
||||||
UPDATE sys_game_list_config
|
|
||||||
SET regions = @turkey_region_id,
|
|
||||||
update_time = NOW(3)
|
|
||||||
WHERE sys_origin = @sys_origin
|
|
||||||
AND is_showcase = 1;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS hotgame_provider_config (
|
|
||||||
id BIGINT NOT NULL PRIMARY KEY,
|
|
||||||
sys_origin VARCHAR(32) NOT NULL,
|
|
||||||
profile VARCHAR(32) NOT NULL DEFAULT 'PROD',
|
|
||||||
active TINYINT(1) NOT NULL DEFAULT 1,
|
|
||||||
app_key VARCHAR(255) NOT NULL DEFAULT '',
|
|
||||||
callback_base_url VARCHAR(1024) NOT NULL DEFAULT '',
|
|
||||||
test_uid VARCHAR(64) DEFAULT NULL,
|
|
||||||
test_token VARCHAR(1024) DEFAULT NULL,
|
|
||||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE KEY uk_hotgame_provider_sys_origin_profile (sys_origin, profile),
|
|
||||||
KEY idx_hotgame_provider_active (sys_origin, active, update_time)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS hotgame_game_catalog (
|
|
||||||
id BIGINT NOT NULL PRIMARY KEY,
|
|
||||||
sys_origin VARCHAR(32) NOT NULL,
|
|
||||||
profile VARCHAR(32) NOT NULL DEFAULT 'PROD',
|
|
||||||
internal_game_id VARCHAR(64) NOT NULL,
|
|
||||||
vendor_game_id VARCHAR(64) NOT NULL,
|
|
||||||
name VARCHAR(128) NOT NULL,
|
|
||||||
cover VARCHAR(1024) DEFAULT NULL,
|
|
||||||
preview_url VARCHAR(1024) DEFAULT NULL,
|
|
||||||
download_url VARCHAR(1024) DEFAULT NULL,
|
|
||||||
package_version VARCHAR(32) DEFAULT NULL,
|
|
||||||
raw_json LONGTEXT DEFAULT NULL,
|
|
||||||
status VARCHAR(32) NOT NULL DEFAULT 'ENABLED',
|
|
||||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE KEY uk_hotgame_catalog_sys_profile_vendor_game (sys_origin, profile, vendor_game_id),
|
|
||||||
KEY idx_hotgame_catalog_profile_internal_game (sys_origin, profile, internal_game_id, status)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
|
|
||||||
INSERT INTO hotgame_provider_config (
|
|
||||||
id, sys_origin, profile, active, app_key, callback_base_url, create_time, update_time
|
|
||||||
) VALUES
|
|
||||||
(202606100520001, 'LIKEI', 'PROD', 1, 'laluprodappkey', 'https://api.global-interaction.com/', NOW(), NOW()),
|
|
||||||
(202606100520002, 'LIKEI', 'TEST', 0, 'lalutestappkey', 'https://api-test.global-interaction.com/', NOW(), NOW())
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
app_key = IF(TRIM(app_key) = '', VALUES(app_key), app_key),
|
|
||||||
callback_base_url = IF(TRIM(callback_base_url) = '', VALUES(callback_base_url), callback_base_url),
|
|
||||||
update_time = NOW();
|
|
||||||
|
|
||||||
INSERT INTO hotgame_game_catalog (
|
|
||||||
id, sys_origin, profile, internal_game_id, vendor_game_id, name, cover,
|
|
||||||
preview_url, download_url, package_version, raw_json, status, create_time, update_time
|
|
||||||
) VALUES
|
|
||||||
(202606100520101, 'LIKEI', 'TEST', 'hg_1', '1', '幸运77(Lucky77)', '',
|
|
||||||
'https://hk.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}',
|
|
||||||
'https://hk.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}',
|
|
||||||
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
|
||||||
(202606100520102, 'LIKEI', 'TEST', 'hg_13', '13', '海盗捕鱼(PirateFishing)', '',
|
|
||||||
'https://hk.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
|
||||||
'https://hk.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
|
||||||
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
|
||||||
(202606100520103, 'LIKEI', 'TEST', 'hg_19', '19', 'FortuneSlot (宝石solt)', '',
|
|
||||||
'https://hk.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
|
||||||
'https://hk.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
|
||||||
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
|
||||||
(202606100520104, 'LIKEI', 'TEST', 'hg_15', '15', '美味摩天轮(Delicious)', '',
|
|
||||||
'https://hk.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
|
||||||
'https://hk.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
|
||||||
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
|
||||||
(202606100520105, 'LIKEI', 'TEST', 'hg_20', '20', '水果派对(FruitParty)', '',
|
|
||||||
'https://hk.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
|
||||||
'https://hk.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
|
||||||
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
|
||||||
(202606100520106, 'LIKEI', 'TEST', 'hg_16', '16', '太空摩天轮(GreedyStar)', '',
|
|
||||||
'https://hk.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
|
||||||
'https://hk.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
|
||||||
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
|
||||||
(202606100520201, 'LIKEI', 'PROD', 'hg_1', '1', '幸运77(Lucky77)', '',
|
|
||||||
'https://cdn-res.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}',
|
|
||||||
'https://cdn-res.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}',
|
|
||||||
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
|
||||||
(202606100520202, 'LIKEI', 'PROD', 'hg_13', '13', '海盗捕鱼(PirateFishing)', '',
|
|
||||||
'https://cdn-res.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
|
||||||
'https://cdn-res.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
|
||||||
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
|
||||||
(202606100520203, 'LIKEI', 'PROD', 'hg_19', '19', 'FortuneSlot (宝石solt)', '',
|
|
||||||
'https://cdn-res.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
|
||||||
'https://cdn-res.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
|
||||||
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
|
||||||
(202606100520204, 'LIKEI', 'PROD', 'hg_15', '15', '美味摩天轮(Delicious)', '',
|
|
||||||
'https://cdn-res.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
|
||||||
'https://cdn-res.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
|
||||||
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
|
||||||
(202606100520205, 'LIKEI', 'PROD', 'hg_20', '20', '水果派对(FruitParty)', '',
|
|
||||||
'https://cdn-res.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
|
||||||
'https://cdn-res.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
|
||||||
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
|
||||||
(202606100520206, 'LIKEI', 'PROD', 'hg_16', '16', '太空摩天轮(GreedyStar)', '',
|
|
||||||
'https://cdn-res.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
|
||||||
'https://cdn-res.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
|
||||||
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW())
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
internal_game_id = VALUES(internal_game_id),
|
|
||||||
name = VALUES(name),
|
|
||||||
cover = VALUES(cover),
|
|
||||||
preview_url = VALUES(preview_url),
|
|
||||||
download_url = VALUES(download_url),
|
|
||||||
package_version = VALUES(package_version),
|
|
||||||
raw_json = VALUES(raw_json),
|
|
||||||
status = VALUES(status),
|
|
||||||
update_time = NOW();
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
SET @current_schema := DATABASE();
|
|
||||||
|
|
||||||
SET @modify_sys_game_list_cover_sql := IF(
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = @current_schema
|
|
||||||
AND table_name = 'sys_game_list_config'
|
|
||||||
AND column_name = 'cover'
|
|
||||||
),
|
|
||||||
'ALTER TABLE `sys_game_list_config` MODIFY COLUMN `cover` VARCHAR(1024) DEFAULT NULL COMMENT ''封面图''',
|
|
||||||
'SELECT 1'
|
|
||||||
);
|
|
||||||
|
|
||||||
PREPARE modify_sys_game_list_cover_stmt FROM @modify_sys_game_list_cover_sql;
|
|
||||||
EXECUTE modify_sys_game_list_cover_stmt;
|
|
||||||
DEALLOCATE PREPARE modify_sys_game_list_cover_stmt;
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
SET @table_schema = DATABASE();
|
|
||||||
|
|
||||||
SET @ddl = IF(
|
|
||||||
EXISTS(
|
|
||||||
SELECT 1
|
|
||||||
FROM information_schema.COLUMNS
|
|
||||||
WHERE TABLE_SCHEMA = @table_schema
|
|
||||||
AND TABLE_NAME = 'team_manager_base_info'
|
|
||||||
AND COLUMN_NAME = 'can_add_bd_leader'
|
|
||||||
),
|
|
||||||
'SELECT 1',
|
|
||||||
"ALTER TABLE `team_manager_base_info` ADD COLUMN `can_add_bd_leader` tinyint(1) NOT NULL DEFAULT 1 COMMENT '经理中心是否允许添加BD Leader' AFTER `region_id`"
|
|
||||||
);
|
|
||||||
PREPARE stmt FROM @ddl;
|
|
||||||
EXECUTE stmt;
|
|
||||||
DEALLOCATE PREPARE stmt;
|
|
||||||
|
|
||||||
SET @ddl = IF(
|
|
||||||
EXISTS(
|
|
||||||
SELECT 1
|
|
||||||
FROM information_schema.COLUMNS
|
|
||||||
WHERE TABLE_SCHEMA = @table_schema
|
|
||||||
AND TABLE_NAME = 'team_manager_base_info'
|
|
||||||
AND COLUMN_NAME = 'can_gift_props'
|
|
||||||
),
|
|
||||||
'SELECT 1',
|
|
||||||
"ALTER TABLE `team_manager_base_info` ADD COLUMN `can_gift_props` tinyint(1) NOT NULL DEFAULT 1 COMMENT '经理中心是否允许赠送道具' AFTER `can_add_bd_leader`"
|
|
||||||
);
|
|
||||||
PREPARE stmt FROM @ddl;
|
|
||||||
EXECUTE stmt;
|
|
||||||
DEALLOCATE PREPARE stmt;
|
|
||||||
|
|
||||||
SET @ddl = IF(
|
|
||||||
EXISTS(
|
|
||||||
SELECT 1
|
|
||||||
FROM information_schema.COLUMNS
|
|
||||||
WHERE TABLE_SCHEMA = @table_schema
|
|
||||||
AND TABLE_NAME = 'team_manager_base_info'
|
|
||||||
AND COLUMN_NAME = 'can_ban_user'
|
|
||||||
),
|
|
||||||
'SELECT 1',
|
|
||||||
"ALTER TABLE `team_manager_base_info` ADD COLUMN `can_ban_user` tinyint(1) NOT NULL DEFAULT 1 COMMENT '经理中心是否允许封禁用户' AFTER `can_gift_props`"
|
|
||||||
);
|
|
||||||
PREPARE stmt FROM @ddl;
|
|
||||||
EXECUTE stmt;
|
|
||||||
DEALLOCATE PREPARE stmt;
|
|
||||||
|
|
||||||
SET @ddl = IF(
|
|
||||||
EXISTS(
|
|
||||||
SELECT 1
|
|
||||||
FROM information_schema.COLUMNS
|
|
||||||
WHERE TABLE_SCHEMA = @table_schema
|
|
||||||
AND TABLE_NAME = 'team_manager_base_info'
|
|
||||||
AND COLUMN_NAME = 'can_unban_user'
|
|
||||||
),
|
|
||||||
'SELECT 1',
|
|
||||||
"ALTER TABLE `team_manager_base_info` ADD COLUMN `can_unban_user` tinyint(1) NOT NULL DEFAULT 1 COMMENT '经理中心是否允许解封用户' AFTER `can_ban_user`"
|
|
||||||
);
|
|
||||||
PREPARE stmt FROM @ddl;
|
|
||||||
EXECUTE stmt;
|
|
||||||
DEALLOCATE PREPARE stmt;
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
SET @table_schema = DATABASE();
|
|
||||||
|
|
||||||
SET @ddl = IF(
|
|
||||||
EXISTS(
|
|
||||||
SELECT 1
|
|
||||||
FROM information_schema.COLUMNS
|
|
||||||
WHERE TABLE_SCHEMA = @table_schema
|
|
||||||
AND TABLE_NAME = 'team_manager_base_info'
|
|
||||||
AND COLUMN_NAME = 'can_change_country'
|
|
||||||
),
|
|
||||||
'SELECT 1',
|
|
||||||
"ALTER TABLE `team_manager_base_info` ADD COLUMN `can_change_country` tinyint(1) NOT NULL DEFAULT 1 COMMENT '经理中心是否允许修改用户国家' AFTER `can_unban_user`"
|
|
||||||
);
|
|
||||||
PREPARE stmt FROM @ddl;
|
|
||||||
EXECUTE stmt;
|
|
||||||
DEALLOCATE PREPARE stmt;
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user