Compare commits

...

20 Commits

Author SHA1 Message Date
zhx
ad4a755998 增加用户的manager身份 2026-05-25 23:41:50 +08:00
zhx
245412ae95 修复排行榜 2026-05-25 22:43:54 +08:00
zhx
5000076365 修改礼物tab接口 2026-05-25 21:03:37 +08:00
zhx
9040b4b161 增加礼物tab接口 2026-05-25 20:58:15 +08:00
zhx
ccd1967242 fix: backfill wallet outbox initdb migrations 2026-05-25 19:52:13 +08:00
zhx
1b3cf31a7d 完善接口 2026-05-25 19:36:27 +08:00
zhx
0bfe1cf57d 数据大屏 2026-05-25 17:50:20 +08:00
zhx
597b1cf587 fix: prioritize zeeone app access token session 2026-05-25 16:33:18 +08:00
zhx
2df7d8b59e 新增消息大屏 2026-05-25 15:45:57 +08:00
zhx
0824c58d4f 排行榜文档 2026-05-23 15:19:12 +08:00
zhx
17d777610c 兼容百顺游戏 2026-05-23 15:12:17 +08:00
zhx
d1a7facf10 百顺游戏 2026-05-23 12:07:02 +08:00
zhx
33ddebd8de 完善游戏平台以及红包的相关架构 2026-05-23 11:21:36 +08:00
zhx
a5a53b390f 小游 2026-05-22 13:34:54 +08:00
zhx
4af7844d1b zeeone 接口 2026-05-22 11:57:04 +08:00
zhx
8dc76890a6 重复订单 2026-05-21 18:30:55 +08:00
zhx
5e0e02cd2a 灵仙游戏 2026-05-21 15:31:00 +08:00
zhx
9d4dd79a86 快捷创建账号 2026-05-21 11:52:06 +08:00
zhx
21dd835c75 灵仙和小游游戏 2026-05-20 18:37:44 +08:00
zhx
61f4e31b6a 游戏部署 2026-05-20 18:07:33 +08:00
261 changed files with 36576 additions and 3371 deletions

View File

@ -21,14 +21,31 @@
- `notice-service` 负责从各 owner service 的 outbox/事件源读取事实并投递私有通知、腾讯云 IM 单聊、后续 push/inbox钱包、活动和房间不能直接耦合私有通知投递实现。
- `user-service` 负责用户主数据,不应该成为房间高频流程的同步中心。
- `cron-service` 只负责后台调度、任务运行记录和通过内部 gRPC 触发 owner service 批处理;不能直接拥有房间、账务、用户或活动业务状态。
- `statistics-service` 只负责消费 owner service 已发布到 RocketMQ 的业务事实并写本服务聚合表;它不是业务事实 owner不能直连 room/wallet/user/game 等 owner 数据库补数或查询明细。
- `server/admin` 是后台管理服务独立 module负责管理员、RBAC、菜单、审计和后台 HTTP API访问 app 后端只能通过 `hyapp.local/api` 生成的 protobuf client 或明确的集成接口。
- `api` 是独立 Go modulemodule path 为 `hyapp.local/api`,只放 protobuf contract 和生成代码,不放服务实现或业务工具包。
- `wallet_outbox``room_outbox``user_outbox``game_outbox` 只能由各自 owner service 直接读取并发布到 RocketMQ其他服务不能配置 owner 数据库 DSN 或直连 owner 数据库扫描 outbox。
- 下游服务消费 owner outbox 必须使用自己的 RocketMQ consumer group 和本服务幂等表;消费位点属于消息系统和本地幂等事实,不能共享 owner service 的 outbox 状态字段。
- 数据大屏查询入口在 `server/admin`,由 admin 调 `statistics-service` 内部查询 API`gateway-service` 是 App HTTP 入口,除非有明确 App 产品需求,否则不要把后台统计大屏暴露到 gateway。
## Statistics Rules
- 硬性规则:统计查询不能查全表,不能回查 owner service 明细表,不能在 HTTP handler 临时扫业务流水;所有统计展示一律查询 `statistics-service` 的聚合表。
- `statistics-service` 的事实输入只来自 RocketMQ`WalletRechargeRecorded``GameOrderSettled``RoomGiftSent``RoomUserJoined``UserRegistered` 等事件必须先由 owner service outbox 持久化,再由 owner service 发布。
- 充值口径以 `WalletRechargeRecorded` 为准;渠道分桶只能使用事件里的 `recharge_type`,当前缺渠道时归入币商充值,不能从支付表或用户资料反推。
- 游戏口径以 `GameOrderSettled` 为准;`debit/bet` 计投注流水,`credit/payout` 计返奖,`refund/reverse` 计退款,利润按 `投注 - 返奖 - 退款` 计算。
- 活跃口径以事实事件去重用户:进房来自 `RoomUserJoined`,游戏活跃来自 `GameOrderSettled`,统计服务用本地唯一键收敛日活,不能依赖 Redis presence。
- 留存口径以 `UserRegistered` cohort 和 `stat_user_day_activity` 活跃表计算;注册、活跃、充值、游戏和礼物事件都按 UTC `stat_day` 聚合。
- ARPU 直接由充值聚合除以活跃用户ARPPU/UP 值直接由充值聚合除以付费用户。产品若要调整 UP 值定义,必须先改明确字段名和边界测试。
- 国家维度只能来自事件字段,例如用户注册 region、房间 `visible_region_id`、充值目标区域或游戏事件区域不能用服务所在地、IP 或本地时区推断。
## Persistence Rules
- `room-service` 生产路径使用 MySQL repository 和 Redis directory。
- `memory` repository/directory 只用于单元测试或明确的本地隔离测试。
- MySQL 是房间恢复的持久来源:`rooms``room_snapshots``room_command_log``room_outbox`
- `wallet-service``wallet_outbox` 是账务事实的持久来源;它负责补偿发布到 RocketMQ下游不得把钱包库当作事件查询库。
- `statistics-service` 的 MySQL 聚合表是数据大屏唯一查询来源:`statistics_event_consumption` 记录幂等事实,`stat_app_day_country``stat_game_day_country``stat_user_day_activity``stat_user_registration``stat_recharge_day_payers``stat_lucky_gift_day_payers` 保存统计读模型。
- Redis 只保存当前路由/lease 等热状态,不能作为唯一恢复来源。
- Redis lease 更新必须保持原子性,不能改回非原子的 GET + SET 接管逻辑。
@ -65,6 +82,7 @@
- `13007`: cron gRPC
- `13008`: game gRPC
- `13009`: notice gRPC
- `13010`: statistics query HTTP
- `23306`: local Docker MySQL host port
- `13379`: local Docker Redis host port

View File

@ -1,10 +1,13 @@
COMPOSE_SERVICES := mysql redis gateway-service room-service wallet-service user-service activity-service cron-service game-service notice-service
SERVICE_ALIASES := gs gateway rs room ws wallet us user as activity cs cron game games ns notice mysql redis gateway-service room-service wallet-service user-service activity-service cron-service game-service notice-service
COMPOSE_SERVICES := mysql redis rocketmq-namesrv rocketmq-broker gateway-service room-service wallet-service user-service activity-service cron-service game-service notice-service statistics-service
GO_RUN_SERVICES := gateway-service room-service wallet-service user-service activity-service cron-service game-service notice-service statistics-service
SERVICE_ALIASES := gs gateway rs room ws wallet us user as activity cs cron game games ns notice stats statistics mq namesrv broker mysql redis rocketmq-namesrv rocketmq-broker gateway-service room-service wallet-service user-service activity-service cron-service game-service notice-service statistics-service
SERVICE_TOKEN := $(firstword $(filter $(SERVICE_ALIASES),$(MAKECMDGOALS)))
SERVICE_ID := $(strip $(or $(SERVICE),$(if $(filter gs gateway,$(SERVICE_TOKEN)),gateway-service,$(if $(filter rs room,$(SERVICE_TOKEN)),room-service,$(if $(filter ws wallet,$(SERVICE_TOKEN)),wallet-service,$(if $(filter us user,$(SERVICE_TOKEN)),user-service,$(if $(filter as activity,$(SERVICE_TOKEN)),activity-service,$(if $(filter cs cron,$(SERVICE_TOKEN)),cron-service,$(if $(filter game games,$(SERVICE_TOKEN)),game-service,$(if $(filter ns notice,$(SERVICE_TOKEN)),notice-service,$(SERVICE_TOKEN)))))))))))
SERVICE_ID := $(strip $(or $(SERVICE),$(if $(filter gs gateway,$(SERVICE_TOKEN)),gateway-service,$(if $(filter rs room,$(SERVICE_TOKEN)),room-service,$(if $(filter ws wallet,$(SERVICE_TOKEN)),wallet-service,$(if $(filter us user,$(SERVICE_TOKEN)),user-service,$(if $(filter as activity,$(SERVICE_TOKEN)),activity-service,$(if $(filter cs cron,$(SERVICE_TOKEN)),cron-service,$(if $(filter game games,$(SERVICE_TOKEN)),game-service,$(if $(filter ns notice,$(SERVICE_TOKEN)),notice-service,$(if $(filter stats statistics,$(SERVICE_TOKEN)),statistics-service,$(if $(filter namesrv,$(SERVICE_TOKEN)),rocketmq-namesrv,$(if $(filter mq broker,$(SERVICE_TOKEN)),rocketmq-broker,$(SERVICE_TOKEN))))))))))))))
ADMIN_CONFIG ?= configs/config.yaml
DEV_RUN_SERVICES ?= all
DEV_RUN_SERVICE_LIST := $(strip $(if $(filter mysql redis,$(SERVICE_ID)),,$(if $(SERVICE_ID),$(SERVICE_ID),$(DEV_RUN_SERVICES))))
.PHONY: proto vet test build admin admin-up admin-deps admin-bootstrap admin-test admin-build up db-init rebuild restart stop logs ps addr down up-service check-service resolve-compose-conflicts $(SERVICE_ALIASES)
.PHONY: proto vet test build run admin admin-up admin-deps admin-bootstrap admin-test admin-build up db-init rebuild restart stop logs ps addr down up-service check-service resolve-compose-conflicts $(SERVICE_ALIASES)
# `proto` 负责把独立 api module 内的 .proto 重新生成成 Go 代码。
proto:
@ -40,6 +43,16 @@ build:
go build ./...
go build ./server/admin/...
# `run` 拉起 MySQL/Redis 后在本机用 go run 启动所有 Go 服务,并在源码或配置变更时热重启。
# 可用 `make run rs`、`make run SERVICE=room-service` 或 `make run DEV_RUN_SERVICES=user-service,gateway-service` 只跑子集。
run:
./scripts/resolve-compose-container-conflicts.sh
docker compose up -d mysql redis
./scripts/apply-local-mysql-initdb.sh
docker compose stop $(GO_RUN_SERVICES) >/dev/null 2>&1 || true
./scripts/print-compose-addresses.sh
TZ=UTC go run ./cmd/dev-run -services "$(DEV_RUN_SERVICE_LIST)"
# `admin` 在本机直接启动后台管理后端;依赖默认从 server/admin/configs/config.yaml 读取。
# 启动前自动杀掉占用 13100 端口的旧进程,避免 bind: address already in use。
ADMIN_PORT ?= 13100
@ -78,7 +91,7 @@ up:
@if [ -z "$(SERVICE_ID)" ]; then \
docker compose up -d mysql redis; \
./scripts/apply-local-mysql-initdb.sh; \
docker compose up --build -d gateway-service room-service wallet-service user-service activity-service cron-service game-service notice-service; \
docker compose up --build -d gateway-service room-service wallet-service user-service activity-service cron-service game-service notice-service statistics-service; \
elif [ "$(SERVICE_ID)" = "mysql" ] || [ "$(SERVICE_ID)" = "redis" ]; then \
docker compose up -d $(SERVICE_ID); \
else \

View File

@ -15,6 +15,7 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
- `activity-service` 当前承接每日任务、活动/系统消息基础能力和房间事件 consumer可通过 room outbox direct gRPC 或 RocketMQ `hyapp_room_outbox` 独立 consumer group 消费房间事实。
- `cron-service` 当前负责后台调度、任务运行记录,以及通过内部 gRPC 触发 owner service 批处理;它不直接拥有房间、账务、用户或活动业务状态。
- `notice-service` 当前消费 `wallet_outbox` 的余额变更事件和 `room_outbox` 的踢人事件,异步投递腾讯云 IM 单聊私有通知;房间群消息和群成员控制仍由 `room-service` 的房间 IM bridge 处理MQ 模式下通过独立 consumer group 解耦。
- `statistics-service` 当前消费 user/wallet/room/game outbox 的 RocketMQ 事实,写入本服务 MySQL 聚合表,并通过内部 HTTP 查询 API 支撑后台数据大屏;它不拥有注册、账务、房间或游戏业务事实。
- 腾讯云 IM 承担客户端长连接、群消息、公屏、单聊、离线/漫游和全球接入能力;本仓库不再部署 `im-service`
- 多 App 共用同一套后端服务,入口由 `gateway-service` 通过包名或 `app_code` 解析租户;所有业务库表按 `app_code` 隔离,设计细节见 `docs/多App租户架构.md`
@ -23,7 +24,10 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
- 客户端命令面:`gateway-service` 暴露 HTTP JSON。
- 客户端实时面:腾讯云 IM SDK客户端用 gateway 签发的 UserSig 登录。
- 服务内部调用gRPC + protobuf契约在独立 Go module `api`,业务代码通过 `hyapp.local/api/proto/...` 引用生成包。
- 房间外事件protobuf outbox 先落 MySQL生产可由 RocketMQ fanout 到 activity、notice、IM bridge 和后续 push/inbox消费端按 `event_id` 幂等。
- 房间外事件protobuf/JSON outbox 先落 MySQL生产可由 RocketMQ fanout 到 activity、notice、IM bridge、statistics 和后续 push/inbox消费端按 `event_id` 幂等。
- outbox 读取权只属于 owner service。`room-service` 读取 `room_outbox``wallet-service` 读取 `wallet_outbox``user-service` 读取 `user_outbox``game-service` 读取 `game_outbox` 并发布 RocketMQ其他服务不能直连 owner 数据库扫描 outbox。
- 下游消费 owner outbox 必须使用本服务独立 RocketMQ consumer group并在本服务库内用幂等表或唯一键记录已消费 `event_id`;消费位点不能写回或复用 owner service 的 outbox 状态。
- 后台统计查询面:`server/admin``statistics-service` 内部 HTTP API`gateway-service` 是 App 入口,除非有明确 App 产品需求,否则不暴露后台数据大屏统计。
## Time Model
@ -49,9 +53,40 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
| `cron-service` | `13007` | gRPC |
| `game-service` | `13008` | gRPC |
| `notice-service` | `13009` | gRPC |
| `statistics-service` | `13010` | internal query HTTP |
| `statistics-service` | `13110` | health HTTP |
| MySQL | `23306 -> 3306` | local Docker only |
| Redis | `13379 -> 6379` | local Docker only |
## Statistics
统计系统的硬边界是“不查全表,不回查 owner 明细库”。数据大屏只能查询 `statistics-service` 的聚合表;任何新指标都必须先定义事件事实、聚合表字段和 UTC 时间范围,再接入查询接口。
事实来源:
- `user-service`: `UserRegistered`,用于新增用户和留存 cohort。
- `wallet-service`: `WalletRechargeRecorded`,用于总充值、首充、付费用户、币商/MifaPay/Google 渠道分桶、ARPU、ARPPU 和 UP 值。
- `room-service`: `RoomUserJoined` 用于进房活跃,`RoomGiftSent` 用于礼物消耗、幸运礼物流水和幸运礼物付费用户;国家维度来自事件中的 `visible_region_id`
- `game-service`: `GameOrderSettled``debit/bet` 计投注流水,`credit/payout` 计返奖,`refund/reverse` 计退款;游戏利润为 `投注 - 返奖 - 退款`
聚合读模型:
- `statistics_event_consumption`: 本服务消费幂等表,按 `app_code + source + event_id` 去重。
- `stat_app_day_country`: App/国家/UTC 日总览,保存新增、活跃、付费、充值渠道、礼物、幸运礼物、游戏总览。
- `stat_user_day_activity`: 用户 UTC 日活跃事实,用于 DAU 和留存 join。
- `stat_user_registration`: 注册 cohort用于次留、7 留和 30 留。
- `stat_recharge_day_payers`: 充值付费用户去重用于付费人数、ARPPU 和 UP 值。
- `stat_lucky_gift_day_payers`: 幸运礼物付费用户去重。
- `stat_game_day_country` / `stat_game_day_players`: 游戏维度流水、返奖、退款、玩家数和排行。
查询契约:
```text
GET /internal/v1/statistics/overview?app_code=lalu&start_ms=<UTC ms>&end_ms=<UTC ms>&country_id=<optional>
```
`start_ms`/`end_ms` 统一使用 `[start_ms, end_ms)`。返回值只来自聚合表;`ARPU = recharge_usd_minor / active_users``ARPPU = recharge_usd_minor / paid_users`,当前 `up_value_usd_minor` 与 ARPPU 同口径。产品若要改变 UP 值定义,必须先改字段名、契约和边界测试。
## Run Locally
Use Docker for local and test environments:
@ -132,7 +167,15 @@ docs/flutter对接/Flutter App 通知与钱包余额对接.md
## RocketMQ
本地默认不依赖 RocketMQ`room-service` 使用 `outbox_worker.publish_mode=direct` 直连腾讯云 IM publisher 和 activity gRPC publisher。线上推荐启用腾讯云 RocketMQ并把 `room-service` 配成 `publish_mode=mq`,让 room outbox 只发布到消息总线,下游服务各自 consumer group 消费。
本地默认不依赖 RocketMQ`room-service` 可使用 `outbox_worker.publish_mode=direct` 直连腾讯云 IM publisher 和 activity gRPC publisher。线上推荐启用腾讯云 RocketMQ并把 owner outbox 统一发布到消息总线,下游服务各自 consumer group 消费。
规则:
- `room_outbox` 只由 `room-service` 读取和发布;`wallet_outbox` 只由 `wallet-service` 读取和发布;`user_outbox` 只由 `user-service` 读取和发布;`game_outbox` 只由 `game-service` 读取和发布。
- `statistics-service``activity-service``notice-service``cron-service` 等非 owner service 不能配置 owner 数据库 DSN 或扫描其他服务 outbox 表。
- 每个业务消费者使用独立 consumer group。即使消费同一个 topic不同业务也不能共用 group否则会互相抢消息。
- 每个消费者在自己的库内维护幂等事实,例如按 `app_code + event_id` 或业务唯一键建唯一约束RocketMQ 的消费位点只解决投递进度,不替代业务幂等。
- owner service 的 MySQL outbox 仍是可靠事实源。RocketMQ 发布失败由 owner outbox worker 补偿重试;下游处理失败由各自 consumer group 重投和本地幂等表收敛。
当前 topic 和 consumer group
@ -141,9 +184,18 @@ docs/flutter对接/Flutter App 通知与钱包余额对接.md
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-activity-room-outbox` | activity-service 消费 `RoomGiftSent``RoomTreasureCountdownStarted` 等房间事实 |
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-notice-room-outbox` | notice-service 消费 `RoomUserKicked` 等私有通知事实 |
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-room-im-bridge` | room-service IM bridge 消费房间群系统消息和群成员控制事实 |
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-user-mictime-room-outbox` | user-service 消费 `RoomMicChanged` 并更新用户麦时读模型 |
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-statistics-room-outbox` | statistics-service 消费 `RoomGiftSent``RoomUserJoined` 并更新聚合表 |
| `hyapp_user_outbox` | `hyapp-user-outbox-producer` | `hyapp-statistics-user-outbox` | statistics-service 消费 `UserRegistered` 并更新注册 cohort |
| `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-user-invite-wallet-outbox` | user-service 消费 `WalletRechargeRecorded` 并更新邀请有效充值 |
| `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-activity-first-recharge-wallet-outbox` | activity-service 消费 `WalletRechargeRecorded` 并处理首充奖励 |
| `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-activity-red-packet-wallet-outbox` | activity-service 消费 `WalletRedPacketCreated` 并生成区域飘屏 outbox |
| `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-notice-wallet-outbox` | notice-service 消费余额、账务等私有通知事实 |
| `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-statistics-wallet-outbox` | statistics-service 消费 `WalletRechargeRecorded` 并更新充值聚合 |
| `hyapp_game_outbox` | `hyapp-game-outbox-producer` | `hyapp-statistics-game-outbox` | statistics-service 消费 `GameOrderSettled` 并更新游戏聚合 |
| `hyapp_room_treasure_open` | `hyapp-room-treasure-open-producer` | `hyapp-room-treasure-open` | 宝箱倒计时到点唤醒 room-service 开箱命令 |
RocketMQ 不拥有房间状态。`room_outbox` 仍是 MySQL 内的可靠事实源MQ 发布失败时 outbox worker 退避重试,到达重试上限后进入死信。宝箱延迟消息只负责唤醒,到点后 room-service 会重新校验 `box_id`、等级、`open_at_ms`、状态和 UTC 重置边界。
RocketMQ 不拥有业务状态。`room_outbox``wallet_outbox``user_outbox``game_outbox` 仍是各 owner service MySQL 内的可靠事实源MQ 发布失败时 outbox worker 退避重试,到达重试上限后进入死信。宝箱延迟消息只负责唤醒,到点后 room-service 会重新校验 `box_id`、等级、`open_at_ms`、状态和 UTC 重置边界。
## Storage Model
@ -156,6 +208,7 @@ MySQL 是本地和线上运行的持久化底座:
- `cron-service`: `hyapp_cron`,保存后台任务定义、运行记录和调度锁;实际业务批处理仍由各 owner service 执行。
- `game-service`: `hyapp_game`,保存游戏平台、房间游戏会话和游戏交易映射。
- `notice-service`: `hyapp_notice`,保存外部通知投递位点、重试锁和死信状态;业务事实仍留在 owner service outbox。
- `statistics-service`: `hyapp_statistics`,保存事件消费幂等和数据大屏聚合读模型;禁止把 owner service 明细表当作统计查询来源。
- `hyapp_admin`: 后台管理后端独立库,只保存后台账号、权限和审计;不要把后台审计耦合进 App 业务库。
`room-service` uses MySQL as the room recovery durable source:
@ -205,6 +258,7 @@ services/activity-service/ activity foundation and room event consumer boundary
services/cron-service/ background scheduling and owner-service batch trigger
services/game-service/ game session and wallet integration
services/notice-service/ async user notice delivery workers
services/statistics-service/ data dashboard event consumers and aggregate query API
```
## Current Limits
@ -212,7 +266,8 @@ services/notice-service/ async user notice delivery workers
- 腾讯云 IM 回调入口已经落在 gateway线上仍必须在腾讯云 IM 控制台启用 `Group.CallbackBeforeApplyJoinGroup``Group.CallbackBeforeSendMsg`,并配置公网 callback URL 与鉴权 token。
- 前端对同一次用户写动作的 HTTP 重试必须复用同一个 `command_id``X-Request-ID` 不需要也不应该由前端传入。
- `wallet-service` App 运行路径使用 MySQL 扣费事务;本地需要先通过 compose initdb 建好 `hyapp_wallet`
- `notice-service` 本地默认不启用腾讯云 IM worker线上启用钱包通知、房间通知 MySQL worker 或 RocketMQ room outbox consumer 时,必须同时启用 `tencent_im.enabled=true`
- `notice-service` 本地默认不启用腾讯云 IM worker线上启用钱包通知或房间通知 RocketMQ consumer 时,必须同时启用 `tencent_im.enabled=true`,且不能直连 wallet/room 数据库扫描 outbox
- `activity-service` 已承接每日任务、消息/活动基础能力和 room outbox consumer本地可走 direct gRPC线上推荐走 RocketMQ `hyapp_room_outbox`
- `room-service` App 已启动 `room_outbox` 补偿 worker`direct` 模式表示直连 publisher 完成,`mq` 模式表示事件已发布到 RocketMQ下游成功由各服务消费位点保证。
- `statistics-service` 只保证从已发布 MQ 事件生成聚合表;如果历史库没有对应 outbox 事实,例如早期用户没有 `UserRegistered`、本地没有 `game_outbox`,统计结果会按当前事实为 0不允许临时扫业务表补数。
- JWT、腾讯云 IM secret 等本地配置只能用开发值,线上必须走安全配置源。

File diff suppressed because it is too large Load Diff

View File

@ -531,6 +531,122 @@ message ListRegistrationRewardClaimsResponse {
int64 total = 2;
}
// FirstRechargeRewardTier max_coin_amount=0
message FirstRechargeRewardTier {
int64 tier_id = 1;
string tier_code = 2;
string tier_name = 3;
int64 min_coin_amount = 4;
int64 max_coin_amount = 5;
int64 resource_group_id = 6;
string status = 7;
int32 sort_order = 8;
int64 created_at_ms = 9;
int64 updated_at_ms = 10;
}
// FirstRechargeRewardConfig App activity-service
message FirstRechargeRewardConfig {
string app_code = 1;
bool enabled = 2;
repeated FirstRechargeRewardTier tiers = 3;
int64 updated_by_admin_id = 4;
int64 created_at_ms = 5;
int64 updated_at_ms = 6;
}
// FirstRechargeRewardClaim user_id
message FirstRechargeRewardClaim {
string claim_id = 1;
string event_id = 2;
string transaction_id = 3;
string command_id = 4;
int64 user_id = 5;
int64 tier_id = 6;
string tier_code = 7;
int64 resource_group_id = 8;
int64 recharge_coin_amount = 9;
int64 recharge_sequence = 10;
string recharge_type = 11;
string status = 12;
string wallet_command_id = 13;
string wallet_grant_id = 14;
string failure_reason = 15;
int64 recharged_at_ms = 16;
int64 granted_at_ms = 17;
int64 created_at_ms = 18;
int64 updated_at_ms = 19;
}
// FirstRechargeRewardStatus App
message FirstRechargeRewardStatus {
bool enabled = 1;
repeated FirstRechargeRewardTier tiers = 2;
bool rewarded = 3;
FirstRechargeRewardClaim claim = 4;
int64 server_time_ms = 5;
}
message GetFirstRechargeRewardStatusRequest {
RequestMeta meta = 1;
int64 user_id = 2;
}
message GetFirstRechargeRewardStatusResponse {
FirstRechargeRewardStatus status = 1;
}
// ConsumeFirstRechargeRewardRequest wallet recharge_sequence=1
message ConsumeFirstRechargeRewardRequest {
RequestMeta meta = 1;
string event_id = 2;
string transaction_id = 3;
string command_id = 4;
int64 user_id = 5;
int64 recharge_coin_amount = 6;
int64 recharge_sequence = 7;
string recharge_type = 8;
int64 occurred_at_ms = 9;
}
message ConsumeFirstRechargeRewardResponse {
FirstRechargeRewardClaim claim = 1;
bool consumed = 2;
string reason = 3;
}
message GetFirstRechargeRewardConfigRequest {
RequestMeta meta = 1;
}
message GetFirstRechargeRewardConfigResponse {
FirstRechargeRewardConfig config = 1;
}
message UpdateFirstRechargeRewardConfigRequest {
RequestMeta meta = 1;
bool enabled = 2;
repeated FirstRechargeRewardTier tiers = 3;
int64 operator_admin_id = 4;
}
message UpdateFirstRechargeRewardConfigResponse {
FirstRechargeRewardConfig config = 1;
}
message ListFirstRechargeRewardClaimsRequest {
RequestMeta meta = 1;
string status = 2;
int64 user_id = 3;
int32 page = 4;
int32 page_size = 5;
}
message ListFirstRechargeRewardClaimsResponse {
repeated FirstRechargeRewardClaim claims = 1;
int64 total = 2;
}
// SevenDayCheckInReward
message SevenDayCheckInReward {
int32 day_index = 1;
@ -1006,6 +1122,7 @@ message LuckyGiftMeta {
string gift_id = 7;
int64 coin_spent = 8;
int64 paid_at_ms = 9;
string pool_id = 10;
}
message LuckyGiftTier {
@ -1015,6 +1132,7 @@ message LuckyGiftTier {
int64 weight = 4;
bool high_water_only = 5;
bool enabled = 6;
int64 multiplier_ppm = 7;
}
message LuckyGiftConfig {
@ -1056,6 +1174,8 @@ message LuckyGiftConfig {
int64 updated_by_admin_id = 36;
int64 created_at_ms = 37;
int64 updated_at_ms = 38;
string pool_id = 39;
repeated int64 multiplier_ppms = 40;
}
message CheckLuckyGiftRequest {
@ -1063,6 +1183,7 @@ message CheckLuckyGiftRequest {
int64 user_id = 2;
string room_id = 3;
string gift_id = 4;
string pool_id = 5;
}
message CheckLuckyGiftResponse {
@ -1073,6 +1194,7 @@ message CheckLuckyGiftResponse {
int64 rule_version = 5;
int64 target_rtp_ppm = 6;
string experience_pool = 7;
string pool_id = 8;
}
message LuckyGiftDrawResult {
@ -1095,6 +1217,7 @@ message LuckyGiftDrawResult {
bool stage_feedback = 17;
bool high_multiplier = 18;
int64 created_at_ms = 19;
string pool_id = 20;
}
message ExecuteLuckyGiftDrawRequest {
@ -1108,6 +1231,7 @@ message ExecuteLuckyGiftDrawResponse {
message GetLuckyGiftConfigRequest {
RequestMeta meta = 1;
string gift_id = 2;
string pool_id = 3;
}
message GetLuckyGiftConfigResponse {
@ -1124,6 +1248,14 @@ message UpsertLuckyGiftConfigResponse {
LuckyGiftConfig config = 1;
}
message ListLuckyGiftConfigsRequest {
RequestMeta meta = 1;
}
message ListLuckyGiftConfigsResponse {
repeated LuckyGiftConfig configs = 1;
}
message ListLuckyGiftDrawsRequest {
RequestMeta meta = 1;
string gift_id = 2;
@ -1132,6 +1264,7 @@ message ListLuckyGiftDrawsRequest {
string status = 5;
int32 page = 6;
int32 page_size = 7;
string pool_id = 8;
}
message ListLuckyGiftDrawsResponse {
@ -1139,6 +1272,35 @@ message ListLuckyGiftDrawsResponse {
int64 total = 2;
}
message LuckyGiftDrawSummary {
string pool_id = 1;
int64 total_draws = 2;
int64 unique_users = 3;
int64 unique_rooms = 4;
int64 total_spent_coins = 5;
int64 total_reward_coins = 6;
int64 base_reward_coins = 7;
int64 room_atmosphere_reward_coins = 8;
int64 activity_subsidy_coins = 9;
int64 actual_rtp_ppm = 10;
int64 pending_draws = 11;
int64 granted_draws = 12;
int64 failed_draws = 13;
}
message GetLuckyGiftDrawSummaryRequest {
RequestMeta meta = 1;
string gift_id = 2;
int64 user_id = 3;
string room_id = 4;
string status = 5;
string pool_id = 6;
}
message GetLuckyGiftDrawSummaryResponse {
LuckyGiftDrawSummary summary = 1;
}
// ActivityService RPC
service ActivityService {
rpc PingActivity(PingActivityRequest) returns (PingActivityResponse);
@ -1226,6 +1388,19 @@ service AdminRegistrationRewardService {
rpc ListRegistrationRewardClaims(ListRegistrationRewardClaimsRequest) returns (ListRegistrationRewardClaimsResponse);
}
// FirstRechargeRewardService App wallet
service FirstRechargeRewardService {
rpc GetFirstRechargeRewardStatus(GetFirstRechargeRewardStatusRequest) returns (GetFirstRechargeRewardStatusResponse);
rpc ConsumeFirstRechargeReward(ConsumeFirstRechargeRewardRequest) returns (ConsumeFirstRechargeRewardResponse);
}
// AdminFirstRechargeRewardService 访
service AdminFirstRechargeRewardService {
rpc GetFirstRechargeRewardConfig(GetFirstRechargeRewardConfigRequest) returns (GetFirstRechargeRewardConfigResponse);
rpc UpdateFirstRechargeRewardConfig(UpdateFirstRechargeRewardConfigRequest) returns (UpdateFirstRechargeRewardConfigResponse);
rpc ListFirstRechargeRewardClaims(ListFirstRechargeRewardClaimsRequest) returns (ListFirstRechargeRewardClaimsResponse);
}
// SevenDayCheckInService App
service SevenDayCheckInService {
rpc GetSevenDayCheckInStatus(GetSevenDayCheckInStatusRequest) returns (GetSevenDayCheckInStatusResponse);
@ -1257,5 +1432,7 @@ service AdminAchievementService {
service AdminLuckyGiftService {
rpc GetLuckyGiftConfig(GetLuckyGiftConfigRequest) returns (GetLuckyGiftConfigResponse);
rpc UpsertLuckyGiftConfig(UpsertLuckyGiftConfigRequest) returns (UpsertLuckyGiftConfigResponse);
rpc ListLuckyGiftConfigs(ListLuckyGiftConfigsRequest) returns (ListLuckyGiftConfigsResponse);
rpc ListLuckyGiftDraws(ListLuckyGiftDrawsRequest) returns (ListLuckyGiftDrawsResponse);
rpc GetLuckyGiftDrawSummary(GetLuckyGiftDrawSummaryRequest) returns (GetLuckyGiftDrawSummaryResponse);
}

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v5.28.3
// - protoc v7.35.0
// source: proto/activity/v1/activity.proto
package activityv1
@ -2319,6 +2319,334 @@ var AdminRegistrationRewardService_ServiceDesc = grpc.ServiceDesc{
Metadata: "proto/activity/v1/activity.proto",
}
const (
FirstRechargeRewardService_GetFirstRechargeRewardStatus_FullMethodName = "/hyapp.activity.v1.FirstRechargeRewardService/GetFirstRechargeRewardStatus"
FirstRechargeRewardService_ConsumeFirstRechargeReward_FullMethodName = "/hyapp.activity.v1.FirstRechargeRewardService/ConsumeFirstRechargeReward"
)
// FirstRechargeRewardServiceClient is the client API for FirstRechargeRewardService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// FirstRechargeRewardService 拥有 App 首冲奖励查询和 wallet 充值事实消费入口。
type FirstRechargeRewardServiceClient interface {
GetFirstRechargeRewardStatus(ctx context.Context, in *GetFirstRechargeRewardStatusRequest, opts ...grpc.CallOption) (*GetFirstRechargeRewardStatusResponse, error)
ConsumeFirstRechargeReward(ctx context.Context, in *ConsumeFirstRechargeRewardRequest, opts ...grpc.CallOption) (*ConsumeFirstRechargeRewardResponse, error)
}
type firstRechargeRewardServiceClient struct {
cc grpc.ClientConnInterface
}
func NewFirstRechargeRewardServiceClient(cc grpc.ClientConnInterface) FirstRechargeRewardServiceClient {
return &firstRechargeRewardServiceClient{cc}
}
func (c *firstRechargeRewardServiceClient) GetFirstRechargeRewardStatus(ctx context.Context, in *GetFirstRechargeRewardStatusRequest, opts ...grpc.CallOption) (*GetFirstRechargeRewardStatusResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetFirstRechargeRewardStatusResponse)
err := c.cc.Invoke(ctx, FirstRechargeRewardService_GetFirstRechargeRewardStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *firstRechargeRewardServiceClient) ConsumeFirstRechargeReward(ctx context.Context, in *ConsumeFirstRechargeRewardRequest, opts ...grpc.CallOption) (*ConsumeFirstRechargeRewardResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ConsumeFirstRechargeRewardResponse)
err := c.cc.Invoke(ctx, FirstRechargeRewardService_ConsumeFirstRechargeReward_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// FirstRechargeRewardServiceServer is the server API for FirstRechargeRewardService service.
// All implementations must embed UnimplementedFirstRechargeRewardServiceServer
// for forward compatibility.
//
// FirstRechargeRewardService 拥有 App 首冲奖励查询和 wallet 充值事实消费入口。
type FirstRechargeRewardServiceServer interface {
GetFirstRechargeRewardStatus(context.Context, *GetFirstRechargeRewardStatusRequest) (*GetFirstRechargeRewardStatusResponse, error)
ConsumeFirstRechargeReward(context.Context, *ConsumeFirstRechargeRewardRequest) (*ConsumeFirstRechargeRewardResponse, error)
mustEmbedUnimplementedFirstRechargeRewardServiceServer()
}
// UnimplementedFirstRechargeRewardServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedFirstRechargeRewardServiceServer struct{}
func (UnimplementedFirstRechargeRewardServiceServer) GetFirstRechargeRewardStatus(context.Context, *GetFirstRechargeRewardStatusRequest) (*GetFirstRechargeRewardStatusResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetFirstRechargeRewardStatus not implemented")
}
func (UnimplementedFirstRechargeRewardServiceServer) ConsumeFirstRechargeReward(context.Context, *ConsumeFirstRechargeRewardRequest) (*ConsumeFirstRechargeRewardResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ConsumeFirstRechargeReward not implemented")
}
func (UnimplementedFirstRechargeRewardServiceServer) mustEmbedUnimplementedFirstRechargeRewardServiceServer() {
}
func (UnimplementedFirstRechargeRewardServiceServer) testEmbeddedByValue() {}
// UnsafeFirstRechargeRewardServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to FirstRechargeRewardServiceServer will
// result in compilation errors.
type UnsafeFirstRechargeRewardServiceServer interface {
mustEmbedUnimplementedFirstRechargeRewardServiceServer()
}
func RegisterFirstRechargeRewardServiceServer(s grpc.ServiceRegistrar, srv FirstRechargeRewardServiceServer) {
// If the following call panics, it indicates UnimplementedFirstRechargeRewardServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&FirstRechargeRewardService_ServiceDesc, srv)
}
func _FirstRechargeRewardService_GetFirstRechargeRewardStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetFirstRechargeRewardStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(FirstRechargeRewardServiceServer).GetFirstRechargeRewardStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: FirstRechargeRewardService_GetFirstRechargeRewardStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(FirstRechargeRewardServiceServer).GetFirstRechargeRewardStatus(ctx, req.(*GetFirstRechargeRewardStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
func _FirstRechargeRewardService_ConsumeFirstRechargeReward_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ConsumeFirstRechargeRewardRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(FirstRechargeRewardServiceServer).ConsumeFirstRechargeReward(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: FirstRechargeRewardService_ConsumeFirstRechargeReward_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(FirstRechargeRewardServiceServer).ConsumeFirstRechargeReward(ctx, req.(*ConsumeFirstRechargeRewardRequest))
}
return interceptor(ctx, in, info, handler)
}
// FirstRechargeRewardService_ServiceDesc is the grpc.ServiceDesc for FirstRechargeRewardService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var FirstRechargeRewardService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.activity.v1.FirstRechargeRewardService",
HandlerType: (*FirstRechargeRewardServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetFirstRechargeRewardStatus",
Handler: _FirstRechargeRewardService_GetFirstRechargeRewardStatus_Handler,
},
{
MethodName: "ConsumeFirstRechargeReward",
Handler: _FirstRechargeRewardService_ConsumeFirstRechargeReward_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
}
const (
AdminFirstRechargeRewardService_GetFirstRechargeRewardConfig_FullMethodName = "/hyapp.activity.v1.AdminFirstRechargeRewardService/GetFirstRechargeRewardConfig"
AdminFirstRechargeRewardService_UpdateFirstRechargeRewardConfig_FullMethodName = "/hyapp.activity.v1.AdminFirstRechargeRewardService/UpdateFirstRechargeRewardConfig"
AdminFirstRechargeRewardService_ListFirstRechargeRewardClaims_FullMethodName = "/hyapp.activity.v1.AdminFirstRechargeRewardService/ListFirstRechargeRewardClaims"
)
// AdminFirstRechargeRewardServiceClient is the client API for AdminFirstRechargeRewardService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// AdminFirstRechargeRewardService 是后台活动管理访问首冲奖励配置和发放记录的唯一入口。
type AdminFirstRechargeRewardServiceClient interface {
GetFirstRechargeRewardConfig(ctx context.Context, in *GetFirstRechargeRewardConfigRequest, opts ...grpc.CallOption) (*GetFirstRechargeRewardConfigResponse, error)
UpdateFirstRechargeRewardConfig(ctx context.Context, in *UpdateFirstRechargeRewardConfigRequest, opts ...grpc.CallOption) (*UpdateFirstRechargeRewardConfigResponse, error)
ListFirstRechargeRewardClaims(ctx context.Context, in *ListFirstRechargeRewardClaimsRequest, opts ...grpc.CallOption) (*ListFirstRechargeRewardClaimsResponse, error)
}
type adminFirstRechargeRewardServiceClient struct {
cc grpc.ClientConnInterface
}
func NewAdminFirstRechargeRewardServiceClient(cc grpc.ClientConnInterface) AdminFirstRechargeRewardServiceClient {
return &adminFirstRechargeRewardServiceClient{cc}
}
func (c *adminFirstRechargeRewardServiceClient) GetFirstRechargeRewardConfig(ctx context.Context, in *GetFirstRechargeRewardConfigRequest, opts ...grpc.CallOption) (*GetFirstRechargeRewardConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetFirstRechargeRewardConfigResponse)
err := c.cc.Invoke(ctx, AdminFirstRechargeRewardService_GetFirstRechargeRewardConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminFirstRechargeRewardServiceClient) UpdateFirstRechargeRewardConfig(ctx context.Context, in *UpdateFirstRechargeRewardConfigRequest, opts ...grpc.CallOption) (*UpdateFirstRechargeRewardConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpdateFirstRechargeRewardConfigResponse)
err := c.cc.Invoke(ctx, AdminFirstRechargeRewardService_UpdateFirstRechargeRewardConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminFirstRechargeRewardServiceClient) ListFirstRechargeRewardClaims(ctx context.Context, in *ListFirstRechargeRewardClaimsRequest, opts ...grpc.CallOption) (*ListFirstRechargeRewardClaimsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListFirstRechargeRewardClaimsResponse)
err := c.cc.Invoke(ctx, AdminFirstRechargeRewardService_ListFirstRechargeRewardClaims_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AdminFirstRechargeRewardServiceServer is the server API for AdminFirstRechargeRewardService service.
// All implementations must embed UnimplementedAdminFirstRechargeRewardServiceServer
// for forward compatibility.
//
// AdminFirstRechargeRewardService 是后台活动管理访问首冲奖励配置和发放记录的唯一入口。
type AdminFirstRechargeRewardServiceServer interface {
GetFirstRechargeRewardConfig(context.Context, *GetFirstRechargeRewardConfigRequest) (*GetFirstRechargeRewardConfigResponse, error)
UpdateFirstRechargeRewardConfig(context.Context, *UpdateFirstRechargeRewardConfigRequest) (*UpdateFirstRechargeRewardConfigResponse, error)
ListFirstRechargeRewardClaims(context.Context, *ListFirstRechargeRewardClaimsRequest) (*ListFirstRechargeRewardClaimsResponse, error)
mustEmbedUnimplementedAdminFirstRechargeRewardServiceServer()
}
// UnimplementedAdminFirstRechargeRewardServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedAdminFirstRechargeRewardServiceServer struct{}
func (UnimplementedAdminFirstRechargeRewardServiceServer) GetFirstRechargeRewardConfig(context.Context, *GetFirstRechargeRewardConfigRequest) (*GetFirstRechargeRewardConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetFirstRechargeRewardConfig not implemented")
}
func (UnimplementedAdminFirstRechargeRewardServiceServer) UpdateFirstRechargeRewardConfig(context.Context, *UpdateFirstRechargeRewardConfigRequest) (*UpdateFirstRechargeRewardConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateFirstRechargeRewardConfig not implemented")
}
func (UnimplementedAdminFirstRechargeRewardServiceServer) ListFirstRechargeRewardClaims(context.Context, *ListFirstRechargeRewardClaimsRequest) (*ListFirstRechargeRewardClaimsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListFirstRechargeRewardClaims not implemented")
}
func (UnimplementedAdminFirstRechargeRewardServiceServer) mustEmbedUnimplementedAdminFirstRechargeRewardServiceServer() {
}
func (UnimplementedAdminFirstRechargeRewardServiceServer) testEmbeddedByValue() {}
// UnsafeAdminFirstRechargeRewardServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to AdminFirstRechargeRewardServiceServer will
// result in compilation errors.
type UnsafeAdminFirstRechargeRewardServiceServer interface {
mustEmbedUnimplementedAdminFirstRechargeRewardServiceServer()
}
func RegisterAdminFirstRechargeRewardServiceServer(s grpc.ServiceRegistrar, srv AdminFirstRechargeRewardServiceServer) {
// If the following call panics, it indicates UnimplementedAdminFirstRechargeRewardServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&AdminFirstRechargeRewardService_ServiceDesc, srv)
}
func _AdminFirstRechargeRewardService_GetFirstRechargeRewardConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetFirstRechargeRewardConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminFirstRechargeRewardServiceServer).GetFirstRechargeRewardConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminFirstRechargeRewardService_GetFirstRechargeRewardConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminFirstRechargeRewardServiceServer).GetFirstRechargeRewardConfig(ctx, req.(*GetFirstRechargeRewardConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminFirstRechargeRewardService_UpdateFirstRechargeRewardConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateFirstRechargeRewardConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminFirstRechargeRewardServiceServer).UpdateFirstRechargeRewardConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminFirstRechargeRewardService_UpdateFirstRechargeRewardConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminFirstRechargeRewardServiceServer).UpdateFirstRechargeRewardConfig(ctx, req.(*UpdateFirstRechargeRewardConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminFirstRechargeRewardService_ListFirstRechargeRewardClaims_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListFirstRechargeRewardClaimsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminFirstRechargeRewardServiceServer).ListFirstRechargeRewardClaims(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminFirstRechargeRewardService_ListFirstRechargeRewardClaims_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminFirstRechargeRewardServiceServer).ListFirstRechargeRewardClaims(ctx, req.(*ListFirstRechargeRewardClaimsRequest))
}
return interceptor(ctx, in, info, handler)
}
// AdminFirstRechargeRewardService_ServiceDesc is the grpc.ServiceDesc for AdminFirstRechargeRewardService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var AdminFirstRechargeRewardService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.activity.v1.AdminFirstRechargeRewardService",
HandlerType: (*AdminFirstRechargeRewardServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetFirstRechargeRewardConfig",
Handler: _AdminFirstRechargeRewardService_GetFirstRechargeRewardConfig_Handler,
},
{
MethodName: "UpdateFirstRechargeRewardConfig",
Handler: _AdminFirstRechargeRewardService_UpdateFirstRechargeRewardConfig_Handler,
},
{
MethodName: "ListFirstRechargeRewardClaims",
Handler: _AdminFirstRechargeRewardService_ListFirstRechargeRewardClaims_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
}
const (
SevenDayCheckInService_GetSevenDayCheckInStatus_FullMethodName = "/hyapp.activity.v1.SevenDayCheckInService/GetSevenDayCheckInStatus"
SevenDayCheckInService_SignSevenDayCheckIn_FullMethodName = "/hyapp.activity.v1.SevenDayCheckInService/SignSevenDayCheckIn"
@ -3014,9 +3342,11 @@ var AdminAchievementService_ServiceDesc = grpc.ServiceDesc{
}
const (
AdminLuckyGiftService_GetLuckyGiftConfig_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/GetLuckyGiftConfig"
AdminLuckyGiftService_UpsertLuckyGiftConfig_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/UpsertLuckyGiftConfig"
AdminLuckyGiftService_ListLuckyGiftDraws_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/ListLuckyGiftDraws"
AdminLuckyGiftService_GetLuckyGiftConfig_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/GetLuckyGiftConfig"
AdminLuckyGiftService_UpsertLuckyGiftConfig_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/UpsertLuckyGiftConfig"
AdminLuckyGiftService_ListLuckyGiftConfigs_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/ListLuckyGiftConfigs"
AdminLuckyGiftService_ListLuckyGiftDraws_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/ListLuckyGiftDraws"
AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/GetLuckyGiftDrawSummary"
)
// AdminLuckyGiftServiceClient is the client API for AdminLuckyGiftService service.
@ -3027,7 +3357,9 @@ const (
type AdminLuckyGiftServiceClient interface {
GetLuckyGiftConfig(ctx context.Context, in *GetLuckyGiftConfigRequest, opts ...grpc.CallOption) (*GetLuckyGiftConfigResponse, error)
UpsertLuckyGiftConfig(ctx context.Context, in *UpsertLuckyGiftConfigRequest, opts ...grpc.CallOption) (*UpsertLuckyGiftConfigResponse, error)
ListLuckyGiftConfigs(ctx context.Context, in *ListLuckyGiftConfigsRequest, opts ...grpc.CallOption) (*ListLuckyGiftConfigsResponse, error)
ListLuckyGiftDraws(ctx context.Context, in *ListLuckyGiftDrawsRequest, opts ...grpc.CallOption) (*ListLuckyGiftDrawsResponse, error)
GetLuckyGiftDrawSummary(ctx context.Context, in *GetLuckyGiftDrawSummaryRequest, opts ...grpc.CallOption) (*GetLuckyGiftDrawSummaryResponse, error)
}
type adminLuckyGiftServiceClient struct {
@ -3058,6 +3390,16 @@ func (c *adminLuckyGiftServiceClient) UpsertLuckyGiftConfig(ctx context.Context,
return out, nil
}
func (c *adminLuckyGiftServiceClient) ListLuckyGiftConfigs(ctx context.Context, in *ListLuckyGiftConfigsRequest, opts ...grpc.CallOption) (*ListLuckyGiftConfigsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListLuckyGiftConfigsResponse)
err := c.cc.Invoke(ctx, AdminLuckyGiftService_ListLuckyGiftConfigs_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminLuckyGiftServiceClient) ListLuckyGiftDraws(ctx context.Context, in *ListLuckyGiftDrawsRequest, opts ...grpc.CallOption) (*ListLuckyGiftDrawsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListLuckyGiftDrawsResponse)
@ -3068,6 +3410,16 @@ func (c *adminLuckyGiftServiceClient) ListLuckyGiftDraws(ctx context.Context, in
return out, nil
}
func (c *adminLuckyGiftServiceClient) GetLuckyGiftDrawSummary(ctx context.Context, in *GetLuckyGiftDrawSummaryRequest, opts ...grpc.CallOption) (*GetLuckyGiftDrawSummaryResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetLuckyGiftDrawSummaryResponse)
err := c.cc.Invoke(ctx, AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AdminLuckyGiftServiceServer is the server API for AdminLuckyGiftService service.
// All implementations must embed UnimplementedAdminLuckyGiftServiceServer
// for forward compatibility.
@ -3076,7 +3428,9 @@ func (c *adminLuckyGiftServiceClient) ListLuckyGiftDraws(ctx context.Context, in
type AdminLuckyGiftServiceServer interface {
GetLuckyGiftConfig(context.Context, *GetLuckyGiftConfigRequest) (*GetLuckyGiftConfigResponse, error)
UpsertLuckyGiftConfig(context.Context, *UpsertLuckyGiftConfigRequest) (*UpsertLuckyGiftConfigResponse, error)
ListLuckyGiftConfigs(context.Context, *ListLuckyGiftConfigsRequest) (*ListLuckyGiftConfigsResponse, error)
ListLuckyGiftDraws(context.Context, *ListLuckyGiftDrawsRequest) (*ListLuckyGiftDrawsResponse, error)
GetLuckyGiftDrawSummary(context.Context, *GetLuckyGiftDrawSummaryRequest) (*GetLuckyGiftDrawSummaryResponse, error)
mustEmbedUnimplementedAdminLuckyGiftServiceServer()
}
@ -3093,9 +3447,15 @@ func (UnimplementedAdminLuckyGiftServiceServer) GetLuckyGiftConfig(context.Conte
func (UnimplementedAdminLuckyGiftServiceServer) UpsertLuckyGiftConfig(context.Context, *UpsertLuckyGiftConfigRequest) (*UpsertLuckyGiftConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpsertLuckyGiftConfig not implemented")
}
func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftConfigs(context.Context, *ListLuckyGiftConfigsRequest) (*ListLuckyGiftConfigsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListLuckyGiftConfigs not implemented")
}
func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftDraws(context.Context, *ListLuckyGiftDrawsRequest) (*ListLuckyGiftDrawsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListLuckyGiftDraws not implemented")
}
func (UnimplementedAdminLuckyGiftServiceServer) GetLuckyGiftDrawSummary(context.Context, *GetLuckyGiftDrawSummaryRequest) (*GetLuckyGiftDrawSummaryResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetLuckyGiftDrawSummary not implemented")
}
func (UnimplementedAdminLuckyGiftServiceServer) mustEmbedUnimplementedAdminLuckyGiftServiceServer() {}
func (UnimplementedAdminLuckyGiftServiceServer) testEmbeddedByValue() {}
@ -3153,6 +3513,24 @@ func _AdminLuckyGiftService_UpsertLuckyGiftConfig_Handler(srv interface{}, ctx c
return interceptor(ctx, in, info, handler)
}
func _AdminLuckyGiftService_ListLuckyGiftConfigs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListLuckyGiftConfigsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminLuckyGiftServiceServer).ListLuckyGiftConfigs(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminLuckyGiftService_ListLuckyGiftConfigs_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminLuckyGiftServiceServer).ListLuckyGiftConfigs(ctx, req.(*ListLuckyGiftConfigsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminLuckyGiftService_ListLuckyGiftDraws_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListLuckyGiftDrawsRequest)
if err := dec(in); err != nil {
@ -3171,6 +3549,24 @@ func _AdminLuckyGiftService_ListLuckyGiftDraws_Handler(srv interface{}, ctx cont
return interceptor(ctx, in, info, handler)
}
func _AdminLuckyGiftService_GetLuckyGiftDrawSummary_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetLuckyGiftDrawSummaryRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminLuckyGiftServiceServer).GetLuckyGiftDrawSummary(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminLuckyGiftServiceServer).GetLuckyGiftDrawSummary(ctx, req.(*GetLuckyGiftDrawSummaryRequest))
}
return interceptor(ctx, in, info, handler)
}
// AdminLuckyGiftService_ServiceDesc is the grpc.ServiceDesc for AdminLuckyGiftService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -3186,10 +3582,18 @@ var AdminLuckyGiftService_ServiceDesc = grpc.ServiceDesc{
MethodName: "UpsertLuckyGiftConfig",
Handler: _AdminLuckyGiftService_UpsertLuckyGiftConfig_Handler,
},
{
MethodName: "ListLuckyGiftConfigs",
Handler: _AdminLuckyGiftService_ListLuckyGiftConfigs_Handler,
},
{
MethodName: "ListLuckyGiftDraws",
Handler: _AdminLuckyGiftService_ListLuckyGiftDraws_Handler,
},
{
MethodName: "GetLuckyGiftDrawSummary",
Handler: _AdminLuckyGiftService_GetLuckyGiftDrawSummary_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc (unknown)
// protoc v7.35.0
// source: proto/events/room/v1/events.proto
package roomeventsv1
@ -280,11 +280,13 @@ func (x *RoomProfileUpdated) GetSeatCount() int32 {
// RoomUserJoined 表达用户业务态进房成功。
type RoomUserJoined struct {
state protoimpl.MessageState `protogen:"open.v1"`
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
Role string `protobuf:"bytes,2,opt,name=role,proto3" json:"role,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
Role string `protobuf:"bytes,2,opt,name=role,proto3" json:"role,omitempty"`
// visible_region_id 是用户进房时所在房间的国家/区域桶,统计服务不能反查 room-service。
VisibleRegionId int64 `protobuf:"varint,3,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RoomUserJoined) Reset() {
@ -331,6 +333,13 @@ func (x *RoomUserJoined) GetRole() string {
return ""
}
func (x *RoomUserJoined) GetVisibleRegionId() int64 {
if x != nil {
return x.VisibleRegionId
}
return 0
}
// RoomUserLeft 表达用户离房成功。
type RoomUserLeft struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -971,6 +980,7 @@ type RoomGiftSent struct {
BillingReceiptId string `protobuf:"bytes,6,opt,name=billing_receipt_id,json=billingReceiptId,proto3" json:"billing_receipt_id,omitempty"`
VisibleRegionId int64 `protobuf:"varint,7,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"`
CommandId string `protobuf:"bytes,8,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
PoolId string `protobuf:"bytes,9,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -1061,6 +1071,13 @@ func (x *RoomGiftSent) GetCommandId() string {
return ""
}
func (x *RoomGiftSent) GetPoolId() string {
if x != nil {
return x.PoolId
}
return ""
}
// RoomHeatChanged 表达热度变化结果。
type RoomHeatChanged struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -1749,10 +1766,11 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
"roomAvatar\x12)\n" +
"\x10room_description\x18\x04 \x01(\tR\x0froomDescription\x12\x1d\n" +
"\n" +
"seat_count\x18\x05 \x01(\x05R\tseatCount\"=\n" +
"seat_count\x18\x05 \x01(\x05R\tseatCount\"i\n" +
"\x0eRoomUserJoined\x12\x17\n" +
"\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x12\n" +
"\x04role\x18\x02 \x01(\tR\x04role\"'\n" +
"\x04role\x18\x02 \x01(\tR\x04role\x12*\n" +
"\x11visible_region_id\x18\x03 \x01(\x03R\x0fvisibleRegionId\"'\n" +
"\fRoomUserLeft\x12\x17\n" +
"\auser_id\x18\x01 \x01(\x03R\x06userId\"H\n" +
"\n" +
@ -1797,7 +1815,7 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\\\n" +
"\x10RoomUserUnbanned\x12\"\n" +
"\ractor_user_id\x18\x01 \x01(\x03R\vactorUserId\x12$\n" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\xaa\x02\n" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\xc3\x02\n" +
"\fRoomGiftSent\x12$\n" +
"\x0esender_user_id\x18\x01 \x01(\x03R\fsenderUserId\x12$\n" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x17\n" +
@ -1809,7 +1827,8 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
"\x12billing_receipt_id\x18\x06 \x01(\tR\x10billingReceiptId\x12*\n" +
"\x11visible_region_id\x18\a \x01(\x03R\x0fvisibleRegionId\x12\x1d\n" +
"\n" +
"command_id\x18\b \x01(\tR\tcommandId\"J\n" +
"command_id\x18\b \x01(\tR\tcommandId\x12\x17\n" +
"\apool_id\x18\t \x01(\tR\x06poolId\"J\n" +
"\x0fRoomHeatChanged\x12\x14\n" +
"\x05delta\x18\x01 \x01(\x03R\x05delta\x12!\n" +
"\fcurrent_heat\x18\x02 \x01(\x03R\vcurrentHeat\"_\n" +

View File

@ -40,6 +40,8 @@ message RoomProfileUpdated {
message RoomUserJoined {
int64 user_id = 1;
string role = 2;
// visible_region_id / room-service
int64 visible_region_id = 3;
}
// RoomUserLeft
@ -127,6 +129,7 @@ message RoomGiftSent {
string billing_receipt_id = 6;
int64 visible_region_id = 7;
string command_id = 8;
string pool_id = 9;
}
// RoomHeatChanged

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.28.3
// protoc v7.35.0
// source: proto/game/v1/game.proto
package gamev1
@ -107,17 +107,23 @@ func (x *RequestMeta) GetAppCode() string {
}
type GamePlatform struct {
state protoimpl.MessageState `protogen:"open.v1"`
AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
PlatformCode string `protobuf:"bytes,2,opt,name=platform_code,json=platformCode,proto3" json:"platform_code,omitempty"`
PlatformName string `protobuf:"bytes,3,opt,name=platform_name,json=platformName,proto3" json:"platform_name,omitempty"`
Status string `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"`
ApiBaseUrl string `protobuf:"bytes,5,opt,name=api_base_url,json=apiBaseUrl,proto3" json:"api_base_url,omitempty"`
SortOrder int32 `protobuf:"varint,6,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"`
CreatedAtMs int64 `protobuf:"varint,7,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
UpdatedAtMs int64 `protobuf:"varint,8,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
PlatformCode string `protobuf:"bytes,2,opt,name=platform_code,json=platformCode,proto3" json:"platform_code,omitempty"`
PlatformName string `protobuf:"bytes,3,opt,name=platform_name,json=platformName,proto3" json:"platform_name,omitempty"`
Status string `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"`
ApiBaseUrl string `protobuf:"bytes,5,opt,name=api_base_url,json=apiBaseUrl,proto3" json:"api_base_url,omitempty"`
SortOrder int32 `protobuf:"varint,6,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"`
CreatedAtMs int64 `protobuf:"varint,7,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
UpdatedAtMs int64 `protobuf:"varint,8,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
AdapterType string `protobuf:"bytes,9,opt,name=adapter_type,json=adapterType,proto3" json:"adapter_type,omitempty"`
// callback_secret is visible to admin platform configuration pages and writable for key rotation.
CallbackSecret string `protobuf:"bytes,10,opt,name=callback_secret,json=callbackSecret,proto3" json:"callback_secret,omitempty"`
CallbackSecretSet bool `protobuf:"varint,11,opt,name=callback_secret_set,json=callbackSecretSet,proto3" json:"callback_secret_set,omitempty"`
CallbackIpWhitelist []string `protobuf:"bytes,12,rep,name=callback_ip_whitelist,json=callbackIpWhitelist,proto3" json:"callback_ip_whitelist,omitempty"`
AdapterConfigJson string `protobuf:"bytes,13,opt,name=adapter_config_json,json=adapterConfigJson,proto3" json:"adapter_config_json,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GamePlatform) Reset() {
@ -206,6 +212,41 @@ func (x *GamePlatform) GetUpdatedAtMs() int64 {
return 0
}
func (x *GamePlatform) GetAdapterType() string {
if x != nil {
return x.AdapterType
}
return ""
}
func (x *GamePlatform) GetCallbackSecret() string {
if x != nil {
return x.CallbackSecret
}
return ""
}
func (x *GamePlatform) GetCallbackSecretSet() bool {
if x != nil {
return x.CallbackSecretSet
}
return false
}
func (x *GamePlatform) GetCallbackIpWhitelist() []string {
if x != nil {
return x.CallbackIpWhitelist
}
return nil
}
func (x *GamePlatform) GetAdapterConfigJson() string {
if x != nil {
return x.AdapterConfigJson
}
return ""
}
type GameCatalogItem struct {
state protoimpl.MessageState `protogen:"open.v1"`
AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
@ -662,6 +703,9 @@ type LaunchGameRequest struct {
GameId string `protobuf:"bytes,4,opt,name=game_id,json=gameId,proto3" json:"game_id,omitempty"`
Scene string `protobuf:"bytes,5,opt,name=scene,proto3" json:"scene,omitempty"`
RoomId string `protobuf:"bytes,6,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"`
// access_token is the same bearer token used by the App request.
// Provider adapters pass it through as the game token instead of minting a separate game token.
AccessToken string `protobuf:"bytes,7,opt,name=access_token,json=accessToken,proto3" json:"access_token,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -738,6 +782,13 @@ func (x *LaunchGameRequest) GetRoomId() string {
return ""
}
func (x *LaunchGameRequest) GetAccessToken() string {
if x != nil {
return x.AccessToken
}
return ""
}
type LaunchGameResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"`
@ -1618,6 +1669,102 @@ func (x *SetGameStatusRequest) GetStatus() string {
return ""
}
type DeleteCatalogRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
GameId string `protobuf:"bytes,2,opt,name=game_id,json=gameId,proto3" json:"game_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeleteCatalogRequest) Reset() {
*x = DeleteCatalogRequest{}
mi := &file_proto_game_v1_game_proto_msgTypes[21]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeleteCatalogRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeleteCatalogRequest) ProtoMessage() {}
func (x *DeleteCatalogRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_game_v1_game_proto_msgTypes[21]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeleteCatalogRequest.ProtoReflect.Descriptor instead.
func (*DeleteCatalogRequest) Descriptor() ([]byte, []int) {
return file_proto_game_v1_game_proto_rawDescGZIP(), []int{21}
}
func (x *DeleteCatalogRequest) GetMeta() *RequestMeta {
if x != nil {
return x.Meta
}
return nil
}
func (x *DeleteCatalogRequest) GetGameId() string {
if x != nil {
return x.GameId
}
return ""
}
type DeleteCatalogResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
ServerTimeMs int64 `protobuf:"varint,1,opt,name=server_time_ms,json=serverTimeMs,proto3" json:"server_time_ms,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeleteCatalogResponse) Reset() {
*x = DeleteCatalogResponse{}
mi := &file_proto_game_v1_game_proto_msgTypes[22]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeleteCatalogResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeleteCatalogResponse) ProtoMessage() {}
func (x *DeleteCatalogResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_game_v1_game_proto_msgTypes[22]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeleteCatalogResponse.ProtoReflect.Descriptor instead.
func (*DeleteCatalogResponse) Descriptor() ([]byte, []int) {
return file_proto_game_v1_game_proto_rawDescGZIP(), []int{22}
}
func (x *DeleteCatalogResponse) GetServerTimeMs() int64 {
if x != nil {
return x.ServerTimeMs
}
return 0
}
var File_proto_game_v1_game_proto protoreflect.FileDescriptor
const file_proto_game_v1_game_proto_rawDesc = "" +
@ -1631,7 +1778,7 @@ const file_proto_game_v1_game_proto_rawDesc = "" +
"\ractor_user_id\x18\x04 \x01(\x03R\vactorUserId\x12\x1c\n" +
"\n" +
"sent_at_ms\x18\x05 \x01(\x03R\bsentAtMs\x12\x19\n" +
"\bapp_code\x18\x06 \x01(\tR\aappCode\"\x94\x02\n" +
"\bapp_code\x18\x06 \x01(\tR\aappCode\"\xf4\x03\n" +
"\fGamePlatform\x12\x19\n" +
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12#\n" +
"\rplatform_code\x18\x02 \x01(\tR\fplatformCode\x12#\n" +
@ -1642,7 +1789,13 @@ const file_proto_game_v1_game_proto_rawDesc = "" +
"\n" +
"sort_order\x18\x06 \x01(\x05R\tsortOrder\x12\"\n" +
"\rcreated_at_ms\x18\a \x01(\x03R\vcreatedAtMs\x12\"\n" +
"\rupdated_at_ms\x18\b \x01(\x03R\vupdatedAtMs\"\xf6\x03\n" +
"\rupdated_at_ms\x18\b \x01(\x03R\vupdatedAtMs\x12!\n" +
"\fadapter_type\x18\t \x01(\tR\vadapterType\x12'\n" +
"\x0fcallback_secret\x18\n" +
" \x01(\tR\x0ecallbackSecret\x12.\n" +
"\x13callback_secret_set\x18\v \x01(\bR\x11callbackSecretSet\x122\n" +
"\x15callback_ip_whitelist\x18\f \x03(\tR\x13callbackIpWhitelist\x12.\n" +
"\x13adapter_config_json\x18\r \x01(\tR\x11adapterConfigJson\"\xf6\x03\n" +
"\x0fGameCatalogItem\x12\x19\n" +
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" +
"\agame_id\x18\x02 \x01(\tR\x06gameId\x12#\n" +
@ -1690,14 +1843,15 @@ const file_proto_game_v1_game_proto_rawDesc = "" +
"\x0fclient_platform\x18\a \x01(\tR\x0eclientPlatform\"g\n" +
"\x11ListGamesResponse\x12,\n" +
"\x05games\x18\x01 \x03(\v2\x16.hyapp.game.v1.AppGameR\x05games\x12$\n" +
"\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"\xcc\x01\n" +
"\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"\xef\x01\n" +
"\x11LaunchGameRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.game.v1.RequestMetaR\x04meta\x12\x17\n" +
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12&\n" +
"\x0fdisplay_user_id\x18\x03 \x01(\tR\rdisplayUserId\x12\x17\n" +
"\agame_id\x18\x04 \x01(\tR\x06gameId\x12\x14\n" +
"\x05scene\x18\x05 \x01(\tR\x05scene\x12\x17\n" +
"\aroom_id\x18\x06 \x01(\tR\x06roomId\"\xbe\x01\n" +
"\aroom_id\x18\x06 \x01(\tR\x06roomId\x12!\n" +
"\faccess_token\x18\a \x01(\tR\vaccessToken\"\xbe\x01\n" +
"\x12LaunchGameResponse\x12\x1d\n" +
"\n" +
"session_id\x18\x01 \x01(\tR\tsessionId\x12\x1d\n" +
@ -1770,7 +1924,12 @@ const file_proto_game_v1_game_proto_rawDesc = "" +
"\x14SetGameStatusRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.game.v1.RequestMetaR\x04meta\x12\x17\n" +
"\agame_id\x18\x02 \x01(\tR\x06gameId\x12\x16\n" +
"\x06status\x18\x03 \x01(\tR\x06status2\xb3\x01\n" +
"\x06status\x18\x03 \x01(\tR\x06status\"_\n" +
"\x14DeleteCatalogRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.game.v1.RequestMetaR\x04meta\x12\x17\n" +
"\agame_id\x18\x02 \x01(\tR\x06gameId\"=\n" +
"\x15DeleteCatalogResponse\x12$\n" +
"\x0eserver_time_ms\x18\x01 \x01(\x03R\fserverTimeMs2\xb3\x01\n" +
"\x0eGameAppService\x12N\n" +
"\tListGames\x12\x1f.hyapp.game.v1.ListGamesRequest\x1a .hyapp.game.v1.ListGamesResponse\x12Q\n" +
"\n" +
@ -1778,13 +1937,14 @@ const file_proto_game_v1_game_proto_rawDesc = "" +
"\x13GameCallbackService\x12Q\n" +
"\x0eHandleCallback\x12\x1e.hyapp.game.v1.CallbackRequest\x1a\x1f.hyapp.game.v1.CallbackResponse2t\n" +
"\x0fGameCronService\x12a\n" +
"\x1cProcessLevelEventOutboxBatch\x12\x1f.hyapp.game.v1.CronBatchRequest\x1a .hyapp.game.v1.CronBatchResponse2\xc9\x03\n" +
"\x1cProcessLevelEventOutboxBatch\x12\x1f.hyapp.game.v1.CronBatchRequest\x1a .hyapp.game.v1.CronBatchResponse2\xa5\x04\n" +
"\x10GameAdminService\x12Z\n" +
"\rListPlatforms\x12#.hyapp.game.v1.ListPlatformsRequest\x1a$.hyapp.game.v1.ListPlatformsResponse\x12W\n" +
"\x0eUpsertPlatform\x12$.hyapp.game.v1.UpsertPlatformRequest\x1a\x1f.hyapp.game.v1.PlatformResponse\x12T\n" +
"\vListCatalog\x12!.hyapp.game.v1.ListCatalogRequest\x1a\".hyapp.game.v1.ListCatalogResponse\x12T\n" +
"\rUpsertCatalog\x12#.hyapp.game.v1.UpsertCatalogRequest\x1a\x1e.hyapp.game.v1.CatalogResponse\x12T\n" +
"\rSetGameStatus\x12#.hyapp.game.v1.SetGameStatusRequest\x1a\x1e.hyapp.game.v1.CatalogResponseB&Z$hyapp.local/api/proto/game/v1;gamev1b\x06proto3"
"\rSetGameStatus\x12#.hyapp.game.v1.SetGameStatusRequest\x1a\x1e.hyapp.game.v1.CatalogResponse\x12Z\n" +
"\rDeleteCatalog\x12#.hyapp.game.v1.DeleteCatalogRequest\x1a$.hyapp.game.v1.DeleteCatalogResponseB&Z$hyapp.local/api/proto/game/v1;gamev1b\x06proto3"
var (
file_proto_game_v1_game_proto_rawDescOnce sync.Once
@ -1798,7 +1958,7 @@ func file_proto_game_v1_game_proto_rawDescGZIP() []byte {
return file_proto_game_v1_game_proto_rawDescData
}
var file_proto_game_v1_game_proto_msgTypes = make([]protoimpl.MessageInfo, 23)
var file_proto_game_v1_game_proto_msgTypes = make([]protoimpl.MessageInfo, 25)
var file_proto_game_v1_game_proto_goTypes = []any{
(*RequestMeta)(nil), // 0: hyapp.game.v1.RequestMeta
(*GamePlatform)(nil), // 1: hyapp.game.v1.GamePlatform
@ -1821,16 +1981,18 @@ var file_proto_game_v1_game_proto_goTypes = []any{
(*UpsertCatalogRequest)(nil), // 18: hyapp.game.v1.UpsertCatalogRequest
(*CatalogResponse)(nil), // 19: hyapp.game.v1.CatalogResponse
(*SetGameStatusRequest)(nil), // 20: hyapp.game.v1.SetGameStatusRequest
nil, // 21: hyapp.game.v1.CallbackRequest.HeadersEntry
nil, // 22: hyapp.game.v1.CallbackRequest.QueryEntry
(*DeleteCatalogRequest)(nil), // 21: hyapp.game.v1.DeleteCatalogRequest
(*DeleteCatalogResponse)(nil), // 22: hyapp.game.v1.DeleteCatalogResponse
nil, // 23: hyapp.game.v1.CallbackRequest.HeadersEntry
nil, // 24: hyapp.game.v1.CallbackRequest.QueryEntry
}
var file_proto_game_v1_game_proto_depIdxs = []int32{
0, // 0: hyapp.game.v1.ListGamesRequest.meta:type_name -> hyapp.game.v1.RequestMeta
3, // 1: hyapp.game.v1.ListGamesResponse.games:type_name -> hyapp.game.v1.AppGame
0, // 2: hyapp.game.v1.LaunchGameRequest.meta:type_name -> hyapp.game.v1.RequestMeta
0, // 3: hyapp.game.v1.CallbackRequest.meta:type_name -> hyapp.game.v1.RequestMeta
21, // 4: hyapp.game.v1.CallbackRequest.headers:type_name -> hyapp.game.v1.CallbackRequest.HeadersEntry
22, // 5: hyapp.game.v1.CallbackRequest.query:type_name -> hyapp.game.v1.CallbackRequest.QueryEntry
23, // 4: hyapp.game.v1.CallbackRequest.headers:type_name -> hyapp.game.v1.CallbackRequest.HeadersEntry
24, // 5: hyapp.game.v1.CallbackRequest.query:type_name -> hyapp.game.v1.CallbackRequest.QueryEntry
0, // 6: hyapp.game.v1.CronBatchRequest.meta:type_name -> hyapp.game.v1.RequestMeta
0, // 7: hyapp.game.v1.ListPlatformsRequest.meta:type_name -> hyapp.game.v1.RequestMeta
1, // 8: hyapp.game.v1.ListPlatformsResponse.platforms:type_name -> hyapp.game.v1.GamePlatform
@ -1843,29 +2005,32 @@ var file_proto_game_v1_game_proto_depIdxs = []int32{
2, // 15: hyapp.game.v1.UpsertCatalogRequest.game:type_name -> hyapp.game.v1.GameCatalogItem
2, // 16: hyapp.game.v1.CatalogResponse.game:type_name -> hyapp.game.v1.GameCatalogItem
0, // 17: hyapp.game.v1.SetGameStatusRequest.meta:type_name -> hyapp.game.v1.RequestMeta
4, // 18: hyapp.game.v1.GameAppService.ListGames:input_type -> hyapp.game.v1.ListGamesRequest
6, // 19: hyapp.game.v1.GameAppService.LaunchGame:input_type -> hyapp.game.v1.LaunchGameRequest
8, // 20: hyapp.game.v1.GameCallbackService.HandleCallback:input_type -> hyapp.game.v1.CallbackRequest
10, // 21: hyapp.game.v1.GameCronService.ProcessLevelEventOutboxBatch:input_type -> hyapp.game.v1.CronBatchRequest
12, // 22: hyapp.game.v1.GameAdminService.ListPlatforms:input_type -> hyapp.game.v1.ListPlatformsRequest
14, // 23: hyapp.game.v1.GameAdminService.UpsertPlatform:input_type -> hyapp.game.v1.UpsertPlatformRequest
16, // 24: hyapp.game.v1.GameAdminService.ListCatalog:input_type -> hyapp.game.v1.ListCatalogRequest
18, // 25: hyapp.game.v1.GameAdminService.UpsertCatalog:input_type -> hyapp.game.v1.UpsertCatalogRequest
20, // 26: hyapp.game.v1.GameAdminService.SetGameStatus:input_type -> hyapp.game.v1.SetGameStatusRequest
5, // 27: hyapp.game.v1.GameAppService.ListGames:output_type -> hyapp.game.v1.ListGamesResponse
7, // 28: hyapp.game.v1.GameAppService.LaunchGame:output_type -> hyapp.game.v1.LaunchGameResponse
9, // 29: hyapp.game.v1.GameCallbackService.HandleCallback:output_type -> hyapp.game.v1.CallbackResponse
11, // 30: hyapp.game.v1.GameCronService.ProcessLevelEventOutboxBatch:output_type -> hyapp.game.v1.CronBatchResponse
13, // 31: hyapp.game.v1.GameAdminService.ListPlatforms:output_type -> hyapp.game.v1.ListPlatformsResponse
15, // 32: hyapp.game.v1.GameAdminService.UpsertPlatform:output_type -> hyapp.game.v1.PlatformResponse
17, // 33: hyapp.game.v1.GameAdminService.ListCatalog:output_type -> hyapp.game.v1.ListCatalogResponse
19, // 34: hyapp.game.v1.GameAdminService.UpsertCatalog:output_type -> hyapp.game.v1.CatalogResponse
19, // 35: hyapp.game.v1.GameAdminService.SetGameStatus:output_type -> hyapp.game.v1.CatalogResponse
27, // [27:36] is the sub-list for method output_type
18, // [18:27] is the sub-list for method input_type
18, // [18:18] is the sub-list for extension type_name
18, // [18:18] is the sub-list for extension extendee
0, // [0:18] is the sub-list for field type_name
0, // 18: hyapp.game.v1.DeleteCatalogRequest.meta:type_name -> hyapp.game.v1.RequestMeta
4, // 19: hyapp.game.v1.GameAppService.ListGames:input_type -> hyapp.game.v1.ListGamesRequest
6, // 20: hyapp.game.v1.GameAppService.LaunchGame:input_type -> hyapp.game.v1.LaunchGameRequest
8, // 21: hyapp.game.v1.GameCallbackService.HandleCallback:input_type -> hyapp.game.v1.CallbackRequest
10, // 22: hyapp.game.v1.GameCronService.ProcessLevelEventOutboxBatch:input_type -> hyapp.game.v1.CronBatchRequest
12, // 23: hyapp.game.v1.GameAdminService.ListPlatforms:input_type -> hyapp.game.v1.ListPlatformsRequest
14, // 24: hyapp.game.v1.GameAdminService.UpsertPlatform:input_type -> hyapp.game.v1.UpsertPlatformRequest
16, // 25: hyapp.game.v1.GameAdminService.ListCatalog:input_type -> hyapp.game.v1.ListCatalogRequest
18, // 26: hyapp.game.v1.GameAdminService.UpsertCatalog:input_type -> hyapp.game.v1.UpsertCatalogRequest
20, // 27: hyapp.game.v1.GameAdminService.SetGameStatus:input_type -> hyapp.game.v1.SetGameStatusRequest
21, // 28: hyapp.game.v1.GameAdminService.DeleteCatalog:input_type -> hyapp.game.v1.DeleteCatalogRequest
5, // 29: hyapp.game.v1.GameAppService.ListGames:output_type -> hyapp.game.v1.ListGamesResponse
7, // 30: hyapp.game.v1.GameAppService.LaunchGame:output_type -> hyapp.game.v1.LaunchGameResponse
9, // 31: hyapp.game.v1.GameCallbackService.HandleCallback:output_type -> hyapp.game.v1.CallbackResponse
11, // 32: hyapp.game.v1.GameCronService.ProcessLevelEventOutboxBatch:output_type -> hyapp.game.v1.CronBatchResponse
13, // 33: hyapp.game.v1.GameAdminService.ListPlatforms:output_type -> hyapp.game.v1.ListPlatformsResponse
15, // 34: hyapp.game.v1.GameAdminService.UpsertPlatform:output_type -> hyapp.game.v1.PlatformResponse
17, // 35: hyapp.game.v1.GameAdminService.ListCatalog:output_type -> hyapp.game.v1.ListCatalogResponse
19, // 36: hyapp.game.v1.GameAdminService.UpsertCatalog:output_type -> hyapp.game.v1.CatalogResponse
19, // 37: hyapp.game.v1.GameAdminService.SetGameStatus:output_type -> hyapp.game.v1.CatalogResponse
22, // 38: hyapp.game.v1.GameAdminService.DeleteCatalog:output_type -> hyapp.game.v1.DeleteCatalogResponse
29, // [29:39] is the sub-list for method output_type
19, // [19:29] is the sub-list for method input_type
19, // [19:19] is the sub-list for extension type_name
19, // [19:19] is the sub-list for extension extendee
0, // [0:19] is the sub-list for field type_name
}
func init() { file_proto_game_v1_game_proto_init() }
@ -1879,7 +2044,7 @@ func file_proto_game_v1_game_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_game_v1_game_proto_rawDesc), len(file_proto_game_v1_game_proto_rawDesc)),
NumEnums: 0,
NumMessages: 23,
NumMessages: 25,
NumExtensions: 0,
NumServices: 4,
},

View File

@ -23,6 +23,12 @@ message GamePlatform {
int32 sort_order = 6;
int64 created_at_ms = 7;
int64 updated_at_ms = 8;
string adapter_type = 9;
// callback_secret is visible to admin platform configuration pages and writable for key rotation.
string callback_secret = 10;
bool callback_secret_set = 11;
repeated string callback_ip_whitelist = 12;
string adapter_config_json = 13;
}
message GameCatalogItem {
@ -82,6 +88,9 @@ message LaunchGameRequest {
string game_id = 4;
string scene = 5;
string room_id = 6;
// access_token is the same bearer token used by the App request.
// Provider adapters pass it through as the game token instead of minting a separate game token.
string access_token = 7;
}
message LaunchGameResponse {
@ -173,6 +182,15 @@ message SetGameStatusRequest {
string status = 3;
}
message DeleteCatalogRequest {
RequestMeta meta = 1;
string game_id = 2;
}
message DeleteCatalogResponse {
int64 server_time_ms = 1;
}
service GameAppService {
rpc ListGames(ListGamesRequest) returns (ListGamesResponse);
rpc LaunchGame(LaunchGameRequest) returns (LaunchGameResponse);
@ -193,4 +211,5 @@ service GameAdminService {
rpc ListCatalog(ListCatalogRequest) returns (ListCatalogResponse);
rpc UpsertCatalog(UpsertCatalogRequest) returns (CatalogResponse);
rpc SetGameStatus(SetGameStatusRequest) returns (CatalogResponse);
rpc DeleteCatalog(DeleteCatalogRequest) returns (DeleteCatalogResponse);
}

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v5.28.3
// - protoc v7.35.0
// source: proto/game/v1/game.proto
package gamev1
@ -372,6 +372,7 @@ const (
GameAdminService_ListCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/ListCatalog"
GameAdminService_UpsertCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/UpsertCatalog"
GameAdminService_SetGameStatus_FullMethodName = "/hyapp.game.v1.GameAdminService/SetGameStatus"
GameAdminService_DeleteCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/DeleteCatalog"
)
// GameAdminServiceClient is the client API for GameAdminService service.
@ -383,6 +384,7 @@ type GameAdminServiceClient interface {
ListCatalog(ctx context.Context, in *ListCatalogRequest, opts ...grpc.CallOption) (*ListCatalogResponse, error)
UpsertCatalog(ctx context.Context, in *UpsertCatalogRequest, opts ...grpc.CallOption) (*CatalogResponse, error)
SetGameStatus(ctx context.Context, in *SetGameStatusRequest, opts ...grpc.CallOption) (*CatalogResponse, error)
DeleteCatalog(ctx context.Context, in *DeleteCatalogRequest, opts ...grpc.CallOption) (*DeleteCatalogResponse, error)
}
type gameAdminServiceClient struct {
@ -443,6 +445,16 @@ func (c *gameAdminServiceClient) SetGameStatus(ctx context.Context, in *SetGameS
return out, nil
}
func (c *gameAdminServiceClient) DeleteCatalog(ctx context.Context, in *DeleteCatalogRequest, opts ...grpc.CallOption) (*DeleteCatalogResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteCatalogResponse)
err := c.cc.Invoke(ctx, GameAdminService_DeleteCatalog_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// GameAdminServiceServer is the server API for GameAdminService service.
// All implementations must embed UnimplementedGameAdminServiceServer
// for forward compatibility.
@ -452,6 +464,7 @@ type GameAdminServiceServer interface {
ListCatalog(context.Context, *ListCatalogRequest) (*ListCatalogResponse, error)
UpsertCatalog(context.Context, *UpsertCatalogRequest) (*CatalogResponse, error)
SetGameStatus(context.Context, *SetGameStatusRequest) (*CatalogResponse, error)
DeleteCatalog(context.Context, *DeleteCatalogRequest) (*DeleteCatalogResponse, error)
mustEmbedUnimplementedGameAdminServiceServer()
}
@ -477,6 +490,9 @@ func (UnimplementedGameAdminServiceServer) UpsertCatalog(context.Context, *Upser
func (UnimplementedGameAdminServiceServer) SetGameStatus(context.Context, *SetGameStatusRequest) (*CatalogResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetGameStatus not implemented")
}
func (UnimplementedGameAdminServiceServer) DeleteCatalog(context.Context, *DeleteCatalogRequest) (*DeleteCatalogResponse, error) {
return nil, status.Error(codes.Unimplemented, "method DeleteCatalog not implemented")
}
func (UnimplementedGameAdminServiceServer) mustEmbedUnimplementedGameAdminServiceServer() {}
func (UnimplementedGameAdminServiceServer) testEmbeddedByValue() {}
@ -588,6 +604,24 @@ func _GameAdminService_SetGameStatus_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_DeleteCatalog_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteCatalogRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).DeleteCatalog(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_DeleteCatalog_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).DeleteCatalog(ctx, req.(*DeleteCatalogRequest))
}
return interceptor(ctx, in, info, handler)
}
// GameAdminService_ServiceDesc is the grpc.ServiceDesc for GameAdminService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -615,6 +649,10 @@ var GameAdminService_ServiceDesc = grpc.ServiceDesc{
MethodName: "SetGameStatus",
Handler: _GameAdminService_SetGameStatus_Handler,
},
{
MethodName: "DeleteCatalog",
Handler: _GameAdminService_DeleteCatalog_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/game/v1/game.proto",

File diff suppressed because it is too large Load Diff

View File

@ -126,6 +126,146 @@ message RoomTreasureInfo {
string reward_stack_policy = 8;
}
// RoomTreasureGiftEnergyRule
message RoomTreasureGiftEnergyRule {
string rule_id = 1;
string gift_id = 2;
string gift_type_code = 3;
int64 multiplier_ppm = 4;
int64 fixed_energy = 5;
bool excluded = 6;
}
// AdminRoomTreasureConfig
message AdminRoomTreasureConfig {
string app_code = 1;
bool enabled = 2;
int64 config_version = 3;
string energy_source = 4;
int64 open_delay_ms = 5;
bool broadcast_enabled = 6;
string broadcast_scope = 7;
int64 broadcast_delay_ms = 8;
string reward_stack_policy = 9;
repeated RoomTreasureLevel levels = 10;
repeated RoomTreasureGiftEnergyRule gift_energy_rules = 11;
int64 updated_by_admin_id = 12;
int64 created_at_ms = 13;
int64 updated_at_ms = 14;
}
message AdminGetRoomTreasureConfigRequest {
RequestMeta meta = 1;
}
message AdminGetRoomTreasureConfigResponse {
AdminRoomTreasureConfig config = 1;
int64 server_time_ms = 2;
}
message AdminUpdateRoomTreasureConfigRequest {
RequestMeta meta = 1;
AdminRoomTreasureConfig config = 2;
int64 admin_id = 3;
}
message AdminUpdateRoomTreasureConfigResponse {
AdminRoomTreasureConfig config = 1;
int64 server_time_ms = 2;
}
message AdminRoomSeatConfig {
repeated int32 candidate_seat_counts = 1;
repeated int32 allowed_seat_counts = 2;
int32 default_seat_count = 3;
int64 updated_at_ms = 4;
}
message AdminGetRoomSeatConfigRequest {
RequestMeta meta = 1;
}
message AdminGetRoomSeatConfigResponse {
AdminRoomSeatConfig config = 1;
int64 server_time_ms = 2;
}
message AdminUpdateRoomSeatConfigRequest {
RequestMeta meta = 1;
repeated int32 allowed_seat_counts = 2;
int32 default_seat_count = 3;
}
message AdminUpdateRoomSeatConfigResponse {
AdminRoomSeatConfig config = 1;
int64 server_time_ms = 2;
}
message AdminRoomPinRoom {
string room_id = 1;
string room_short_id = 2;
int64 visible_region_id = 3;
int64 owner_user_id = 4;
string title = 5;
string cover_url = 6;
string status = 7;
}
message AdminRoomPin {
int64 id = 1;
int64 visible_region_id = 2;
string room_id = 3;
int64 weight = 4;
string status = 5;
int64 pinned_at_ms = 6;
int64 expires_at_ms = 7;
int64 cancelled_at_ms = 8;
uint64 created_by_admin_id = 9;
uint64 cancelled_by_admin_id = 10;
int64 created_at_ms = 11;
int64 updated_at_ms = 12;
AdminRoomPinRoom room = 13;
}
message AdminListRoomPinsRequest {
RequestMeta meta = 1;
int32 page = 2;
int32 page_size = 3;
string keyword = 4;
string status = 5;
int64 visible_region_id = 6;
}
message AdminListRoomPinsResponse {
repeated AdminRoomPin pins = 1;
int64 total = 2;
int64 server_time_ms = 3;
}
message AdminCreateRoomPinRequest {
RequestMeta meta = 1;
string room_id = 2;
int64 weight = 3;
int64 duration_days = 4;
uint64 admin_id = 5;
}
message AdminCreateRoomPinResponse {
AdminRoomPin pin = 1;
int64 server_time_ms = 2;
}
message AdminCancelRoomPinRequest {
RequestMeta meta = 1;
int64 pin_id = 2;
uint64 admin_id = 3;
}
message AdminCancelRoomPinResponse {
AdminRoomPin pin = 1;
int64 server_time_ms = 2;
}
// RoomSnapshot room-service
message RoomSnapshot {
string room_id = 1;
@ -245,6 +385,85 @@ message CloseRoomResponse {
RoomSnapshot room = 2;
}
// AdminRoomListItem room-service owner
message AdminRoomListItem {
string room_id = 1;
string room_short_id = 2;
int64 visible_region_id = 3;
int64 owner_user_id = 4;
string title = 5;
string cover_url = 6;
string mode = 7;
string status = 8;
string close_reason = 9;
uint64 closed_by_admin_id = 10;
string closed_by_admin_name = 11;
int64 closed_at_ms = 12;
int64 heat = 13;
int32 online_count = 14;
int32 seat_count = 15;
int32 occupied_seat_count = 16;
int64 sort_score = 17;
int64 created_at_ms = 18;
int64 updated_at_ms = 19;
}
message AdminListRoomsRequest {
RequestMeta meta = 1;
int32 page = 2;
int32 page_size = 3;
string keyword = 4;
string status = 5;
int64 visible_region_id = 6;
string sort_by = 7;
string sort_direction = 8;
}
message AdminListRoomsResponse {
repeated AdminRoomListItem rooms = 1;
int64 total = 2;
int64 server_time_ms = 3;
}
message AdminGetRoomRequest {
RequestMeta meta = 1;
string room_id = 2;
}
message AdminGetRoomResponse {
AdminRoomListItem room = 1;
int64 server_time_ms = 2;
}
message AdminUpdateRoomRequest {
RequestMeta meta = 1;
optional string title = 2;
optional string cover_url = 3;
optional string description = 4;
optional string mode = 5;
optional string status = 6;
optional int64 visible_region_id = 7;
string close_reason = 8;
uint64 admin_id = 9;
string admin_name = 10;
}
message AdminUpdateRoomResponse {
CommandResult result = 1;
RoomSnapshot room = 2;
}
message AdminDeleteRoomRequest {
RequestMeta meta = 1;
uint64 admin_id = 2;
string admin_name = 3;
}
message AdminDeleteRoomResponse {
CommandResult result = 1;
RoomSnapshot room = 2;
}
// MicUpRequest
message MicUpRequest {
RequestMeta meta = 1;
@ -465,6 +684,7 @@ message SendGiftRequest {
// target_type user all_micall_roomcouple HTTP
string target_type = 5;
repeated int64 target_user_ids = 6;
string pool_id = 7;
}
// SendGiftResponse
@ -681,6 +901,12 @@ service RoomCommandService {
rpc RoomHeartbeat(RoomHeartbeatRequest) returns (RoomHeartbeatResponse);
rpc LeaveRoom(LeaveRoomRequest) returns (LeaveRoomResponse);
rpc CloseRoom(CloseRoomRequest) returns (CloseRoomResponse);
rpc AdminUpdateRoom(AdminUpdateRoomRequest) returns (AdminUpdateRoomResponse);
rpc AdminDeleteRoom(AdminDeleteRoomRequest) returns (AdminDeleteRoomResponse);
rpc AdminUpdateRoomTreasureConfig(AdminUpdateRoomTreasureConfigRequest) returns (AdminUpdateRoomTreasureConfigResponse);
rpc AdminUpdateRoomSeatConfig(AdminUpdateRoomSeatConfigRequest) returns (AdminUpdateRoomSeatConfigResponse);
rpc AdminCreateRoomPin(AdminCreateRoomPinRequest) returns (AdminCreateRoomPinResponse);
rpc AdminCancelRoomPin(AdminCancelRoomPinRequest) returns (AdminCancelRoomPinResponse);
rpc MicUp(MicUpRequest) returns (MicUpResponse);
rpc MicDown(MicDownRequest) returns (MicDownResponse);
rpc ChangeMicSeat(ChangeMicSeatRequest) returns (ChangeMicSeatResponse);
@ -708,6 +934,11 @@ service RoomGuardService {
// RoomQueryService Room Cell
service RoomQueryService {
rpc AdminListRooms(AdminListRoomsRequest) returns (AdminListRoomsResponse);
rpc AdminGetRoom(AdminGetRoomRequest) returns (AdminGetRoomResponse);
rpc AdminGetRoomTreasureConfig(AdminGetRoomTreasureConfigRequest) returns (AdminGetRoomTreasureConfigResponse);
rpc AdminGetRoomSeatConfig(AdminGetRoomSeatConfigRequest) returns (AdminGetRoomSeatConfigResponse);
rpc AdminListRoomPins(AdminListRoomPinsRequest) returns (AdminListRoomPinsResponse);
rpc ListRooms(ListRoomsRequest) returns (ListRoomsResponse);
rpc ListRoomFeeds(ListRoomFeedsRequest) returns (ListRoomsResponse);
rpc GetMyRoom(GetMyRoomRequest) returns (GetMyRoomResponse);

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc (unknown)
// - protoc v7.35.0
// source: proto/room/v1/room.proto
package roomv1
@ -19,29 +19,35 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
RoomCommandService_CreateRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/CreateRoom"
RoomCommandService_UpdateRoomProfile_FullMethodName = "/hyapp.room.v1.RoomCommandService/UpdateRoomProfile"
RoomCommandService_JoinRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/JoinRoom"
RoomCommandService_RoomHeartbeat_FullMethodName = "/hyapp.room.v1.RoomCommandService/RoomHeartbeat"
RoomCommandService_LeaveRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/LeaveRoom"
RoomCommandService_CloseRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/CloseRoom"
RoomCommandService_MicUp_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicUp"
RoomCommandService_MicDown_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicDown"
RoomCommandService_ChangeMicSeat_FullMethodName = "/hyapp.room.v1.RoomCommandService/ChangeMicSeat"
RoomCommandService_ConfirmMicPublishing_FullMethodName = "/hyapp.room.v1.RoomCommandService/ConfirmMicPublishing"
RoomCommandService_SetMicMute_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetMicMute"
RoomCommandService_ApplyRTCEvent_FullMethodName = "/hyapp.room.v1.RoomCommandService/ApplyRTCEvent"
RoomCommandService_SetMicSeatLock_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetMicSeatLock"
RoomCommandService_SetChatEnabled_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetChatEnabled"
RoomCommandService_SetRoomPassword_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetRoomPassword"
RoomCommandService_SetRoomAdmin_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetRoomAdmin"
RoomCommandService_MuteUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/MuteUser"
RoomCommandService_KickUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/KickUser"
RoomCommandService_UnbanUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/UnbanUser"
RoomCommandService_SystemEvictUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/SystemEvictUser"
RoomCommandService_SendGift_FullMethodName = "/hyapp.room.v1.RoomCommandService/SendGift"
RoomCommandService_FollowRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/FollowRoom"
RoomCommandService_UnfollowRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/UnfollowRoom"
RoomCommandService_CreateRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/CreateRoom"
RoomCommandService_UpdateRoomProfile_FullMethodName = "/hyapp.room.v1.RoomCommandService/UpdateRoomProfile"
RoomCommandService_JoinRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/JoinRoom"
RoomCommandService_RoomHeartbeat_FullMethodName = "/hyapp.room.v1.RoomCommandService/RoomHeartbeat"
RoomCommandService_LeaveRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/LeaveRoom"
RoomCommandService_CloseRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/CloseRoom"
RoomCommandService_AdminUpdateRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminUpdateRoom"
RoomCommandService_AdminDeleteRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminDeleteRoom"
RoomCommandService_AdminUpdateRoomTreasureConfig_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminUpdateRoomTreasureConfig"
RoomCommandService_AdminUpdateRoomSeatConfig_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminUpdateRoomSeatConfig"
RoomCommandService_AdminCreateRoomPin_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminCreateRoomPin"
RoomCommandService_AdminCancelRoomPin_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminCancelRoomPin"
RoomCommandService_MicUp_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicUp"
RoomCommandService_MicDown_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicDown"
RoomCommandService_ChangeMicSeat_FullMethodName = "/hyapp.room.v1.RoomCommandService/ChangeMicSeat"
RoomCommandService_ConfirmMicPublishing_FullMethodName = "/hyapp.room.v1.RoomCommandService/ConfirmMicPublishing"
RoomCommandService_SetMicMute_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetMicMute"
RoomCommandService_ApplyRTCEvent_FullMethodName = "/hyapp.room.v1.RoomCommandService/ApplyRTCEvent"
RoomCommandService_SetMicSeatLock_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetMicSeatLock"
RoomCommandService_SetChatEnabled_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetChatEnabled"
RoomCommandService_SetRoomPassword_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetRoomPassword"
RoomCommandService_SetRoomAdmin_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetRoomAdmin"
RoomCommandService_MuteUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/MuteUser"
RoomCommandService_KickUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/KickUser"
RoomCommandService_UnbanUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/UnbanUser"
RoomCommandService_SystemEvictUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/SystemEvictUser"
RoomCommandService_SendGift_FullMethodName = "/hyapp.room.v1.RoomCommandService/SendGift"
RoomCommandService_FollowRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/FollowRoom"
RoomCommandService_UnfollowRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/UnfollowRoom"
)
// RoomCommandServiceClient is the client API for RoomCommandService service.
@ -56,6 +62,12 @@ type RoomCommandServiceClient interface {
RoomHeartbeat(ctx context.Context, in *RoomHeartbeatRequest, opts ...grpc.CallOption) (*RoomHeartbeatResponse, error)
LeaveRoom(ctx context.Context, in *LeaveRoomRequest, opts ...grpc.CallOption) (*LeaveRoomResponse, error)
CloseRoom(ctx context.Context, in *CloseRoomRequest, opts ...grpc.CallOption) (*CloseRoomResponse, error)
AdminUpdateRoom(ctx context.Context, in *AdminUpdateRoomRequest, opts ...grpc.CallOption) (*AdminUpdateRoomResponse, error)
AdminDeleteRoom(ctx context.Context, in *AdminDeleteRoomRequest, opts ...grpc.CallOption) (*AdminDeleteRoomResponse, error)
AdminUpdateRoomTreasureConfig(ctx context.Context, in *AdminUpdateRoomTreasureConfigRequest, opts ...grpc.CallOption) (*AdminUpdateRoomTreasureConfigResponse, error)
AdminUpdateRoomSeatConfig(ctx context.Context, in *AdminUpdateRoomSeatConfigRequest, opts ...grpc.CallOption) (*AdminUpdateRoomSeatConfigResponse, error)
AdminCreateRoomPin(ctx context.Context, in *AdminCreateRoomPinRequest, opts ...grpc.CallOption) (*AdminCreateRoomPinResponse, error)
AdminCancelRoomPin(ctx context.Context, in *AdminCancelRoomPinRequest, opts ...grpc.CallOption) (*AdminCancelRoomPinResponse, error)
MicUp(ctx context.Context, in *MicUpRequest, opts ...grpc.CallOption) (*MicUpResponse, error)
MicDown(ctx context.Context, in *MicDownRequest, opts ...grpc.CallOption) (*MicDownResponse, error)
ChangeMicSeat(ctx context.Context, in *ChangeMicSeatRequest, opts ...grpc.CallOption) (*ChangeMicSeatResponse, error)
@ -143,6 +155,66 @@ func (c *roomCommandServiceClient) CloseRoom(ctx context.Context, in *CloseRoomR
return out, nil
}
func (c *roomCommandServiceClient) AdminUpdateRoom(ctx context.Context, in *AdminUpdateRoomRequest, opts ...grpc.CallOption) (*AdminUpdateRoomResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminUpdateRoomResponse)
err := c.cc.Invoke(ctx, RoomCommandService_AdminUpdateRoom_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomCommandServiceClient) AdminDeleteRoom(ctx context.Context, in *AdminDeleteRoomRequest, opts ...grpc.CallOption) (*AdminDeleteRoomResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminDeleteRoomResponse)
err := c.cc.Invoke(ctx, RoomCommandService_AdminDeleteRoom_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomCommandServiceClient) AdminUpdateRoomTreasureConfig(ctx context.Context, in *AdminUpdateRoomTreasureConfigRequest, opts ...grpc.CallOption) (*AdminUpdateRoomTreasureConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminUpdateRoomTreasureConfigResponse)
err := c.cc.Invoke(ctx, RoomCommandService_AdminUpdateRoomTreasureConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomCommandServiceClient) AdminUpdateRoomSeatConfig(ctx context.Context, in *AdminUpdateRoomSeatConfigRequest, opts ...grpc.CallOption) (*AdminUpdateRoomSeatConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminUpdateRoomSeatConfigResponse)
err := c.cc.Invoke(ctx, RoomCommandService_AdminUpdateRoomSeatConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomCommandServiceClient) AdminCreateRoomPin(ctx context.Context, in *AdminCreateRoomPinRequest, opts ...grpc.CallOption) (*AdminCreateRoomPinResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminCreateRoomPinResponse)
err := c.cc.Invoke(ctx, RoomCommandService_AdminCreateRoomPin_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomCommandServiceClient) AdminCancelRoomPin(ctx context.Context, in *AdminCancelRoomPinRequest, opts ...grpc.CallOption) (*AdminCancelRoomPinResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminCancelRoomPinResponse)
err := c.cc.Invoke(ctx, RoomCommandService_AdminCancelRoomPin_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomCommandServiceClient) MicUp(ctx context.Context, in *MicUpRequest, opts ...grpc.CallOption) (*MicUpResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(MicUpResponse)
@ -325,6 +397,12 @@ type RoomCommandServiceServer interface {
RoomHeartbeat(context.Context, *RoomHeartbeatRequest) (*RoomHeartbeatResponse, error)
LeaveRoom(context.Context, *LeaveRoomRequest) (*LeaveRoomResponse, error)
CloseRoom(context.Context, *CloseRoomRequest) (*CloseRoomResponse, error)
AdminUpdateRoom(context.Context, *AdminUpdateRoomRequest) (*AdminUpdateRoomResponse, error)
AdminDeleteRoom(context.Context, *AdminDeleteRoomRequest) (*AdminDeleteRoomResponse, error)
AdminUpdateRoomTreasureConfig(context.Context, *AdminUpdateRoomTreasureConfigRequest) (*AdminUpdateRoomTreasureConfigResponse, error)
AdminUpdateRoomSeatConfig(context.Context, *AdminUpdateRoomSeatConfigRequest) (*AdminUpdateRoomSeatConfigResponse, error)
AdminCreateRoomPin(context.Context, *AdminCreateRoomPinRequest) (*AdminCreateRoomPinResponse, error)
AdminCancelRoomPin(context.Context, *AdminCancelRoomPinRequest) (*AdminCancelRoomPinResponse, error)
MicUp(context.Context, *MicUpRequest) (*MicUpResponse, error)
MicDown(context.Context, *MicDownRequest) (*MicDownResponse, error)
ChangeMicSeat(context.Context, *ChangeMicSeatRequest) (*ChangeMicSeatResponse, error)
@ -370,6 +448,24 @@ func (UnimplementedRoomCommandServiceServer) LeaveRoom(context.Context, *LeaveRo
func (UnimplementedRoomCommandServiceServer) CloseRoom(context.Context, *CloseRoomRequest) (*CloseRoomResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CloseRoom not implemented")
}
func (UnimplementedRoomCommandServiceServer) AdminUpdateRoom(context.Context, *AdminUpdateRoomRequest) (*AdminUpdateRoomResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminUpdateRoom not implemented")
}
func (UnimplementedRoomCommandServiceServer) AdminDeleteRoom(context.Context, *AdminDeleteRoomRequest) (*AdminDeleteRoomResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminDeleteRoom not implemented")
}
func (UnimplementedRoomCommandServiceServer) AdminUpdateRoomTreasureConfig(context.Context, *AdminUpdateRoomTreasureConfigRequest) (*AdminUpdateRoomTreasureConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminUpdateRoomTreasureConfig not implemented")
}
func (UnimplementedRoomCommandServiceServer) AdminUpdateRoomSeatConfig(context.Context, *AdminUpdateRoomSeatConfigRequest) (*AdminUpdateRoomSeatConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminUpdateRoomSeatConfig not implemented")
}
func (UnimplementedRoomCommandServiceServer) AdminCreateRoomPin(context.Context, *AdminCreateRoomPinRequest) (*AdminCreateRoomPinResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminCreateRoomPin not implemented")
}
func (UnimplementedRoomCommandServiceServer) AdminCancelRoomPin(context.Context, *AdminCancelRoomPinRequest) (*AdminCancelRoomPinResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminCancelRoomPin not implemented")
}
func (UnimplementedRoomCommandServiceServer) MicUp(context.Context, *MicUpRequest) (*MicUpResponse, error) {
return nil, status.Error(codes.Unimplemented, "method MicUp not implemented")
}
@ -550,6 +646,114 @@ func _RoomCommandService_CloseRoom_Handler(srv interface{}, ctx context.Context,
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_AdminUpdateRoom_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminUpdateRoomRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomCommandServiceServer).AdminUpdateRoom(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomCommandService_AdminUpdateRoom_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomCommandServiceServer).AdminUpdateRoom(ctx, req.(*AdminUpdateRoomRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_AdminDeleteRoom_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminDeleteRoomRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomCommandServiceServer).AdminDeleteRoom(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomCommandService_AdminDeleteRoom_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomCommandServiceServer).AdminDeleteRoom(ctx, req.(*AdminDeleteRoomRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_AdminUpdateRoomTreasureConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminUpdateRoomTreasureConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomCommandServiceServer).AdminUpdateRoomTreasureConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomCommandService_AdminUpdateRoomTreasureConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomCommandServiceServer).AdminUpdateRoomTreasureConfig(ctx, req.(*AdminUpdateRoomTreasureConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_AdminUpdateRoomSeatConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminUpdateRoomSeatConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomCommandServiceServer).AdminUpdateRoomSeatConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomCommandService_AdminUpdateRoomSeatConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomCommandServiceServer).AdminUpdateRoomSeatConfig(ctx, req.(*AdminUpdateRoomSeatConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_AdminCreateRoomPin_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminCreateRoomPinRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomCommandServiceServer).AdminCreateRoomPin(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomCommandService_AdminCreateRoomPin_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomCommandServiceServer).AdminCreateRoomPin(ctx, req.(*AdminCreateRoomPinRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_AdminCancelRoomPin_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminCancelRoomPinRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomCommandServiceServer).AdminCancelRoomPin(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomCommandService_AdminCancelRoomPin_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomCommandServiceServer).AdminCancelRoomPin(ctx, req.(*AdminCancelRoomPinRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_MicUp_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(MicUpRequest)
if err := dec(in); err != nil {
@ -887,6 +1091,30 @@ var RoomCommandService_ServiceDesc = grpc.ServiceDesc{
MethodName: "CloseRoom",
Handler: _RoomCommandService_CloseRoom_Handler,
},
{
MethodName: "AdminUpdateRoom",
Handler: _RoomCommandService_AdminUpdateRoom_Handler,
},
{
MethodName: "AdminDeleteRoom",
Handler: _RoomCommandService_AdminDeleteRoom_Handler,
},
{
MethodName: "AdminUpdateRoomTreasureConfig",
Handler: _RoomCommandService_AdminUpdateRoomTreasureConfig_Handler,
},
{
MethodName: "AdminUpdateRoomSeatConfig",
Handler: _RoomCommandService_AdminUpdateRoomSeatConfig_Handler,
},
{
MethodName: "AdminCreateRoomPin",
Handler: _RoomCommandService_AdminCreateRoomPin_Handler,
},
{
MethodName: "AdminCancelRoomPin",
Handler: _RoomCommandService_AdminCancelRoomPin_Handler,
},
{
MethodName: "MicUp",
Handler: _RoomCommandService_MicUp_Handler,
@ -1105,13 +1333,18 @@ var RoomGuardService_ServiceDesc = grpc.ServiceDesc{
}
const (
RoomQueryService_ListRooms_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRooms"
RoomQueryService_ListRoomFeeds_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomFeeds"
RoomQueryService_GetMyRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetMyRoom"
RoomQueryService_GetCurrentRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetCurrentRoom"
RoomQueryService_GetRoomSnapshot_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetRoomSnapshot"
RoomQueryService_GetRoomTreasure_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetRoomTreasure"
RoomQueryService_ListRoomOnlineUsers_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomOnlineUsers"
RoomQueryService_AdminListRooms_FullMethodName = "/hyapp.room.v1.RoomQueryService/AdminListRooms"
RoomQueryService_AdminGetRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/AdminGetRoom"
RoomQueryService_AdminGetRoomTreasureConfig_FullMethodName = "/hyapp.room.v1.RoomQueryService/AdminGetRoomTreasureConfig"
RoomQueryService_AdminGetRoomSeatConfig_FullMethodName = "/hyapp.room.v1.RoomQueryService/AdminGetRoomSeatConfig"
RoomQueryService_AdminListRoomPins_FullMethodName = "/hyapp.room.v1.RoomQueryService/AdminListRoomPins"
RoomQueryService_ListRooms_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRooms"
RoomQueryService_ListRoomFeeds_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomFeeds"
RoomQueryService_GetMyRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetMyRoom"
RoomQueryService_GetCurrentRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetCurrentRoom"
RoomQueryService_GetRoomSnapshot_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetRoomSnapshot"
RoomQueryService_GetRoomTreasure_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetRoomTreasure"
RoomQueryService_ListRoomOnlineUsers_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomOnlineUsers"
)
// RoomQueryServiceClient is the client API for RoomQueryService service.
@ -1120,6 +1353,11 @@ const (
//
// RoomQueryService 承载不会改变 Room Cell 状态的读模型查询。
type RoomQueryServiceClient interface {
AdminListRooms(ctx context.Context, in *AdminListRoomsRequest, opts ...grpc.CallOption) (*AdminListRoomsResponse, error)
AdminGetRoom(ctx context.Context, in *AdminGetRoomRequest, opts ...grpc.CallOption) (*AdminGetRoomResponse, error)
AdminGetRoomTreasureConfig(ctx context.Context, in *AdminGetRoomTreasureConfigRequest, opts ...grpc.CallOption) (*AdminGetRoomTreasureConfigResponse, error)
AdminGetRoomSeatConfig(ctx context.Context, in *AdminGetRoomSeatConfigRequest, opts ...grpc.CallOption) (*AdminGetRoomSeatConfigResponse, error)
AdminListRoomPins(ctx context.Context, in *AdminListRoomPinsRequest, opts ...grpc.CallOption) (*AdminListRoomPinsResponse, error)
ListRooms(ctx context.Context, in *ListRoomsRequest, opts ...grpc.CallOption) (*ListRoomsResponse, error)
ListRoomFeeds(ctx context.Context, in *ListRoomFeedsRequest, opts ...grpc.CallOption) (*ListRoomsResponse, error)
GetMyRoom(ctx context.Context, in *GetMyRoomRequest, opts ...grpc.CallOption) (*GetMyRoomResponse, error)
@ -1137,6 +1375,56 @@ func NewRoomQueryServiceClient(cc grpc.ClientConnInterface) RoomQueryServiceClie
return &roomQueryServiceClient{cc}
}
func (c *roomQueryServiceClient) AdminListRooms(ctx context.Context, in *AdminListRoomsRequest, opts ...grpc.CallOption) (*AdminListRoomsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminListRoomsResponse)
err := c.cc.Invoke(ctx, RoomQueryService_AdminListRooms_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomQueryServiceClient) AdminGetRoom(ctx context.Context, in *AdminGetRoomRequest, opts ...grpc.CallOption) (*AdminGetRoomResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminGetRoomResponse)
err := c.cc.Invoke(ctx, RoomQueryService_AdminGetRoom_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomQueryServiceClient) AdminGetRoomTreasureConfig(ctx context.Context, in *AdminGetRoomTreasureConfigRequest, opts ...grpc.CallOption) (*AdminGetRoomTreasureConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminGetRoomTreasureConfigResponse)
err := c.cc.Invoke(ctx, RoomQueryService_AdminGetRoomTreasureConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomQueryServiceClient) AdminGetRoomSeatConfig(ctx context.Context, in *AdminGetRoomSeatConfigRequest, opts ...grpc.CallOption) (*AdminGetRoomSeatConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminGetRoomSeatConfigResponse)
err := c.cc.Invoke(ctx, RoomQueryService_AdminGetRoomSeatConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomQueryServiceClient) AdminListRoomPins(ctx context.Context, in *AdminListRoomPinsRequest, opts ...grpc.CallOption) (*AdminListRoomPinsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminListRoomPinsResponse)
err := c.cc.Invoke(ctx, RoomQueryService_AdminListRoomPins_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomQueryServiceClient) ListRooms(ctx context.Context, in *ListRoomsRequest, opts ...grpc.CallOption) (*ListRoomsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListRoomsResponse)
@ -1213,6 +1501,11 @@ func (c *roomQueryServiceClient) ListRoomOnlineUsers(ctx context.Context, in *Li
//
// RoomQueryService 承载不会改变 Room Cell 状态的读模型查询。
type RoomQueryServiceServer interface {
AdminListRooms(context.Context, *AdminListRoomsRequest) (*AdminListRoomsResponse, error)
AdminGetRoom(context.Context, *AdminGetRoomRequest) (*AdminGetRoomResponse, error)
AdminGetRoomTreasureConfig(context.Context, *AdminGetRoomTreasureConfigRequest) (*AdminGetRoomTreasureConfigResponse, error)
AdminGetRoomSeatConfig(context.Context, *AdminGetRoomSeatConfigRequest) (*AdminGetRoomSeatConfigResponse, error)
AdminListRoomPins(context.Context, *AdminListRoomPinsRequest) (*AdminListRoomPinsResponse, error)
ListRooms(context.Context, *ListRoomsRequest) (*ListRoomsResponse, error)
ListRoomFeeds(context.Context, *ListRoomFeedsRequest) (*ListRoomsResponse, error)
GetMyRoom(context.Context, *GetMyRoomRequest) (*GetMyRoomResponse, error)
@ -1230,6 +1523,21 @@ type RoomQueryServiceServer interface {
// pointer dereference when methods are called.
type UnimplementedRoomQueryServiceServer struct{}
func (UnimplementedRoomQueryServiceServer) AdminListRooms(context.Context, *AdminListRoomsRequest) (*AdminListRoomsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminListRooms not implemented")
}
func (UnimplementedRoomQueryServiceServer) AdminGetRoom(context.Context, *AdminGetRoomRequest) (*AdminGetRoomResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminGetRoom not implemented")
}
func (UnimplementedRoomQueryServiceServer) AdminGetRoomTreasureConfig(context.Context, *AdminGetRoomTreasureConfigRequest) (*AdminGetRoomTreasureConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminGetRoomTreasureConfig not implemented")
}
func (UnimplementedRoomQueryServiceServer) AdminGetRoomSeatConfig(context.Context, *AdminGetRoomSeatConfigRequest) (*AdminGetRoomSeatConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminGetRoomSeatConfig not implemented")
}
func (UnimplementedRoomQueryServiceServer) AdminListRoomPins(context.Context, *AdminListRoomPinsRequest) (*AdminListRoomPinsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminListRoomPins not implemented")
}
func (UnimplementedRoomQueryServiceServer) ListRooms(context.Context, *ListRoomsRequest) (*ListRoomsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListRooms not implemented")
}
@ -1272,6 +1580,96 @@ func RegisterRoomQueryServiceServer(s grpc.ServiceRegistrar, srv RoomQueryServic
s.RegisterService(&RoomQueryService_ServiceDesc, srv)
}
func _RoomQueryService_AdminListRooms_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminListRoomsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomQueryServiceServer).AdminListRooms(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomQueryService_AdminListRooms_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomQueryServiceServer).AdminListRooms(ctx, req.(*AdminListRoomsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomQueryService_AdminGetRoom_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminGetRoomRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomQueryServiceServer).AdminGetRoom(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomQueryService_AdminGetRoom_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomQueryServiceServer).AdminGetRoom(ctx, req.(*AdminGetRoomRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomQueryService_AdminGetRoomTreasureConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminGetRoomTreasureConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomQueryServiceServer).AdminGetRoomTreasureConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomQueryService_AdminGetRoomTreasureConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomQueryServiceServer).AdminGetRoomTreasureConfig(ctx, req.(*AdminGetRoomTreasureConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomQueryService_AdminGetRoomSeatConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminGetRoomSeatConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomQueryServiceServer).AdminGetRoomSeatConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomQueryService_AdminGetRoomSeatConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomQueryServiceServer).AdminGetRoomSeatConfig(ctx, req.(*AdminGetRoomSeatConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomQueryService_AdminListRoomPins_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminListRoomPinsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomQueryServiceServer).AdminListRoomPins(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomQueryService_AdminListRoomPins_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomQueryServiceServer).AdminListRoomPins(ctx, req.(*AdminListRoomPinsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomQueryService_ListRooms_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListRoomsRequest)
if err := dec(in); err != nil {
@ -1405,6 +1803,26 @@ var RoomQueryService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.room.v1.RoomQueryService",
HandlerType: (*RoomQueryServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "AdminListRooms",
Handler: _RoomQueryService_AdminListRooms_Handler,
},
{
MethodName: "AdminGetRoom",
Handler: _RoomQueryService_AdminGetRoom_Handler,
},
{
MethodName: "AdminGetRoomTreasureConfig",
Handler: _RoomQueryService_AdminGetRoomTreasureConfig_Handler,
},
{
MethodName: "AdminGetRoomSeatConfig",
Handler: _RoomQueryService_AdminGetRoomSeatConfig_Handler,
},
{
MethodName: "AdminListRoomPins",
Handler: _RoomQueryService_AdminListRoomPins_Handler,
},
{
MethodName: "ListRooms",
Handler: _RoomQueryService_ListRooms_Handler,

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.28.3
// protoc v7.35.0
// source: proto/user/v1/auth.proto
package userv1
@ -454,6 +454,248 @@ func (x *SetPasswordResponse) GetPasswordSet() bool {
return false
}
// QuickCreateAccountRequest 为测试和三方联调快速创建一个可密码登录的完整账号。
type QuickCreateAccountRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"`
Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"`
Avatar string `protobuf:"bytes,4,opt,name=avatar,proto3" json:"avatar,omitempty"`
Gender string `protobuf:"bytes,5,opt,name=gender,proto3" json:"gender,omitempty"`
Country string `protobuf:"bytes,6,opt,name=country,proto3" json:"country,omitempty"`
InviteCode string `protobuf:"bytes,7,opt,name=invite_code,json=inviteCode,proto3" json:"invite_code,omitempty"`
DeviceId string `protobuf:"bytes,8,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"`
Device string `protobuf:"bytes,9,opt,name=device,proto3" json:"device,omitempty"`
OsVersion string `protobuf:"bytes,10,opt,name=os_version,json=osVersion,proto3" json:"os_version,omitempty"`
Birth string `protobuf:"bytes,11,opt,name=birth,proto3" json:"birth,omitempty"`
AppVersion string `protobuf:"bytes,12,opt,name=app_version,json=appVersion,proto3" json:"app_version,omitempty"`
BuildNumber string `protobuf:"bytes,13,opt,name=build_number,json=buildNumber,proto3" json:"build_number,omitempty"`
Source string `protobuf:"bytes,14,opt,name=source,proto3" json:"source,omitempty"`
InstallChannel string `protobuf:"bytes,15,opt,name=install_channel,json=installChannel,proto3" json:"install_channel,omitempty"`
Campaign string `protobuf:"bytes,16,opt,name=campaign,proto3" json:"campaign,omitempty"`
Platform string `protobuf:"bytes,17,opt,name=platform,proto3" json:"platform,omitempty"`
Language string `protobuf:"bytes,18,opt,name=language,proto3" json:"language,omitempty"`
Timezone string `protobuf:"bytes,19,opt,name=timezone,proto3" json:"timezone,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *QuickCreateAccountRequest) Reset() {
*x = QuickCreateAccountRequest{}
mi := &file_proto_user_v1_auth_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *QuickCreateAccountRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*QuickCreateAccountRequest) ProtoMessage() {}
func (x *QuickCreateAccountRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use QuickCreateAccountRequest.ProtoReflect.Descriptor instead.
func (*QuickCreateAccountRequest) Descriptor() ([]byte, []int) {
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{5}
}
func (x *QuickCreateAccountRequest) GetMeta() *RequestMeta {
if x != nil {
return x.Meta
}
return nil
}
func (x *QuickCreateAccountRequest) GetPassword() string {
if x != nil {
return x.Password
}
return ""
}
func (x *QuickCreateAccountRequest) GetUsername() string {
if x != nil {
return x.Username
}
return ""
}
func (x *QuickCreateAccountRequest) GetAvatar() string {
if x != nil {
return x.Avatar
}
return ""
}
func (x *QuickCreateAccountRequest) GetGender() string {
if x != nil {
return x.Gender
}
return ""
}
func (x *QuickCreateAccountRequest) GetCountry() string {
if x != nil {
return x.Country
}
return ""
}
func (x *QuickCreateAccountRequest) GetInviteCode() string {
if x != nil {
return x.InviteCode
}
return ""
}
func (x *QuickCreateAccountRequest) GetDeviceId() string {
if x != nil {
return x.DeviceId
}
return ""
}
func (x *QuickCreateAccountRequest) GetDevice() string {
if x != nil {
return x.Device
}
return ""
}
func (x *QuickCreateAccountRequest) GetOsVersion() string {
if x != nil {
return x.OsVersion
}
return ""
}
func (x *QuickCreateAccountRequest) GetBirth() string {
if x != nil {
return x.Birth
}
return ""
}
func (x *QuickCreateAccountRequest) GetAppVersion() string {
if x != nil {
return x.AppVersion
}
return ""
}
func (x *QuickCreateAccountRequest) GetBuildNumber() string {
if x != nil {
return x.BuildNumber
}
return ""
}
func (x *QuickCreateAccountRequest) GetSource() string {
if x != nil {
return x.Source
}
return ""
}
func (x *QuickCreateAccountRequest) GetInstallChannel() string {
if x != nil {
return x.InstallChannel
}
return ""
}
func (x *QuickCreateAccountRequest) GetCampaign() string {
if x != nil {
return x.Campaign
}
return ""
}
func (x *QuickCreateAccountRequest) GetPlatform() string {
if x != nil {
return x.Platform
}
return ""
}
func (x *QuickCreateAccountRequest) GetLanguage() string {
if x != nil {
return x.Language
}
return ""
}
func (x *QuickCreateAccountRequest) GetTimezone() string {
if x != nil {
return x.Timezone
}
return ""
}
// QuickCreateAccountResponse 返回新账号登录态display_user_id 即后续账号密码登录用账号。
type QuickCreateAccountResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Token *AuthToken `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"`
PasswordSet bool `protobuf:"varint,2,opt,name=password_set,json=passwordSet,proto3" json:"password_set,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *QuickCreateAccountResponse) Reset() {
*x = QuickCreateAccountResponse{}
mi := &file_proto_user_v1_auth_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *QuickCreateAccountResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*QuickCreateAccountResponse) ProtoMessage() {}
func (x *QuickCreateAccountResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use QuickCreateAccountResponse.ProtoReflect.Descriptor instead.
func (*QuickCreateAccountResponse) Descriptor() ([]byte, []int) {
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{6}
}
func (x *QuickCreateAccountResponse) GetToken() *AuthToken {
if x != nil {
return x.Token
}
return nil
}
func (x *QuickCreateAccountResponse) GetPasswordSet() bool {
if x != nil {
return x.PasswordSet
}
return false
}
// RefreshTokenRequest 使用 refresh token 换取新 token。
type RefreshTokenRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -465,7 +707,7 @@ type RefreshTokenRequest struct {
func (x *RefreshTokenRequest) Reset() {
*x = RefreshTokenRequest{}
mi := &file_proto_user_v1_auth_proto_msgTypes[5]
mi := &file_proto_user_v1_auth_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -477,7 +719,7 @@ func (x *RefreshTokenRequest) String() string {
func (*RefreshTokenRequest) ProtoMessage() {}
func (x *RefreshTokenRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[5]
mi := &file_proto_user_v1_auth_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -490,7 +732,7 @@ func (x *RefreshTokenRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RefreshTokenRequest.ProtoReflect.Descriptor instead.
func (*RefreshTokenRequest) Descriptor() ([]byte, []int) {
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{5}
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{7}
}
func (x *RefreshTokenRequest) GetMeta() *RequestMeta {
@ -517,7 +759,7 @@ type RefreshTokenResponse struct {
func (x *RefreshTokenResponse) Reset() {
*x = RefreshTokenResponse{}
mi := &file_proto_user_v1_auth_proto_msgTypes[6]
mi := &file_proto_user_v1_auth_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -529,7 +771,7 @@ func (x *RefreshTokenResponse) String() string {
func (*RefreshTokenResponse) ProtoMessage() {}
func (x *RefreshTokenResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[6]
mi := &file_proto_user_v1_auth_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -542,7 +784,7 @@ func (x *RefreshTokenResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use RefreshTokenResponse.ProtoReflect.Descriptor instead.
func (*RefreshTokenResponse) Descriptor() ([]byte, []int) {
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{6}
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{8}
}
func (x *RefreshTokenResponse) GetToken() *AuthToken {
@ -564,7 +806,7 @@ type LogoutRequest struct {
func (x *LogoutRequest) Reset() {
*x = LogoutRequest{}
mi := &file_proto_user_v1_auth_proto_msgTypes[7]
mi := &file_proto_user_v1_auth_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -576,7 +818,7 @@ func (x *LogoutRequest) String() string {
func (*LogoutRequest) ProtoMessage() {}
func (x *LogoutRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[7]
mi := &file_proto_user_v1_auth_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -589,7 +831,7 @@ func (x *LogoutRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead.
func (*LogoutRequest) Descriptor() ([]byte, []int) {
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{7}
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{9}
}
func (x *LogoutRequest) GetMeta() *RequestMeta {
@ -623,7 +865,7 @@ type LogoutResponse struct {
func (x *LogoutResponse) Reset() {
*x = LogoutResponse{}
mi := &file_proto_user_v1_auth_proto_msgTypes[8]
mi := &file_proto_user_v1_auth_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -635,7 +877,7 @@ func (x *LogoutResponse) String() string {
func (*LogoutResponse) ProtoMessage() {}
func (x *LogoutResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[8]
mi := &file_proto_user_v1_auth_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -648,7 +890,7 @@ func (x *LogoutResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead.
func (*LogoutResponse) Descriptor() ([]byte, []int) {
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{8}
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{10}
}
func (x *LogoutResponse) GetRevoked() bool {
@ -673,7 +915,7 @@ type RecordLoginBlockedRequest struct {
func (x *RecordLoginBlockedRequest) Reset() {
*x = RecordLoginBlockedRequest{}
mi := &file_proto_user_v1_auth_proto_msgTypes[9]
mi := &file_proto_user_v1_auth_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -685,7 +927,7 @@ func (x *RecordLoginBlockedRequest) String() string {
func (*RecordLoginBlockedRequest) ProtoMessage() {}
func (x *RecordLoginBlockedRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[9]
mi := &file_proto_user_v1_auth_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -698,7 +940,7 @@ func (x *RecordLoginBlockedRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RecordLoginBlockedRequest.ProtoReflect.Descriptor instead.
func (*RecordLoginBlockedRequest) Descriptor() ([]byte, []int) {
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{9}
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{11}
}
func (x *RecordLoginBlockedRequest) GetMeta() *RequestMeta {
@ -753,7 +995,7 @@ type RecordLoginBlockedResponse struct {
func (x *RecordLoginBlockedResponse) Reset() {
*x = RecordLoginBlockedResponse{}
mi := &file_proto_user_v1_auth_proto_msgTypes[10]
mi := &file_proto_user_v1_auth_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -765,7 +1007,7 @@ func (x *RecordLoginBlockedResponse) String() string {
func (*RecordLoginBlockedResponse) ProtoMessage() {}
func (x *RecordLoginBlockedResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[10]
mi := &file_proto_user_v1_auth_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -778,7 +1020,7 @@ func (x *RecordLoginBlockedResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use RecordLoginBlockedResponse.ProtoReflect.Descriptor instead.
func (*RecordLoginBlockedResponse) Descriptor() ([]byte, []int) {
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{10}
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{12}
}
func (x *RecordLoginBlockedResponse) GetRecorded() bool {
@ -834,7 +1076,34 @@ const file_proto_user_v1_auth_proto_rawDesc = "" +
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x1a\n" +
"\bpassword\x18\x03 \x01(\tR\bpassword\"8\n" +
"\x13SetPasswordResponse\x12!\n" +
"\fpassword_set\x18\x01 \x01(\bR\vpasswordSet\"j\n" +
"\fpassword_set\x18\x01 \x01(\bR\vpasswordSet\"\xcd\x04\n" +
"\x19QuickCreateAccountRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12\x1a\n" +
"\bpassword\x18\x02 \x01(\tR\bpassword\x12\x1a\n" +
"\busername\x18\x03 \x01(\tR\busername\x12\x16\n" +
"\x06avatar\x18\x04 \x01(\tR\x06avatar\x12\x16\n" +
"\x06gender\x18\x05 \x01(\tR\x06gender\x12\x18\n" +
"\acountry\x18\x06 \x01(\tR\acountry\x12\x1f\n" +
"\vinvite_code\x18\a \x01(\tR\n" +
"inviteCode\x12\x1b\n" +
"\tdevice_id\x18\b \x01(\tR\bdeviceId\x12\x16\n" +
"\x06device\x18\t \x01(\tR\x06device\x12\x1d\n" +
"\n" +
"os_version\x18\n" +
" \x01(\tR\tosVersion\x12\x14\n" +
"\x05birth\x18\v \x01(\tR\x05birth\x12\x1f\n" +
"\vapp_version\x18\f \x01(\tR\n" +
"appVersion\x12!\n" +
"\fbuild_number\x18\r \x01(\tR\vbuildNumber\x12\x16\n" +
"\x06source\x18\x0e \x01(\tR\x06source\x12'\n" +
"\x0finstall_channel\x18\x0f \x01(\tR\x0einstallChannel\x12\x1a\n" +
"\bcampaign\x18\x10 \x01(\tR\bcampaign\x12\x1a\n" +
"\bplatform\x18\x11 \x01(\tR\bplatform\x12\x1a\n" +
"\blanguage\x18\x12 \x01(\tR\blanguage\x12\x1a\n" +
"\btimezone\x18\x13 \x01(\tR\btimezone\"o\n" +
"\x1aQuickCreateAccountResponse\x12.\n" +
"\x05token\x18\x01 \x01(\v2\x18.hyapp.user.v1.AuthTokenR\x05token\x12!\n" +
"\fpassword_set\x18\x02 \x01(\bR\vpasswordSet\"j\n" +
"\x13RefreshTokenRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12#\n" +
"\rrefresh_token\x18\x02 \x01(\tR\frefreshToken\"F\n" +
@ -855,11 +1124,12 @@ const file_proto_user_v1_auth_proto_rawDesc = "" +
"\btimezone\x18\x05 \x01(\tR\btimezone\x12!\n" +
"\fblock_reason\x18\x06 \x01(\tR\vblockReason\"8\n" +
"\x1aRecordLoginBlockedResponse\x12\x1a\n" +
"\brecorded\x18\x01 \x01(\bR\brecorded2\x98\x04\n" +
"\brecorded\x18\x01 \x01(\bR\brecorded2\x83\x05\n" +
"\vAuthService\x12Q\n" +
"\rLoginPassword\x12#.hyapp.user.v1.LoginPasswordRequest\x1a\x1b.hyapp.user.v1.AuthResponse\x12U\n" +
"\x0fLoginThirdParty\x12%.hyapp.user.v1.LoginThirdPartyRequest\x1a\x1b.hyapp.user.v1.AuthResponse\x12T\n" +
"\vSetPassword\x12!.hyapp.user.v1.SetPasswordRequest\x1a\".hyapp.user.v1.SetPasswordResponse\x12W\n" +
"\vSetPassword\x12!.hyapp.user.v1.SetPasswordRequest\x1a\".hyapp.user.v1.SetPasswordResponse\x12i\n" +
"\x12QuickCreateAccount\x12(.hyapp.user.v1.QuickCreateAccountRequest\x1a).hyapp.user.v1.QuickCreateAccountResponse\x12W\n" +
"\fRefreshToken\x12\".hyapp.user.v1.RefreshTokenRequest\x1a#.hyapp.user.v1.RefreshTokenResponse\x12E\n" +
"\x06Logout\x12\x1c.hyapp.user.v1.LogoutRequest\x1a\x1d.hyapp.user.v1.LogoutResponse\x12i\n" +
"\x12RecordLoginBlocked\x12(.hyapp.user.v1.RecordLoginBlockedRequest\x1a).hyapp.user.v1.RecordLoginBlockedResponseB&Z$hyapp.local/api/proto/user/v1;userv1b\x06proto3"
@ -876,48 +1146,54 @@ func file_proto_user_v1_auth_proto_rawDescGZIP() []byte {
return file_proto_user_v1_auth_proto_rawDescData
}
var file_proto_user_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
var file_proto_user_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
var file_proto_user_v1_auth_proto_goTypes = []any{
(*LoginPasswordRequest)(nil), // 0: hyapp.user.v1.LoginPasswordRequest
(*LoginThirdPartyRequest)(nil), // 1: hyapp.user.v1.LoginThirdPartyRequest
(*AuthResponse)(nil), // 2: hyapp.user.v1.AuthResponse
(*SetPasswordRequest)(nil), // 3: hyapp.user.v1.SetPasswordRequest
(*SetPasswordResponse)(nil), // 4: hyapp.user.v1.SetPasswordResponse
(*RefreshTokenRequest)(nil), // 5: hyapp.user.v1.RefreshTokenRequest
(*RefreshTokenResponse)(nil), // 6: hyapp.user.v1.RefreshTokenResponse
(*LogoutRequest)(nil), // 7: hyapp.user.v1.LogoutRequest
(*LogoutResponse)(nil), // 8: hyapp.user.v1.LogoutResponse
(*RecordLoginBlockedRequest)(nil), // 9: hyapp.user.v1.RecordLoginBlockedRequest
(*RecordLoginBlockedResponse)(nil), // 10: hyapp.user.v1.RecordLoginBlockedResponse
(*RequestMeta)(nil), // 11: hyapp.user.v1.RequestMeta
(*AuthToken)(nil), // 12: hyapp.user.v1.AuthToken
(*QuickCreateAccountRequest)(nil), // 5: hyapp.user.v1.QuickCreateAccountRequest
(*QuickCreateAccountResponse)(nil), // 6: hyapp.user.v1.QuickCreateAccountResponse
(*RefreshTokenRequest)(nil), // 7: hyapp.user.v1.RefreshTokenRequest
(*RefreshTokenResponse)(nil), // 8: hyapp.user.v1.RefreshTokenResponse
(*LogoutRequest)(nil), // 9: hyapp.user.v1.LogoutRequest
(*LogoutResponse)(nil), // 10: hyapp.user.v1.LogoutResponse
(*RecordLoginBlockedRequest)(nil), // 11: hyapp.user.v1.RecordLoginBlockedRequest
(*RecordLoginBlockedResponse)(nil), // 12: hyapp.user.v1.RecordLoginBlockedResponse
(*RequestMeta)(nil), // 13: hyapp.user.v1.RequestMeta
(*AuthToken)(nil), // 14: hyapp.user.v1.AuthToken
}
var file_proto_user_v1_auth_proto_depIdxs = []int32{
11, // 0: hyapp.user.v1.LoginPasswordRequest.meta:type_name -> hyapp.user.v1.RequestMeta
11, // 1: hyapp.user.v1.LoginThirdPartyRequest.meta:type_name -> hyapp.user.v1.RequestMeta
12, // 2: hyapp.user.v1.AuthResponse.token:type_name -> hyapp.user.v1.AuthToken
11, // 3: hyapp.user.v1.SetPasswordRequest.meta:type_name -> hyapp.user.v1.RequestMeta
11, // 4: hyapp.user.v1.RefreshTokenRequest.meta:type_name -> hyapp.user.v1.RequestMeta
12, // 5: hyapp.user.v1.RefreshTokenResponse.token:type_name -> hyapp.user.v1.AuthToken
11, // 6: hyapp.user.v1.LogoutRequest.meta:type_name -> hyapp.user.v1.RequestMeta
11, // 7: hyapp.user.v1.RecordLoginBlockedRequest.meta:type_name -> hyapp.user.v1.RequestMeta
0, // 8: hyapp.user.v1.AuthService.LoginPassword:input_type -> hyapp.user.v1.LoginPasswordRequest
1, // 9: hyapp.user.v1.AuthService.LoginThirdParty:input_type -> hyapp.user.v1.LoginThirdPartyRequest
3, // 10: hyapp.user.v1.AuthService.SetPassword:input_type -> hyapp.user.v1.SetPasswordRequest
5, // 11: hyapp.user.v1.AuthService.RefreshToken:input_type -> hyapp.user.v1.RefreshTokenRequest
7, // 12: hyapp.user.v1.AuthService.Logout:input_type -> hyapp.user.v1.LogoutRequest
9, // 13: hyapp.user.v1.AuthService.RecordLoginBlocked:input_type -> hyapp.user.v1.RecordLoginBlockedRequest
2, // 14: hyapp.user.v1.AuthService.LoginPassword:output_type -> hyapp.user.v1.AuthResponse
2, // 15: hyapp.user.v1.AuthService.LoginThirdParty:output_type -> hyapp.user.v1.AuthResponse
4, // 16: hyapp.user.v1.AuthService.SetPassword:output_type -> hyapp.user.v1.SetPasswordResponse
6, // 17: hyapp.user.v1.AuthService.RefreshToken:output_type -> hyapp.user.v1.RefreshTokenResponse
8, // 18: hyapp.user.v1.AuthService.Logout:output_type -> hyapp.user.v1.LogoutResponse
10, // 19: hyapp.user.v1.AuthService.RecordLoginBlocked:output_type -> hyapp.user.v1.RecordLoginBlockedResponse
14, // [14:20] is the sub-list for method output_type
8, // [8:14] is the sub-list for method input_type
8, // [8:8] is the sub-list for extension type_name
8, // [8:8] is the sub-list for extension extendee
0, // [0:8] is the sub-list for field type_name
13, // 0: hyapp.user.v1.LoginPasswordRequest.meta:type_name -> hyapp.user.v1.RequestMeta
13, // 1: hyapp.user.v1.LoginThirdPartyRequest.meta:type_name -> hyapp.user.v1.RequestMeta
14, // 2: hyapp.user.v1.AuthResponse.token:type_name -> hyapp.user.v1.AuthToken
13, // 3: hyapp.user.v1.SetPasswordRequest.meta:type_name -> hyapp.user.v1.RequestMeta
13, // 4: hyapp.user.v1.QuickCreateAccountRequest.meta:type_name -> hyapp.user.v1.RequestMeta
14, // 5: hyapp.user.v1.QuickCreateAccountResponse.token:type_name -> hyapp.user.v1.AuthToken
13, // 6: hyapp.user.v1.RefreshTokenRequest.meta:type_name -> hyapp.user.v1.RequestMeta
14, // 7: hyapp.user.v1.RefreshTokenResponse.token:type_name -> hyapp.user.v1.AuthToken
13, // 8: hyapp.user.v1.LogoutRequest.meta:type_name -> hyapp.user.v1.RequestMeta
13, // 9: hyapp.user.v1.RecordLoginBlockedRequest.meta:type_name -> hyapp.user.v1.RequestMeta
0, // 10: hyapp.user.v1.AuthService.LoginPassword:input_type -> hyapp.user.v1.LoginPasswordRequest
1, // 11: hyapp.user.v1.AuthService.LoginThirdParty:input_type -> hyapp.user.v1.LoginThirdPartyRequest
3, // 12: hyapp.user.v1.AuthService.SetPassword:input_type -> hyapp.user.v1.SetPasswordRequest
5, // 13: hyapp.user.v1.AuthService.QuickCreateAccount:input_type -> hyapp.user.v1.QuickCreateAccountRequest
7, // 14: hyapp.user.v1.AuthService.RefreshToken:input_type -> hyapp.user.v1.RefreshTokenRequest
9, // 15: hyapp.user.v1.AuthService.Logout:input_type -> hyapp.user.v1.LogoutRequest
11, // 16: hyapp.user.v1.AuthService.RecordLoginBlocked:input_type -> hyapp.user.v1.RecordLoginBlockedRequest
2, // 17: hyapp.user.v1.AuthService.LoginPassword:output_type -> hyapp.user.v1.AuthResponse
2, // 18: hyapp.user.v1.AuthService.LoginThirdParty:output_type -> hyapp.user.v1.AuthResponse
4, // 19: hyapp.user.v1.AuthService.SetPassword:output_type -> hyapp.user.v1.SetPasswordResponse
6, // 20: hyapp.user.v1.AuthService.QuickCreateAccount:output_type -> hyapp.user.v1.QuickCreateAccountResponse
8, // 21: hyapp.user.v1.AuthService.RefreshToken:output_type -> hyapp.user.v1.RefreshTokenResponse
10, // 22: hyapp.user.v1.AuthService.Logout:output_type -> hyapp.user.v1.LogoutResponse
12, // 23: hyapp.user.v1.AuthService.RecordLoginBlocked:output_type -> hyapp.user.v1.RecordLoginBlockedResponse
17, // [17:24] is the sub-list for method output_type
10, // [10:17] is the sub-list for method input_type
10, // [10:10] is the sub-list for extension type_name
10, // [10:10] is the sub-list for extension extendee
0, // [0:10] is the sub-list for field type_name
}
func init() { file_proto_user_v1_auth_proto_init() }
@ -932,7 +1208,7 @@ func file_proto_user_v1_auth_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_user_v1_auth_proto_rawDesc), len(file_proto_user_v1_auth_proto_rawDesc)),
NumEnums: 0,
NumMessages: 11,
NumMessages: 13,
NumExtensions: 0,
NumServices: 1,
},

View File

@ -57,6 +57,35 @@ message SetPasswordResponse {
bool password_set = 1;
}
// QuickCreateAccountRequest
message QuickCreateAccountRequest {
RequestMeta meta = 1;
string password = 2;
string username = 3;
string avatar = 4;
string gender = 5;
string country = 6;
string invite_code = 7;
string device_id = 8;
string device = 9;
string os_version = 10;
string birth = 11;
string app_version = 12;
string build_number = 13;
string source = 14;
string install_channel = 15;
string campaign = 16;
string platform = 17;
string language = 18;
string timezone = 19;
}
// QuickCreateAccountResponse display_user_id
message QuickCreateAccountResponse {
AuthToken token = 1;
bool password_set = 2;
}
// RefreshTokenRequest 使 refresh token token
message RefreshTokenRequest {
RequestMeta meta = 1;
@ -100,6 +129,7 @@ service AuthService {
rpc LoginPassword(LoginPasswordRequest) returns (AuthResponse);
rpc LoginThirdParty(LoginThirdPartyRequest) returns (AuthResponse);
rpc SetPassword(SetPasswordRequest) returns (SetPasswordResponse);
rpc QuickCreateAccount(QuickCreateAccountRequest) returns (QuickCreateAccountResponse);
rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse);
rpc Logout(LogoutRequest) returns (LogoutResponse);
rpc RecordLoginBlocked(RecordLoginBlockedRequest) returns (RecordLoginBlockedResponse);

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v5.28.3
// - protoc v7.35.0
// source: proto/user/v1/auth.proto
package userv1
@ -22,6 +22,7 @@ const (
AuthService_LoginPassword_FullMethodName = "/hyapp.user.v1.AuthService/LoginPassword"
AuthService_LoginThirdParty_FullMethodName = "/hyapp.user.v1.AuthService/LoginThirdParty"
AuthService_SetPassword_FullMethodName = "/hyapp.user.v1.AuthService/SetPassword"
AuthService_QuickCreateAccount_FullMethodName = "/hyapp.user.v1.AuthService/QuickCreateAccount"
AuthService_RefreshToken_FullMethodName = "/hyapp.user.v1.AuthService/RefreshToken"
AuthService_Logout_FullMethodName = "/hyapp.user.v1.AuthService/Logout"
AuthService_RecordLoginBlocked_FullMethodName = "/hyapp.user.v1.AuthService/RecordLoginBlocked"
@ -36,6 +37,7 @@ type AuthServiceClient interface {
LoginPassword(ctx context.Context, in *LoginPasswordRequest, opts ...grpc.CallOption) (*AuthResponse, error)
LoginThirdParty(ctx context.Context, in *LoginThirdPartyRequest, opts ...grpc.CallOption) (*AuthResponse, error)
SetPassword(ctx context.Context, in *SetPasswordRequest, opts ...grpc.CallOption) (*SetPasswordResponse, error)
QuickCreateAccount(ctx context.Context, in *QuickCreateAccountRequest, opts ...grpc.CallOption) (*QuickCreateAccountResponse, error)
RefreshToken(ctx context.Context, in *RefreshTokenRequest, opts ...grpc.CallOption) (*RefreshTokenResponse, error)
Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error)
RecordLoginBlocked(ctx context.Context, in *RecordLoginBlockedRequest, opts ...grpc.CallOption) (*RecordLoginBlockedResponse, error)
@ -79,6 +81,16 @@ func (c *authServiceClient) SetPassword(ctx context.Context, in *SetPasswordRequ
return out, nil
}
func (c *authServiceClient) QuickCreateAccount(ctx context.Context, in *QuickCreateAccountRequest, opts ...grpc.CallOption) (*QuickCreateAccountResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(QuickCreateAccountResponse)
err := c.cc.Invoke(ctx, AuthService_QuickCreateAccount_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *authServiceClient) RefreshToken(ctx context.Context, in *RefreshTokenRequest, opts ...grpc.CallOption) (*RefreshTokenResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RefreshTokenResponse)
@ -118,6 +130,7 @@ type AuthServiceServer interface {
LoginPassword(context.Context, *LoginPasswordRequest) (*AuthResponse, error)
LoginThirdParty(context.Context, *LoginThirdPartyRequest) (*AuthResponse, error)
SetPassword(context.Context, *SetPasswordRequest) (*SetPasswordResponse, error)
QuickCreateAccount(context.Context, *QuickCreateAccountRequest) (*QuickCreateAccountResponse, error)
RefreshToken(context.Context, *RefreshTokenRequest) (*RefreshTokenResponse, error)
Logout(context.Context, *LogoutRequest) (*LogoutResponse, error)
RecordLoginBlocked(context.Context, *RecordLoginBlockedRequest) (*RecordLoginBlockedResponse, error)
@ -140,6 +153,9 @@ func (UnimplementedAuthServiceServer) LoginThirdParty(context.Context, *LoginThi
func (UnimplementedAuthServiceServer) SetPassword(context.Context, *SetPasswordRequest) (*SetPasswordResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetPassword not implemented")
}
func (UnimplementedAuthServiceServer) QuickCreateAccount(context.Context, *QuickCreateAccountRequest) (*QuickCreateAccountResponse, error) {
return nil, status.Error(codes.Unimplemented, "method QuickCreateAccount not implemented")
}
func (UnimplementedAuthServiceServer) RefreshToken(context.Context, *RefreshTokenRequest) (*RefreshTokenResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RefreshToken not implemented")
}
@ -224,6 +240,24 @@ func _AuthService_SetPassword_Handler(srv interface{}, ctx context.Context, dec
return interceptor(ctx, in, info, handler)
}
func _AuthService_QuickCreateAccount_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(QuickCreateAccountRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AuthServiceServer).QuickCreateAccount(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AuthService_QuickCreateAccount_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AuthServiceServer).QuickCreateAccount(ctx, req.(*QuickCreateAccountRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AuthService_RefreshToken_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RefreshTokenRequest)
if err := dec(in); err != nil {
@ -297,6 +331,10 @@ var AuthService_ServiceDesc = grpc.ServiceDesc{
MethodName: "SetPassword",
Handler: _AuthService_SetPassword_Handler,
},
{
MethodName: "QuickCreateAccount",
Handler: _AuthService_QuickCreateAccount_Handler,
},
{
MethodName: "RefreshToken",
Handler: _AuthService_RefreshToken_Handler,

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.28.3
// protoc v7.35.0
// source: proto/user/v1/host.proto
package userv1
@ -442,7 +442,7 @@ func (x *CoinSellerProfile) GetUpdatedAtMs() int64 {
}
// UserRoleSummary 是 App 我的页和入口显隐使用的轻量角色摘要。
// 它把 host、Agency、BD 和币商身份收敛到一次 user-service RPC避免 gateway 随角色扩张继续 fanout。
// 它把 host、Agency、manager、BD 和币商身份收敛到一次 user-service RPC避免 gateway 随角色扩张继续 fanout。
type UserRoleSummary struct {
state protoimpl.MessageState `protogen:"open.v1"`
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
@ -457,6 +457,7 @@ type UserRoleSummary struct {
BdStatus string `protobuf:"bytes,10,opt,name=bd_status,json=bdStatus,proto3" json:"bd_status,omitempty"`
CoinSellerStatus string `protobuf:"bytes,11,opt,name=coin_seller_status,json=coinSellerStatus,proto3" json:"coin_seller_status,omitempty"`
PendingRoleInvitations int64 `protobuf:"varint,12,opt,name=pending_role_invitations,json=pendingRoleInvitations,proto3" json:"pending_role_invitations,omitempty"`
IsManager bool `protobuf:"varint,13,opt,name=is_manager,json=isManager,proto3" json:"is_manager,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -575,6 +576,13 @@ func (x *UserRoleSummary) GetPendingRoleInvitations() int64 {
return 0
}
func (x *UserRoleSummary) GetIsManager() bool {
if x != nil {
return x.IsManager
}
return false
}
// AgencyMembership 是 host 与 Agency 的有时效关系事实。
type AgencyMembership struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -3689,7 +3697,7 @@ const file_proto_user_v1_host_proto_rawDesc = "" +
"\x13merchant_asset_type\x18\x03 \x01(\tR\x11merchantAssetType\x12+\n" +
"\x12created_by_user_id\x18\x04 \x01(\x03R\x0fcreatedByUserId\x12\"\n" +
"\rcreated_at_ms\x18\x05 \x01(\x03R\vcreatedAtMs\x12\"\n" +
"\rupdated_at_ms\x18\x06 \x01(\x03R\vupdatedAtMs\"\x95\x03\n" +
"\rupdated_at_ms\x18\x06 \x01(\x03R\vupdatedAtMs\"\xb4\x03\n" +
"\x0fUserRoleSummary\x12\x17\n" +
"\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x17\n" +
"\ais_host\x18\x02 \x01(\bR\x06isHost\x12\x1b\n" +
@ -3705,7 +3713,9 @@ const file_proto_user_v1_host_proto_rawDesc = "" +
"\tbd_status\x18\n" +
" \x01(\tR\bbdStatus\x12,\n" +
"\x12coin_seller_status\x18\v \x01(\tR\x10coinSellerStatus\x128\n" +
"\x18pending_role_invitations\x18\f \x01(\x03R\x16pendingRoleInvitations\"\xaa\x03\n" +
"\x18pending_role_invitations\x18\f \x01(\x03R\x16pendingRoleInvitations\x12\x1d\n" +
"\n" +
"is_manager\x18\r \x01(\bR\tisManager\"\xaa\x03\n" +
"\x10AgencyMembership\x12#\n" +
"\rmembership_id\x18\x01 \x01(\x03R\fmembershipId\x12\x1b\n" +
"\tagency_id\x18\x02 \x01(\x03R\bagencyId\x12 \n" +

View File

@ -57,7 +57,7 @@ message CoinSellerProfile {
}
// UserRoleSummary App 使
// hostAgencyBD user-service RPC gateway fanout
// hostAgencymanagerBD user-service RPC gateway fanout
message UserRoleSummary {
int64 user_id = 1;
bool is_host = 2;
@ -71,6 +71,7 @@ message UserRoleSummary {
string bd_status = 10;
string coin_seller_status = 11;
int64 pending_role_invitations = 12;
bool is_manager = 13;
}
// AgencyMembership host Agency

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v5.28.3
// - protoc v7.35.0
// source: proto/user/v1/host.proto
package userv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.28.3
// protoc v7.35.0
// source: proto/user/v1/user.proto
package userv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v5.28.3
// - protoc v7.35.0
// source: proto/user/v1/user.proto
package userv1

File diff suppressed because it is too large Load Diff

View File

@ -149,6 +149,9 @@ message Resource {
int64 created_at_ms = 19;
int64 updated_at_ms = 20;
bool manager_grant_enabled = 21;
string price_type = 22;
int64 coin_price = 23;
int64 gift_point_amount = 24;
}
message ResourceGroupItem {
@ -320,6 +323,9 @@ message CreateResourceRequest {
int32 sort_order = 16;
int64 operator_user_id = 17;
optional bool manager_grant_enabled = 18;
string price_type = 19;
int64 coin_price = 20;
int64 gift_point_amount = 21;
}
message UpdateResourceRequest {
@ -342,6 +348,9 @@ message UpdateResourceRequest {
int32 sort_order = 17;
int64 operator_user_id = 18;
optional bool manager_grant_enabled = 19;
string price_type = 20;
int64 coin_price = 21;
int64 gift_point_amount = 22;
}
message SetResourceStatusRequest {
@ -889,6 +898,12 @@ message VipLevel {
repeated VipRewardItem reward_items = 7;
bool can_purchase = 8;
int32 sort_order = 9;
bool recharge_gate_required = 10;
int64 required_recharge_coin_amount = 11;
int64 user_recharge_coin_amount = 12;
string purchase_locked_reason = 13;
int64 created_at_ms = 14;
int64 updated_at_ms = 15;
}
message UserVip {
@ -938,6 +953,56 @@ message PurchaseVipResponse {
repeated VipRewardItem reward_items = 6;
}
message GrantVipRequest {
string command_id = 1;
string app_code = 2;
int64 target_user_id = 3;
int32 level = 4;
string grant_source = 5;
int64 operator_user_id = 6;
string reason = 7;
}
message GrantVipResponse {
string transaction_id = 1;
UserVip vip = 2;
repeated VipRewardItem reward_items = 3;
int64 server_time_ms = 4;
}
message AdminVipLevelInput {
int32 level = 1;
string name = 2;
string status = 3;
int64 price_coin = 4;
int64 duration_ms = 5;
int64 reward_resource_group_id = 6;
int32 sort_order = 7;
int64 required_recharge_coin_amount = 8;
}
message ListAdminVipLevelsRequest {
string request_id = 1;
string app_code = 2;
}
message ListAdminVipLevelsResponse {
repeated VipLevel levels = 1;
int64 server_time_ms = 2;
}
message UpdateAdminVipLevelsRequest {
string request_id = 1;
string app_code = 2;
repeated AdminVipLevelInput levels = 3;
int64 operator_user_id = 4;
}
message UpdateAdminVipLevelsResponse {
repeated VipLevel levels = 1;
int64 server_time_ms = 2;
}
// CreditTaskRewardRequest activity-service
message CreditTaskRewardRequest {
string command_id = 1;
@ -981,6 +1046,160 @@ message ApplyGameCoinChangeResponse {
bool idempotent_replay = 3;
}
message RedPacketConfig {
string app_code = 1;
bool enabled = 2;
repeated int32 count_tiers = 3;
repeated int64 amount_tiers = 4;
int32 delayed_open_seconds = 5;
int32 expire_seconds = 6;
int32 daily_send_limit = 7;
int64 updated_by_admin_id = 8;
int64 created_at_ms = 9;
int64 updated_at_ms = 10;
}
message RedPacketClaim {
string app_code = 1;
string claim_id = 2;
string command_id = 3;
string packet_id = 4;
int64 user_id = 5;
int64 amount = 6;
string wallet_transaction_id = 7;
string status = 8;
string failure_reason = 9;
int64 created_at_ms = 10;
int64 updated_at_ms = 11;
}
message RedPacket {
string app_code = 1;
string packet_id = 2;
string command_id = 3;
int64 sender_user_id = 4;
string room_id = 5;
int64 region_id = 6;
string packet_type = 7;
int64 total_amount = 8;
int32 packet_count = 9;
int64 remaining_amount = 10;
int32 remaining_count = 11;
string status = 12;
int64 open_at_ms = 13;
int64 expires_at_ms = 14;
int64 created_at_ms = 15;
int64 updated_at_ms = 16;
bool claimed_by_viewer = 17;
int64 viewer_claim_amount = 18;
repeated RedPacketClaim claims = 19;
}
message GetRedPacketConfigRequest {
string request_id = 1;
string app_code = 2;
}
message GetRedPacketConfigResponse {
RedPacketConfig config = 1;
int64 server_time_ms = 2;
}
message UpdateRedPacketConfigRequest {
string request_id = 1;
string app_code = 2;
bool enabled = 3;
repeated int32 count_tiers = 4;
repeated int64 amount_tiers = 5;
int32 delayed_open_seconds = 6;
int32 expire_seconds = 7;
int32 daily_send_limit = 8;
int64 operator_user_id = 9;
}
message UpdateRedPacketConfigResponse {
RedPacketConfig config = 1;
int64 server_time_ms = 2;
}
message CreateRedPacketRequest {
string request_id = 1;
string app_code = 2;
string command_id = 3;
int64 sender_user_id = 4;
string room_id = 5;
int64 region_id = 6;
string packet_type = 7;
int64 total_amount = 8;
int32 packet_count = 9;
}
message CreateRedPacketResponse {
RedPacket packet = 1;
int64 balance_after = 2;
int64 server_time_ms = 3;
}
message ClaimRedPacketRequest {
string request_id = 1;
string app_code = 2;
string command_id = 3;
string packet_id = 4;
int64 user_id = 5;
}
message ClaimRedPacketResponse {
RedPacketClaim claim = 1;
int64 balance_after = 2;
int64 server_time_ms = 3;
}
message ListRedPacketsRequest {
string request_id = 1;
string app_code = 2;
string room_id = 3;
int64 sender_user_id = 4;
int64 region_id = 5;
string packet_type = 6;
string status = 7;
int64 viewer_user_id = 8;
int64 start_at_ms = 9;
int64 end_at_ms = 10;
int32 page = 11;
int32 page_size = 12;
}
message ListRedPacketsResponse {
repeated RedPacket packets = 1;
int64 total = 2;
int64 server_time_ms = 3;
}
message GetRedPacketRequest {
string request_id = 1;
string app_code = 2;
string packet_id = 3;
int64 viewer_user_id = 4;
bool include_claims = 5;
}
message GetRedPacketResponse {
RedPacket packet = 1;
int64 server_time_ms = 2;
}
message ExpireRedPacketsRequest {
string request_id = 1;
string app_code = 2;
int32 batch_size = 3;
}
message ExpireRedPacketsResponse {
int32 expired_count = 1;
int64 refunded_amount = 2;
int64 server_time_ms = 3;
}
// WalletService gRPC HTTP gateway-service
service WalletService {
rpc DebitGift(DebitGiftRequest) returns (DebitGiftResponse);
@ -1024,6 +1243,16 @@ service WalletService {
rpc ListVipPackages(ListVipPackagesRequest) returns (ListVipPackagesResponse);
rpc GetMyVip(GetMyVipRequest) returns (GetMyVipResponse);
rpc PurchaseVip(PurchaseVipRequest) returns (PurchaseVipResponse);
rpc GrantVip(GrantVipRequest) returns (GrantVipResponse);
rpc ListAdminVipLevels(ListAdminVipLevelsRequest) returns (ListAdminVipLevelsResponse);
rpc UpdateAdminVipLevels(UpdateAdminVipLevelsRequest) returns (UpdateAdminVipLevelsResponse);
rpc CreditTaskReward(CreditTaskRewardRequest) returns (CreditTaskRewardResponse);
rpc ApplyGameCoinChange(ApplyGameCoinChangeRequest) returns (ApplyGameCoinChangeResponse);
rpc GetRedPacketConfig(GetRedPacketConfigRequest) returns (GetRedPacketConfigResponse);
rpc UpdateRedPacketConfig(UpdateRedPacketConfigRequest) returns (UpdateRedPacketConfigResponse);
rpc CreateRedPacket(CreateRedPacketRequest) returns (CreateRedPacketResponse);
rpc ClaimRedPacket(ClaimRedPacketRequest) returns (ClaimRedPacketResponse);
rpc ListRedPackets(ListRedPacketsRequest) returns (ListRedPacketsResponse);
rpc GetRedPacket(GetRedPacketRequest) returns (GetRedPacketResponse);
rpc ExpireRedPackets(ExpireRedPacketsRequest) returns (ExpireRedPacketsResponse);
}

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc (unknown)
// - protoc v7.35.0
// source: proto/wallet/v1/wallet.proto
package walletv1
@ -60,8 +60,18 @@ const (
WalletService_ListVipPackages_FullMethodName = "/hyapp.wallet.v1.WalletService/ListVipPackages"
WalletService_GetMyVip_FullMethodName = "/hyapp.wallet.v1.WalletService/GetMyVip"
WalletService_PurchaseVip_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseVip"
WalletService_GrantVip_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantVip"
WalletService_ListAdminVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/ListAdminVipLevels"
WalletService_UpdateAdminVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateAdminVipLevels"
WalletService_CreditTaskReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditTaskReward"
WalletService_ApplyGameCoinChange_FullMethodName = "/hyapp.wallet.v1.WalletService/ApplyGameCoinChange"
WalletService_GetRedPacketConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetRedPacketConfig"
WalletService_UpdateRedPacketConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateRedPacketConfig"
WalletService_CreateRedPacket_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateRedPacket"
WalletService_ClaimRedPacket_FullMethodName = "/hyapp.wallet.v1.WalletService/ClaimRedPacket"
WalletService_ListRedPackets_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRedPackets"
WalletService_GetRedPacket_FullMethodName = "/hyapp.wallet.v1.WalletService/GetRedPacket"
WalletService_ExpireRedPackets_FullMethodName = "/hyapp.wallet.v1.WalletService/ExpireRedPackets"
)
// WalletServiceClient is the client API for WalletService service.
@ -111,8 +121,18 @@ type WalletServiceClient interface {
ListVipPackages(ctx context.Context, in *ListVipPackagesRequest, opts ...grpc.CallOption) (*ListVipPackagesResponse, error)
GetMyVip(ctx context.Context, in *GetMyVipRequest, opts ...grpc.CallOption) (*GetMyVipResponse, error)
PurchaseVip(ctx context.Context, in *PurchaseVipRequest, opts ...grpc.CallOption) (*PurchaseVipResponse, error)
GrantVip(ctx context.Context, in *GrantVipRequest, opts ...grpc.CallOption) (*GrantVipResponse, error)
ListAdminVipLevels(ctx context.Context, in *ListAdminVipLevelsRequest, opts ...grpc.CallOption) (*ListAdminVipLevelsResponse, error)
UpdateAdminVipLevels(ctx context.Context, in *UpdateAdminVipLevelsRequest, opts ...grpc.CallOption) (*UpdateAdminVipLevelsResponse, error)
CreditTaskReward(ctx context.Context, in *CreditTaskRewardRequest, opts ...grpc.CallOption) (*CreditTaskRewardResponse, error)
ApplyGameCoinChange(ctx context.Context, in *ApplyGameCoinChangeRequest, opts ...grpc.CallOption) (*ApplyGameCoinChangeResponse, error)
GetRedPacketConfig(ctx context.Context, in *GetRedPacketConfigRequest, opts ...grpc.CallOption) (*GetRedPacketConfigResponse, error)
UpdateRedPacketConfig(ctx context.Context, in *UpdateRedPacketConfigRequest, opts ...grpc.CallOption) (*UpdateRedPacketConfigResponse, error)
CreateRedPacket(ctx context.Context, in *CreateRedPacketRequest, opts ...grpc.CallOption) (*CreateRedPacketResponse, error)
ClaimRedPacket(ctx context.Context, in *ClaimRedPacketRequest, opts ...grpc.CallOption) (*ClaimRedPacketResponse, error)
ListRedPackets(ctx context.Context, in *ListRedPacketsRequest, opts ...grpc.CallOption) (*ListRedPacketsResponse, error)
GetRedPacket(ctx context.Context, in *GetRedPacketRequest, opts ...grpc.CallOption) (*GetRedPacketResponse, error)
ExpireRedPackets(ctx context.Context, in *ExpireRedPacketsRequest, opts ...grpc.CallOption) (*ExpireRedPacketsResponse, error)
}
type walletServiceClient struct {
@ -533,6 +553,36 @@ func (c *walletServiceClient) PurchaseVip(ctx context.Context, in *PurchaseVipRe
return out, nil
}
func (c *walletServiceClient) GrantVip(ctx context.Context, in *GrantVipRequest, opts ...grpc.CallOption) (*GrantVipResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GrantVipResponse)
err := c.cc.Invoke(ctx, WalletService_GrantVip_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ListAdminVipLevels(ctx context.Context, in *ListAdminVipLevelsRequest, opts ...grpc.CallOption) (*ListAdminVipLevelsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListAdminVipLevelsResponse)
err := c.cc.Invoke(ctx, WalletService_ListAdminVipLevels_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) UpdateAdminVipLevels(ctx context.Context, in *UpdateAdminVipLevelsRequest, opts ...grpc.CallOption) (*UpdateAdminVipLevelsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpdateAdminVipLevelsResponse)
err := c.cc.Invoke(ctx, WalletService_UpdateAdminVipLevels_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) CreditTaskReward(ctx context.Context, in *CreditTaskRewardRequest, opts ...grpc.CallOption) (*CreditTaskRewardResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreditTaskRewardResponse)
@ -553,6 +603,76 @@ func (c *walletServiceClient) ApplyGameCoinChange(ctx context.Context, in *Apply
return out, nil
}
func (c *walletServiceClient) GetRedPacketConfig(ctx context.Context, in *GetRedPacketConfigRequest, opts ...grpc.CallOption) (*GetRedPacketConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetRedPacketConfigResponse)
err := c.cc.Invoke(ctx, WalletService_GetRedPacketConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) UpdateRedPacketConfig(ctx context.Context, in *UpdateRedPacketConfigRequest, opts ...grpc.CallOption) (*UpdateRedPacketConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpdateRedPacketConfigResponse)
err := c.cc.Invoke(ctx, WalletService_UpdateRedPacketConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) CreateRedPacket(ctx context.Context, in *CreateRedPacketRequest, opts ...grpc.CallOption) (*CreateRedPacketResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreateRedPacketResponse)
err := c.cc.Invoke(ctx, WalletService_CreateRedPacket_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ClaimRedPacket(ctx context.Context, in *ClaimRedPacketRequest, opts ...grpc.CallOption) (*ClaimRedPacketResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ClaimRedPacketResponse)
err := c.cc.Invoke(ctx, WalletService_ClaimRedPacket_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ListRedPackets(ctx context.Context, in *ListRedPacketsRequest, opts ...grpc.CallOption) (*ListRedPacketsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListRedPacketsResponse)
err := c.cc.Invoke(ctx, WalletService_ListRedPackets_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) GetRedPacket(ctx context.Context, in *GetRedPacketRequest, opts ...grpc.CallOption) (*GetRedPacketResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetRedPacketResponse)
err := c.cc.Invoke(ctx, WalletService_GetRedPacket_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ExpireRedPackets(ctx context.Context, in *ExpireRedPacketsRequest, opts ...grpc.CallOption) (*ExpireRedPacketsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ExpireRedPacketsResponse)
err := c.cc.Invoke(ctx, WalletService_ExpireRedPackets_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// WalletServiceServer is the server API for WalletService service.
// All implementations must embed UnimplementedWalletServiceServer
// for forward compatibility.
@ -600,8 +720,18 @@ type WalletServiceServer interface {
ListVipPackages(context.Context, *ListVipPackagesRequest) (*ListVipPackagesResponse, error)
GetMyVip(context.Context, *GetMyVipRequest) (*GetMyVipResponse, error)
PurchaseVip(context.Context, *PurchaseVipRequest) (*PurchaseVipResponse, error)
GrantVip(context.Context, *GrantVipRequest) (*GrantVipResponse, error)
ListAdminVipLevels(context.Context, *ListAdminVipLevelsRequest) (*ListAdminVipLevelsResponse, error)
UpdateAdminVipLevels(context.Context, *UpdateAdminVipLevelsRequest) (*UpdateAdminVipLevelsResponse, error)
CreditTaskReward(context.Context, *CreditTaskRewardRequest) (*CreditTaskRewardResponse, error)
ApplyGameCoinChange(context.Context, *ApplyGameCoinChangeRequest) (*ApplyGameCoinChangeResponse, error)
GetRedPacketConfig(context.Context, *GetRedPacketConfigRequest) (*GetRedPacketConfigResponse, error)
UpdateRedPacketConfig(context.Context, *UpdateRedPacketConfigRequest) (*UpdateRedPacketConfigResponse, error)
CreateRedPacket(context.Context, *CreateRedPacketRequest) (*CreateRedPacketResponse, error)
ClaimRedPacket(context.Context, *ClaimRedPacketRequest) (*ClaimRedPacketResponse, error)
ListRedPackets(context.Context, *ListRedPacketsRequest) (*ListRedPacketsResponse, error)
GetRedPacket(context.Context, *GetRedPacketRequest) (*GetRedPacketResponse, error)
ExpireRedPackets(context.Context, *ExpireRedPacketsRequest) (*ExpireRedPacketsResponse, error)
mustEmbedUnimplementedWalletServiceServer()
}
@ -735,12 +865,42 @@ func (UnimplementedWalletServiceServer) GetMyVip(context.Context, *GetMyVipReque
func (UnimplementedWalletServiceServer) PurchaseVip(context.Context, *PurchaseVipRequest) (*PurchaseVipResponse, error) {
return nil, status.Error(codes.Unimplemented, "method PurchaseVip not implemented")
}
func (UnimplementedWalletServiceServer) GrantVip(context.Context, *GrantVipRequest) (*GrantVipResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GrantVip not implemented")
}
func (UnimplementedWalletServiceServer) ListAdminVipLevels(context.Context, *ListAdminVipLevelsRequest) (*ListAdminVipLevelsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListAdminVipLevels not implemented")
}
func (UnimplementedWalletServiceServer) UpdateAdminVipLevels(context.Context, *UpdateAdminVipLevelsRequest) (*UpdateAdminVipLevelsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateAdminVipLevels not implemented")
}
func (UnimplementedWalletServiceServer) CreditTaskReward(context.Context, *CreditTaskRewardRequest) (*CreditTaskRewardResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CreditTaskReward not implemented")
}
func (UnimplementedWalletServiceServer) ApplyGameCoinChange(context.Context, *ApplyGameCoinChangeRequest) (*ApplyGameCoinChangeResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ApplyGameCoinChange not implemented")
}
func (UnimplementedWalletServiceServer) GetRedPacketConfig(context.Context, *GetRedPacketConfigRequest) (*GetRedPacketConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetRedPacketConfig not implemented")
}
func (UnimplementedWalletServiceServer) UpdateRedPacketConfig(context.Context, *UpdateRedPacketConfigRequest) (*UpdateRedPacketConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateRedPacketConfig not implemented")
}
func (UnimplementedWalletServiceServer) CreateRedPacket(context.Context, *CreateRedPacketRequest) (*CreateRedPacketResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CreateRedPacket not implemented")
}
func (UnimplementedWalletServiceServer) ClaimRedPacket(context.Context, *ClaimRedPacketRequest) (*ClaimRedPacketResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ClaimRedPacket not implemented")
}
func (UnimplementedWalletServiceServer) ListRedPackets(context.Context, *ListRedPacketsRequest) (*ListRedPacketsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListRedPackets not implemented")
}
func (UnimplementedWalletServiceServer) GetRedPacket(context.Context, *GetRedPacketRequest) (*GetRedPacketResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetRedPacket not implemented")
}
func (UnimplementedWalletServiceServer) ExpireRedPackets(context.Context, *ExpireRedPacketsRequest) (*ExpireRedPacketsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ExpireRedPackets not implemented")
}
func (UnimplementedWalletServiceServer) mustEmbedUnimplementedWalletServiceServer() {}
func (UnimplementedWalletServiceServer) testEmbeddedByValue() {}
@ -1500,6 +1660,60 @@ func _WalletService_PurchaseVip_Handler(srv interface{}, ctx context.Context, de
return interceptor(ctx, in, info, handler)
}
func _WalletService_GrantVip_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GrantVipRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).GrantVip(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_GrantVip_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).GrantVip(ctx, req.(*GrantVipRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListAdminVipLevels_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListAdminVipLevelsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).ListAdminVipLevels(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_ListAdminVipLevels_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).ListAdminVipLevels(ctx, req.(*ListAdminVipLevelsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_UpdateAdminVipLevels_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateAdminVipLevelsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).UpdateAdminVipLevels(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_UpdateAdminVipLevels_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).UpdateAdminVipLevels(ctx, req.(*UpdateAdminVipLevelsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_CreditTaskReward_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreditTaskRewardRequest)
if err := dec(in); err != nil {
@ -1536,6 +1750,132 @@ func _WalletService_ApplyGameCoinChange_Handler(srv interface{}, ctx context.Con
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetRedPacketConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetRedPacketConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).GetRedPacketConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_GetRedPacketConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).GetRedPacketConfig(ctx, req.(*GetRedPacketConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_UpdateRedPacketConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateRedPacketConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).UpdateRedPacketConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_UpdateRedPacketConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).UpdateRedPacketConfig(ctx, req.(*UpdateRedPacketConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_CreateRedPacket_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateRedPacketRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).CreateRedPacket(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_CreateRedPacket_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).CreateRedPacket(ctx, req.(*CreateRedPacketRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ClaimRedPacket_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ClaimRedPacketRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).ClaimRedPacket(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_ClaimRedPacket_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).ClaimRedPacket(ctx, req.(*ClaimRedPacketRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListRedPackets_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListRedPacketsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).ListRedPackets(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_ListRedPackets_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).ListRedPackets(ctx, req.(*ListRedPacketsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetRedPacket_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetRedPacketRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).GetRedPacket(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_GetRedPacket_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).GetRedPacket(ctx, req.(*GetRedPacketRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ExpireRedPackets_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ExpireRedPacketsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).ExpireRedPackets(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_ExpireRedPackets_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).ExpireRedPackets(ctx, req.(*ExpireRedPacketsRequest))
}
return interceptor(ctx, in, info, handler)
}
// WalletService_ServiceDesc is the grpc.ServiceDesc for WalletService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -1707,6 +2047,18 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "PurchaseVip",
Handler: _WalletService_PurchaseVip_Handler,
},
{
MethodName: "GrantVip",
Handler: _WalletService_GrantVip_Handler,
},
{
MethodName: "ListAdminVipLevels",
Handler: _WalletService_ListAdminVipLevels_Handler,
},
{
MethodName: "UpdateAdminVipLevels",
Handler: _WalletService_UpdateAdminVipLevels_Handler,
},
{
MethodName: "CreditTaskReward",
Handler: _WalletService_CreditTaskReward_Handler,
@ -1715,6 +2067,34 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ApplyGameCoinChange",
Handler: _WalletService_ApplyGameCoinChange_Handler,
},
{
MethodName: "GetRedPacketConfig",
Handler: _WalletService_GetRedPacketConfig_Handler,
},
{
MethodName: "UpdateRedPacketConfig",
Handler: _WalletService_UpdateRedPacketConfig_Handler,
},
{
MethodName: "CreateRedPacket",
Handler: _WalletService_CreateRedPacket_Handler,
},
{
MethodName: "ClaimRedPacket",
Handler: _WalletService_ClaimRedPacket_Handler,
},
{
MethodName: "ListRedPackets",
Handler: _WalletService_ListRedPackets_Handler,
},
{
MethodName: "GetRedPacket",
Handler: _WalletService_GetRedPacket_Handler,
},
{
MethodName: "ExpireRedPackets",
Handler: _WalletService_ExpireRedPackets_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/wallet/v1/wallet.proto",

520
cmd/dev-run/main.go Normal file
View File

@ -0,0 +1,520 @@
// Package main provides the repository-local hot reload runner behind `make run`.
package main
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
const (
scanInterval = 800 * time.Millisecond
restartDebounce = 350 * time.Millisecond
stopTimeout = 8 * time.Second
)
type serviceSpec struct {
Name string
Dir string
Args []string
WatchRoot []string
Ports []int
Color string
}
type serviceRunner struct {
spec serviceSpec
mu sync.Mutex
cmd *exec.Cmd
done chan struct{}
stopping bool
}
var logMu sync.Mutex
func main() {
serviceList := flag.String("services", "all", "comma-separated services to run, or all")
flag.Parse()
root, err := os.Getwd()
if err != nil {
fatal(err)
}
specs, err := selectServices(*serviceList)
if err != nil {
fatal(err)
}
if len(specs) == 0 {
fatal(errors.New("no services selected"))
}
ctx, stop := signalContext()
defer stop()
runners := make(map[string]*serviceRunner, len(specs))
watchers := make([]*watchState, 0, len(specs))
for _, spec := range specs {
runner := &serviceRunner{spec: spec}
runners[spec.Name] = runner
killPortOwners(spec)
if err := runner.start(root); err != nil {
logf(os.Stderr, spec, "start failed: %v", err)
}
watchers = append(watchers, newWatchState(root, spec))
}
printPlain(os.Stdout, "dev-run watching %d service(s): %s", len(specs), serviceNames(specs))
printPlain(os.Stdout, "press Ctrl-C to stop all services")
ticker := time.NewTicker(scanInterval)
defer ticker.Stop()
pending := map[string]time.Time{}
for {
select {
case <-ctx.Done():
stopAll(runners)
return
case now := <-ticker.C:
for _, watcher := range watchers {
changed, err := watcher.changed()
if err != nil {
logf(os.Stderr, watcher.spec, "watch failed: %v", err)
continue
}
if changed {
pending[watcher.spec.Name] = now
}
}
for name, changedAt := range pending {
if now.Sub(changedAt) < restartDebounce {
continue
}
delete(pending, name)
runner := runners[name]
logf(os.Stdout, runner.spec, "change detected, restarting")
runner.restart(root)
}
}
}
}
func allServices() []serviceSpec {
// 启动顺序按低层依赖到入口服务排列gRPC dial 当前不阻塞,但这样能让日志更接近真实依赖链路。
return []serviceSpec{
appService("user-service", []int{13005, 13105}, "36"),
appService("activity-service", []int{13006, 13106}, "35"),
appService("wallet-service", []int{13004, 13104}, "33"),
appService("game-service", []int{13008, 13108}, "32"),
appService("room-service", []int{13001, 13101}, "34"),
appService("notice-service", []int{13009, 13109}, "96"),
appService("cron-service", []int{13007, 13107}, "95"),
appService("gateway-service", []int{13000}, "92"),
appService("statistics-service", []int{13010, 13110}, "94"),
{
Name: "admin",
Dir: "server/admin",
Args: []string{"run", "./cmd/server", "-config", "configs/config.yaml"},
WatchRoot: []string{
"server/admin",
"api",
},
Ports: []int{13100},
Color: "91",
},
}
}
func appService(name string, ports []int, color string) serviceSpec {
return serviceSpec{
Name: name,
Dir: ".",
Args: []string{"run", "./services/" + name + "/cmd/server", "-config", "services/" + name + "/configs/config.yaml"},
WatchRoot: []string{
"services/" + name,
"pkg",
"api",
},
Ports: ports,
Color: color,
}
}
func selectServices(raw string) ([]serviceSpec, error) {
all := allServices()
if strings.TrimSpace(raw) == "" || strings.TrimSpace(raw) == "all" {
return all, nil
}
byName := make(map[string]serviceSpec, len(all))
for _, spec := range all {
byName[spec.Name] = spec
}
aliases := map[string]string{
"gateway": "gateway-service",
"gs": "gateway-service",
"room": "room-service",
"rs": "room-service",
"wallet": "wallet-service",
"ws": "wallet-service",
"user": "user-service",
"us": "user-service",
"activity": "activity-service",
"as": "activity-service",
"cron": "cron-service",
"cs": "cron-service",
"game": "game-service",
"games": "game-service",
"notice": "notice-service",
"ns": "notice-service",
"stats": "statistics-service",
"statistics": "statistics-service",
}
var selected []serviceSpec
seen := map[string]bool{}
for _, token := range strings.Split(raw, ",") {
token = strings.TrimSpace(token)
if token == "" {
continue
}
if canonical, ok := aliases[token]; ok {
token = canonical
}
spec, ok := byName[token]
if !ok {
return nil, fmt.Errorf("unknown service %q", token)
}
if !seen[spec.Name] {
selected = append(selected, spec)
seen[spec.Name] = true
}
}
return selected, nil
}
func (r *serviceRunner) start(repoRoot string) error {
r.mu.Lock()
defer r.mu.Unlock()
cmd := exec.Command("go", r.spec.Args...)
cmd.Dir = filepath.Join(repoRoot, r.spec.Dir)
cmd.Env = append(os.Environ(), "TZ=UTC")
// go run 会再托管一个编译后的子进程;独立进程组保证重启时能一起收到 SIGINT。
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
r.cmd = cmd
r.done = make(chan struct{})
r.stopping = false
go prefixPipe(r.spec, stdout)
go prefixPipe(r.spec, stderr)
go r.wait(cmd, r.done)
logf(os.Stdout, r.spec, "started: go %s", strings.Join(r.spec.Args, " "))
return nil
}
func (r *serviceRunner) restart(repoRoot string) {
r.stop()
killPortOwners(r.spec)
if err := r.start(repoRoot); err != nil {
logf(os.Stderr, r.spec, "restart failed: %v", err)
}
}
func (r *serviceRunner) stop() {
r.mu.Lock()
cmd := r.cmd
done := r.done
r.stopping = true
r.mu.Unlock()
if cmd == nil || cmd.Process == nil || done == nil {
return
}
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGINT)
select {
case <-done:
case <-time.After(stopTimeout):
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
<-done
}
}
func (r *serviceRunner) wait(cmd *exec.Cmd, done chan struct{}) {
err := cmd.Wait()
close(done)
r.mu.Lock()
stopping := r.stopping
if r.cmd == cmd {
r.cmd = nil
r.done = nil
r.stopping = false
}
r.mu.Unlock()
if err != nil {
if stopping {
logf(os.Stdout, r.spec, "stopped")
return
}
logf(os.Stderr, r.spec, "exited: %v", err)
return
}
logf(os.Stdout, r.spec, "exited")
}
func stopAll(runners map[string]*serviceRunner) {
names := make([]string, 0, len(runners))
for name := range runners {
names = append(names, name)
}
sort.Strings(names)
var wg sync.WaitGroup
for _, name := range names {
wg.Add(1)
go func(runner *serviceRunner) {
defer wg.Done()
runner.stop()
}(runners[name])
}
wg.Wait()
}
type watchState struct {
root string
spec serviceSpec
snapshot map[string]fileStamp
}
type fileStamp struct {
modTime time.Time
size int64
}
func newWatchState(root string, spec serviceSpec) *watchState {
w := &watchState{root: root, spec: spec}
w.snapshot = w.scan()
return w
}
func (w *watchState) changed() (bool, error) {
next := w.scan()
changed := !sameSnapshot(w.snapshot, next)
w.snapshot = next
return changed, nil
}
func (w *watchState) scan() map[string]fileStamp {
out := make(map[string]fileStamp)
for _, rel := range w.spec.WatchRoot {
w.scanPath(out, rel)
}
// 根 module 变更会影响所有 app 服务admin 还有自己的独立 module 文件。
w.scanPath(out, "go.mod")
w.scanPath(out, "go.sum")
if w.spec.Name == "admin" {
w.scanPath(out, filepath.Join("server", "admin", "go.mod"))
w.scanPath(out, filepath.Join("server", "admin", "go.sum"))
}
return out
}
func (w *watchState) scanPath(out map[string]fileStamp, rel string) {
path := filepath.Join(w.root, rel)
info, err := os.Stat(path)
if err != nil {
return
}
if !info.IsDir() {
if watchableFile(path) {
out[rel] = fileStamp{modTime: info.ModTime(), size: info.Size()}
}
return
}
_ = filepath.WalkDir(path, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
if ignoredDir(d.Name()) {
return filepath.SkipDir
}
return nil
}
if !watchableFile(path) {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
relPath, err := filepath.Rel(w.root, path)
if err != nil {
return nil
}
out[relPath] = fileStamp{modTime: info.ModTime(), size: info.Size()}
return nil
})
}
func ignoredDir(name string) bool {
switch name {
case ".git", ".idea", ".vscode", "bin", "dist", "node_modules", "storage", "tmp", "vendor":
return true
default:
return false
}
}
func watchableFile(path string) bool {
switch filepath.Ext(path) {
case ".go", ".yaml", ".yml", ".proto", ".mod", ".sum":
return true
default:
return false
}
}
func sameSnapshot(a, b map[string]fileStamp) bool {
if len(a) != len(b) {
return false
}
for path, stampA := range a {
stampB, ok := b[path]
if !ok || !stampA.modTime.Equal(stampB.modTime) || stampA.size != stampB.size {
return false
}
}
return true
}
func killPortOwners(spec serviceSpec) {
seen := map[int]bool{}
for _, port := range spec.Ports {
if port <= 0 || seen[port] {
continue
}
seen[port] = true
pids, err := listeningPIDs(port)
if err != nil {
logf(os.Stderr, spec, "port %d lookup failed: %v", port, err)
continue
}
if len(pids) == 0 {
continue
}
for _, pid := range pids {
// 本地开发启动以抢占端口为准:旧进程可能是 go run 托管的子进程,直接杀监听 PID 最可靠。
if err := syscall.Kill(pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
logf(os.Stderr, spec, "kill pid %d on port %d failed: %v", pid, port, err)
continue
}
logf(os.Stdout, spec, "killed pid %d occupying port %d", pid, port)
}
}
}
func listeningPIDs(port int) ([]int, error) {
output, err := exec.Command("lsof", "-nP", "-tiTCP:"+strconv.Itoa(port), "-sTCP:LISTEN").Output()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && len(output) == 0 {
return nil, nil
}
return nil, err
}
lines := strings.Fields(string(output))
pids := make([]int, 0, len(lines))
for _, line := range lines {
pid, err := strconv.Atoi(strings.TrimSpace(line))
if err != nil {
continue
}
pids = append(pids, pid)
}
return pids, nil
}
func prefixPipe(spec serviceSpec, reader io.Reader) {
scanner := bufio.NewScanner(reader)
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024)
for scanner.Scan() {
logf(os.Stdout, spec, "%s", scanner.Text())
}
if err := scanner.Err(); err != nil {
logf(os.Stderr, spec, "log pipe failed: %v", err)
}
}
func logf(w io.Writer, spec serviceSpec, format string, args ...any) {
line := fmt.Sprintf(format, args...)
prefix := "[" + spec.Name + "]"
if colorEnabled() && spec.Color != "" {
prefix = "\x1b[" + spec.Color + "m" + prefix + "\x1b[0m"
}
printPlain(w, "%s %s", prefix, line)
}
func printPlain(w io.Writer, format string, args ...any) {
logMu.Lock()
defer logMu.Unlock()
fmt.Fprintf(w, format+"\n", args...)
}
func colorEnabled() bool {
if _, ok := os.LookupEnv("NO_COLOR"); ok {
return false
}
return os.Getenv("TERM") != "dumb"
}
func serviceNames(specs []serviceSpec) string {
names := make([]string, 0, len(specs))
for _, spec := range specs {
names = append(names, spec.Name)
}
return strings.Join(names, ", ")
}
func signalContext() (context.Context, context.CancelFunc) {
ctx, cancel := context.WithCancel(context.Background())
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-signals
cancel()
}()
return ctx, cancel
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

View File

@ -32,7 +32,8 @@ room-service 实例之间通过 Redis owner route 和 node registry 直连转发
| 层级 | 地址 | 说明 |
| --- | --- | --- |
| 公网 API | `https://api.global-interaction.com` | HTTPS 443 由公网 CLB 转发到两台 `gateway-service:13000` |
| 后台平台 | `https://hyapp-platform.global-interaction.com` | 香港 Lighthouse 上 nginx 终止 HTTPS反代本机 `admin-server:13100` |
| 后台前端 | `http://1.14.164.2:7001` | 广州 CVM `172.16.0.10` 上 nginx 托管 `admin-platform` 静态资源,`/api` 反代内网后台服务 |
| 后台 API | `172.16.0.6:13100` | 广州 CVM `ins-0x5mjwmc` 运行 `hyapp-admin-server`,通过 `wg-quick@hyapp-admin` 访问生产内网依赖 |
| room/user/activity/game/notice 内网入口 | `10.2.1.16:13001/13005/13006/13008/13009` | `lb-epvnr4o0` 的 Go 服务监听器 |
| wallet 内网入口 | `10.2.1.15:13004` | `lb-4f5xi6p0` 的 Go 服务监听器 |

View File

@ -12,5 +12,6 @@ GRANT ALL PRIVILEGES ON hyapp_admin.* TO 'hyapp'@'%';
GRANT ALL PRIVILEGES ON hyapp_cron.* TO 'hyapp'@'%';
GRANT ALL PRIVILEGES ON hyapp_game.* TO 'hyapp'@'%';
GRANT ALL PRIVILEGES ON hyapp_notice.* TO 'hyapp'@'%';
GRANT ALL PRIVILEGES ON hyapp_statistics.* TO 'hyapp'@'%';
FLUSH PRIVILEGES;

View File

@ -0,0 +1,9 @@
brokerClusterName = hyapp-local
brokerName = broker-a
brokerId = 0
deleteWhen = 04
fileReservedTime = 48
brokerRole = ASYNC_MASTER
flushDiskType = ASYNC_FLUSH
autoCreateTopicEnable = true
listenPort = 10911

View File

@ -3,6 +3,34 @@ x-go-build-args: &go-build-args
GOSUMDB: "${GOSUMDB:-sum.golang.org}"
services:
rocketmq-namesrv:
image: apache/rocketmq:5.3.1
container_name: rocketmq-namesrv
environment:
TZ: UTC
JAVA_OPT_EXT: "-Xms256m -Xmx256m -Xmn128m"
command: ["sh", "mqnamesrv"]
ports:
- "19876:9876"
rocketmq-broker:
image: apache/rocketmq:5.3.1
container_name: rocketmq-broker
user: root
environment:
TZ: UTC
NAMESRV_ADDR: "rocketmq-namesrv:9876"
JAVA_OPT_EXT: "-Xms512m -Xmx512m -Xmn256m"
command: ["sh", "mqbroker", "-n", "rocketmq-namesrv:9876", "-c", "/home/rocketmq/broker.conf"]
ports:
- "19009:10909"
- "19011:10911"
volumes:
- ./deploy/rocketmq/broker.conf:/home/rocketmq/broker.conf:ro
- rocketmq-store:/home/rocketmq/store
depends_on:
- rocketmq-namesrv
gateway-service:
build:
context: .
@ -22,6 +50,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
rocketmq-broker:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:13000/healthz/ready >/dev/null"]
interval: 5s
@ -47,6 +77,8 @@ services:
condition: service_healthy
wallet-service:
condition: service_healthy
rocketmq-broker:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:13101/healthz/ready >/dev/null"]
interval: 5s
@ -68,6 +100,8 @@ services:
depends_on:
mysql:
condition: service_healthy
rocketmq-broker:
condition: service_started
user-service:
condition: service_healthy
activity-service:
@ -93,6 +127,8 @@ services:
depends_on:
mysql:
condition: service_healthy
rocketmq-broker:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:13105/healthz/ready >/dev/null"]
interval: 5s
@ -114,6 +150,8 @@ services:
depends_on:
mysql:
condition: service_healthy
rocketmq-broker:
condition: service_started
user-service:
condition: service_healthy
healthcheck:
@ -141,6 +179,8 @@ services:
condition: service_healthy
activity-service:
condition: service_healthy
rocketmq-broker:
condition: service_started
game-service:
condition: service_healthy
healthcheck:
@ -164,6 +204,8 @@ services:
depends_on:
mysql:
condition: service_healthy
rocketmq-broker:
condition: service_started
wallet-service:
condition: service_healthy
user-service:
@ -198,6 +240,29 @@ services:
retries: 20
start_period: 10s
statistics-service:
build:
context: .
dockerfile: services/statistics-service/Dockerfile
args: *go-build-args
container_name: statistics-service
environment:
TZ: UTC
ports:
- "13010:13010"
- "13110:13110"
depends_on:
mysql:
condition: service_healthy
rocketmq-broker:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:13110/healthz/ready >/dev/null"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
mysql:
image: mysql:8.4
container_name: hyapp-mysql
@ -226,6 +291,7 @@ services:
- ./services/cron-service/deploy/mysql/initdb/001_cron_service.sql:/docker-entrypoint-initdb.d/009_cron_service.sql:ro
- ./services/game-service/deploy/mysql/initdb/001_game_service.sql:/docker-entrypoint-initdb.d/010_game_service.sql:ro
- ./services/notice-service/deploy/mysql/initdb/001_notice_service.sql:/docker-entrypoint-initdb.d/011_notice_service.sql:ro
- ./services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql:/docker-entrypoint-initdb.d/012_statistics_service.sql:ro
- ./deploy/mysql/initdb/999_local_grants.sql:/docker-entrypoint-initdb.d/999_local_grants.sql:ro
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -proot --silent"]
@ -248,3 +314,4 @@ services:
volumes:
mysql-data:
rocketmq-store:

View File

@ -0,0 +1,382 @@
# Flutter Google Play 充值对接
本文描述 Android App 接入 Google Play 一次性金币充值的 Flutter 调用流程。Flutter 只负责购买流程和提交 Google purchase token订单校验、到账金额、入账、幂等和消费确认都以后端 `wallet-service` 为准。
所有 gateway HTTP 接口都返回统一外壳:
```json
{
"code": "OK",
"message": "ok",
"request_id": "server-generated-request-id",
"data": {}
}
```
Flutter 只有在 `code == "OK"` 时读取 `data`。其他错误必须记录 `request_id`,方便后端排查。
## 1. 当前实现状态
已实现:
- `GET /api/v1/wallet/recharge/products`App 端充值商品列表。
- `GET /api/v1/wallet/me/balances`:刷新金币余额。
- 钱包余额 IM 通知:`WalletBalanceChanged`
后端待补齐:
- `POST /api/v1/wallet/payments/google/confirm`:提交 Google purchase token后端校验并入账。
- Google Play RTDN / Pub/Sub 回调:只做补偿通知,不替代客户端确认接口。
- `payment_orders` 和 Google provider 订单审计表。
Flutter 可以先按本文契约接商品列表和本地 Google Billing 流程;确认支付接口需要等后端补齐后联调。
## 2. Flutter 依赖
建议使用 Flutter 官方维护的 `in_app_purchase` 插件接 Google Play Billing。
`pubspec.yaml`
```yaml
dependencies:
in_app_purchase: <compatible_version>
```
Android 真机联调要求:
- App 使用正式包名和签名,包名必须与 Play Console App 一致。
- 测试账号加入 Play Console license testing 或内部测试轨道。
- Play Console 中的一次性商品必须激活,并覆盖测试账号所在地区。
## 3. 获取充值商品列表
`GET /api/v1/wallet/recharge/products`
登录接口,需要 `Authorization: Bearer <access_token>`。gateway 会根据当前登录用户资料里的 `region_id` 过滤商品Flutter 不传用户 ID 和区域 ID。
请求头:
| Header | 必填 | 说明 |
| --- | --- | --- |
| `Authorization` | 是 | App 登录 access token。 |
| `X-App-Code` | 否 | App 编码,默认 `lalu`。 |
| `X-App-Platform` | 是 | 固定传 `android`。也可以用 query `platform=android` 覆盖。 |
示例:
```http
GET /api/v1/wallet/recharge/products
Authorization: Bearer <access_token>
X-App-Code: lalu
X-App-Platform: android
```
成功响应:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"channels": ["google"],
"products": [
{
"product_id": 11,
"product_code": "iap_android_11",
"product_name": "1500 Coins",
"description": "coin pack",
"platform": "android",
"channel": "google",
"currency_code": "USDT",
"coin_amount": 1500,
"amount_minor": 1500000,
"amount_micro": 1500000,
"policy_version": "",
"region_ids": [2002],
"resource_asset_type": "COIN",
"status": "active",
"enabled": true,
"sort_order": 0
}
]
}
}
```
字段规则:
| 字段 | Flutter 用途 |
| --- | --- |
| `product_id` | 后端本地商品 ID提交确认支付时原样带回。 |
| `product_code` | 当前作为 Google Play productId 使用。必须与 Play Console 商品 ID 完全一致。 |
| `channel` | Android Google 支付固定为 `google`。 |
| `coin_amount` | 到账金币数,展示和到账都以后端为准。 |
| `amount_micro` / `amount_minor` | 后台配置金额仅用于后台审计Google 支付价格展示必须用 Google `ProductDetails`。 |
| `resource_asset_type` | 当前固定为 `COIN`。 |
| `enabled` / `status` | 只展示 `enabled=true && status=active` 的商品。 |
注意:当前后端 `product_code` 是自动生成值,例如 `iap_android_11`。如果 Play Console 已经创建了其他商品 ID例如 `coins_150000_usd499`,后端需要先新增 `google_product_id``provider_product_id` 字段Flutter 不要自行硬编码映射。
## 4. 查询 Google ProductDetails
Flutter 拿到后端商品列表后,只对 `channel=google` 且启用的商品查询 Google Play
```dart
final Set<String> googleProductIds = backendProducts
.where((item) => item.channel == 'google' && item.enabled)
.map((item) => item.productCode)
.where((value) => value.isNotEmpty)
.toSet();
final ProductDetailsResponse response =
await InAppPurchase.instance.queryProductDetails(googleProductIds);
```
展示合并规则:
- 金币数量用后端 `coin_amount`
- 商品是否展示以后端上架状态和 Google 查询结果共同决定。
- 价格文案用 Google `ProductDetails.price`,不要用后端 `amount_micro`
- 如果后端商品存在但 Google 查不到 `ProductDetails`,该商品不展示,并记录客户端日志。
合并后的本地模型建议保留:
```dart
class GoogleRechargeSku {
GoogleRechargeSku({
required this.productId,
required this.googleProductId,
required this.coinAmount,
required this.productDetails,
});
final int productId;
final String googleProductId;
final int coinAmount;
final ProductDetails productDetails;
}
```
## 5. 发起购买
金币充值是 consumable。为了避免客户端先消费导致后端未入账Android 不要自动 consume后端校验并入账后由后端调用 Google consume。
```dart
final PurchaseParam purchaseParam = PurchaseParam(
productDetails: sku.productDetails,
);
await InAppPurchase.instance.buyConsumable(
purchaseParam: purchaseParam,
autoConsume: false,
);
```
Flutter 必须监听购买流:
```dart
late final StreamSubscription<List<PurchaseDetails>> _purchaseSub;
void startPurchaseListener() {
_purchaseSub = InAppPurchase.instance.purchaseStream.listen(
_handlePurchases,
onError: (Object error, StackTrace stackTrace) {
// 记录错误并恢复按钮状态。
},
);
}
```
状态处理:
| `PurchaseStatus` | Flutter 行为 |
| --- | --- |
| `pending` | 展示处理中,不提交后端入账。 |
| `purchased` | 提交后端确认接口。 |
| `restored` | Android 一次性消耗品通常不依赖 restored可以按补单逻辑提交后端去重。 |
| `error` | 展示失败,恢复按钮。 |
| `canceled` | 用户取消,不提示错误。 |
## 6. 确认 Google 支付
后端待实现接口:
`POST /api/v1/wallet/payments/google/confirm`
登录接口。Flutter 在 `PurchaseStatus.purchased` 后调用。接口必须可重复调用;同一个 purchase token 重复提交只能返回同一笔入账结果。
请求头:
| Header | 必填 | 说明 |
| --- | --- | --- |
| `Authorization` | 是 | App 登录 access token。 |
| `X-App-Code` | 否 | App 编码,默认 `lalu`。 |
| `X-App-Platform` | 是 | 固定传 `android`。 |
请求体建议契约:
```json
{
"command_id": "google-pay-<stable-id>",
"product_id": 11,
"product_code": "iap_android_11",
"package_name": "com.example.hyapp",
"purchase_token": "google_purchase_token",
"order_id": "GPA.1111-2222-3333-44444",
"purchase_time_ms": 1710000000000
}
```
字段规则:
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `command_id` | 是 | 客户端幂等 ID。同一个 purchase token 重试必须使用同一个值。 |
| `product_id` | 是 | 后端商品列表返回的本地商品 ID。 |
| `product_code` | 是 | 后端商品列表返回的 Google productId。 |
| `package_name` | 是 | 当前 Android 包名。 |
| `purchase_token` | 是 | `PurchaseDetails.verificationData.serverVerificationData`。 |
| `order_id` | 否 | 插件能拿到时传;后端最终以 Google API 查询结果为准。 |
| `purchase_time_ms` | 否 | 客户端购买时间,仅用于日志。 |
`command_id` 建议由 token 派生,确保重试稳定:
```dart
final String commandId = 'google-pay-${sha256Of(purchaseToken).substring(0, 24)}';
```
成功响应建议契约:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"payment_order_id": "pay_google_xxx",
"transaction_id": "wtx_xxx",
"status": "credited",
"product_id": 11,
"product_code": "iap_android_11",
"coin_amount": 1500,
"balance": {
"asset_type": "COIN",
"available_amount": 12800,
"frozen_amount": 0,
"version": 9
},
"idempotent_replay": false,
"consume_state": "consumed"
}
}
```
Flutter 成功处理:
- `status=credited`:展示充值成功,刷新 `/wallet/me/balances`
- `idempotent_replay=true`:按成功处理,不重复提示异常。
- `consume_state=consume_pending`:仍按到账成功处理;后端会补偿 consume。
- 后端成功后调用 `InAppPurchase.instance.completePurchase(purchaseDetails)`,结束客户端 pending 状态。
失败处理:
- `UNAUTHORIZED`:登录失效,走登录态恢复。
- `INVALID_ARGUMENT`:商品 ID、token、包名等参数错误记录 `request_id`
- `UPSTREAM_ERROR` / `INTERNAL_ERROR`:保留订单为待确认,稍后重试。
- provider 校验失败:不入账,不 complete purchase记录 `request_id` 并触发补单或客服入口。
## 7. 补单规则
Flutter 必须做补单,因为用户可能在支付成功后断网、杀进程或后端短时失败。
触发时机:
- App 启动后。
- 登录成功后。
- 进入钱包页后。
- 购买确认接口返回 5xx 或网络失败后延迟重试。
处理规则:
1. 查询 Google Play 当前未完成购买。
2. 对每个 purchase token 查本地 pending 队列。
3. 重新调用 `/api/v1/wallet/payments/google/confirm`
4. 后端返回 `credited` 后 complete purchase。
本地 pending 记录建议保存:
```json
{
"product_id": 11,
"product_code": "iap_android_11",
"purchase_token_hash": "sha256-token",
"purchase_token": "secure-local-token",
"command_id": "google-pay-xxxx",
"created_at_ms": 1710000000000,
"last_error": "network timeout"
}
```
`purchase_token` 属于敏感数据。客户端日志不要打印明文 token本地持久化也应尽量缩短保留时间。
## 8. 余额刷新
充值确认成功后Flutter 需要主动刷新余额:
`GET /api/v1/wallet/me/balances?asset_type=COIN`
成功响应:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"balances": [
{
"asset_type": "COIN",
"available_amount": 12800,
"frozen_amount": 0,
"version": 9
}
]
}
}
```
后端也会通过 IM 私信发送 `WalletBalanceChanged`,但 Flutter 不应只依赖 IM 通知。充值确认成功后主动刷新一次IM 只作为实时补偿。
## 9. Flutter 页面流程
推荐顺序:
1. 进入钱包页,调用 `/wallet/recharge/products`
2. 用返回的 `product_code` 查询 Google `ProductDetails`
3. 合并后展示充值档位。
4. 用户点击档位,调用 `buyConsumable(autoConsume: false)`
5. purchase stream 收到 `purchased`
6. 调用 `/wallet/payments/google/confirm`
7. 后端返回 `credited`
8. 调用 `completePurchase`
9. 刷新 `/wallet/me/balances`
10. 清理本地 pending 记录。
## 10. 不允许的做法
- 不允许 Flutter 根据本地配置决定到账金币数。
- 不允许 Flutter 只看 Google 支付成功就本地加余额。
- 不允许客户端自动 consume 后再提交后端。
- 不允许把 `purchase_token`、Google 原始订单响应打印到普通日志。
- 不允许用价格文案匹配商品;必须用 `product_code` / Google productId 匹配。
## 11. 与当前 Flutter 代码的差异
当前 `hyapp-flutter` 钱包页充值档位仍是本地假数据,后续需要替换为:
- 新增 wallet recharge product API model。
- 在 `WalletLogic.onInit` 拉取 `/wallet/recharge/products`
- 查询 Google `ProductDetails` 并合并展示。
- `topUp()` 从占位提示改为 Google Billing 购买。
- 新增 purchase stream listener 和 pending purchase 补单队列。

View File

@ -0,0 +1,588 @@
# 幸运礼物 Flutter 对接
本文描述 Flutter App 对接幸运礼物所需的 HTTP 接口、请求参数、返回值、错误处理和腾讯云 IM 自定义消息。幸运礼物支持多个互相独立的奖池App 只负责传入 `pool_id`,不计算 RTP、倍率概率、奖池水位或中奖结果。
## 基础约定
本地开发地址:
```text
http://127.0.0.1:13000
```
业务接口统一响应 envelope
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {}
}
```
| 字段 | 说明 |
| --- | --- |
| `code` | 成功固定 `OK`;失败为稳定错误码。 |
| `message` | 短错误文案,不用于客户端分支判断。 |
| `request_id` | 服务端链路追踪 ID客户端日志要记录。 |
| `data` | 成功业务数据;失败时通常不存在。 |
通用请求头:
| Header | 必填 | 说明 |
| --- | --- | --- |
| `Authorization` | 是 | `Bearer <access_token>`。 |
| `X-App-Code` | 否 | App 编码,默认 `lalu`;登录态接口最终以 token 内 app_code 为准。 |
| `Content-Type` | POST 必填 | `application/json`。 |
所有 `*_ms` 都是 Unix epoch milliseconds。金额单位都是整数金币。
## 核心概念
| 概念 | 说明 |
| --- | --- |
| `gift_id` | 用户实际送出的幸运礼物 ID例如某个 100 金币、5000 金币或 500000 金币礼物。 |
| `pool_id` | 后台配置的幸运礼物奖池 ID例如 `pool_95``pool_98`。不同 `pool_id` 的 RTP、奖池、风控和用户阶段完全隔离。 |
| `command_id` | 本次送礼动作幂等键。用户同一次点击产生一个值HTTP 超时或重试必须复用。 |
| `coin_spent` | 服务端按礼物价格和数量计算的实际扣费金额App 不上传该字段。 |
| `multiplier_ppm` | 倍率的 ppm 表达,`1x=1000000``0.5x=500000``100x=100000000`。 |
| `base_reward_coins` | 基础 RTP 返奖,计入平台成本控制。 |
| `effective_reward_coins` | 用户可见总奖励,等于基础返奖、房间气氛奖励、活动补贴之和。 |
App 不要展示后台 RTP 数值,不要展示实时概率,不要本地推算“下一次该中”。客户端只展示服务端返回的结果。
## 当前实现状态
当前仓库已经具备:
| 能力 | 状态 |
| --- | --- |
| `POST /api/v1/rooms/gift/send` 支持 `pool_id` 入参 | 已有 |
| 房间送礼 IM `room_gift_sent` 可携带 `pool_id` | 已有 |
| `activity-service` 内部 gRPC `CheckLuckyGift` / `ExecuteLuckyGiftDraw` | 已有 |
| 后台按 `pool_id` 配置多奖池 | 已有 |
Flutter 完整体验还需要后端补齐:
| 能力 | 建议 |
| --- | --- |
| App 查询某个奖池是否可抽 | 新增 HTTP `POST /api/v1/activities/lucky-gifts/check` |
| 送礼响应直接返回抽奖结果 | 在 `/rooms/gift/send` 成功 `data` 中增加 `lucky_gift` |
| 中奖房间表现 | 新增房间 IM `lucky_gift_drawn` |
| 返奖到账 | 钱包侧新增幸运礼物返奖入账后继续发 `WalletBalanceChanged` |
下面文档按最终 Flutter 契约编写;已存在字段可直接对接,标注为“建议新增”的字段需要后端继续落地。
## 客户端流程
1. 进入房间后调用 `GET /api/v1/rooms/{room_id}/gift-panel` 获取礼物面板、金币余额和收礼人。
2. 找到 `gift_type_code=lucky``gift_type_code=super_lucky` 的礼物,读取该礼物对应的 `pool_id`。当前可从 `presentation_json.lucky_pool_id` 下发,后续建议提升为顶层字段 `lucky_pool_id`
3. 用户点击幸运礼物前,可调用 `POST /api/v1/activities/lucky-gifts/check` 判断奖池是否启用。
4. 用户确认送礼时调用 `POST /api/v1/rooms/gift/send`,请求体带上 `pool_id`
5. 成功响应里如果有 `lucky_gift`,立即播放中奖或未中奖反馈。
6. 房间内监听 `room_gift_sent``lucky_gift_drawn` IM给其他用户同步展示。
7. 金币余额以 `WalletBalanceChanged` 私有 IM 或 `GET /api/v1/wallet/me/balances` 刷新为准,不在本地推算。
## 获取礼物面板
```http
GET /api/v1/rooms/{room_id}/gift-panel
Authorization: Bearer <access_token>
X-App-Code: lalu
```
路径参数:
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 当前语音房 ID。 |
成功响应节选:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {
"coin_balance": 1000000,
"recipients": [],
"tabs": [],
"gifts": [
{
"gift_id": "lucky_star_100",
"name": "幸运星",
"gift_type_code": "lucky",
"charge_asset_type": "COIN",
"coin_price": 100,
"presentation_json": "{\"lucky_pool_id\":\"pool_95\"}",
"resource": {
"asset_url": "https://cdn.example/lucky_star.png",
"animation_url": "https://cdn.example/lucky_star.svga"
}
}
],
"quantity_presets": [1, 9, 99, 999]
}
}
```
幸运礼物识别规则:
| 字段 | 说明 |
| --- | --- |
| `gift_type_code=lucky` | 普通幸运礼物。 |
| `gift_type_code=super_lucky` | 超级幸运礼物。 |
| `presentation_json.lucky_pool_id` | 建议下发的奖池 ID为空时 App 传 `default` 或不传,由服务端按默认奖池处理。 |
| `coin_price` | 单个礼物价格;实际扣费仍以服务端钱包价格为准。 |
## 检查幸运礼物状态
建议新增 App HTTP 接口:
```http
POST /api/v1/activities/lucky-gifts/check
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json
```
请求体:
```json
{
"room_id": "lalu_xxx",
"gift_id": "lucky_star_100",
"pool_id": "pool_95"
}
```
参数:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 当前房间 ID。 |
| `gift_id` | string | 是 | 用户准备送出的幸运礼物 ID。 |
| `pool_id` | string | 否 | 奖池 ID为空时服务端使用 `default`。 |
成功响应:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {
"enabled": true,
"reason": "enabled",
"pool_id": "pool_95",
"gift_id": "lucky_star_100",
"rule_version": 12,
"experience_pool": "novice",
"server_time_ms": 1779259000000
}
}
```
返回字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `enabled` | bool | 是否允许当前用户在当前房间抽该奖池。 |
| `reason` | string | 服务端原因,客户端只做日志或灰度排查。 |
| `pool_id` | string | 实际使用的奖池 ID。 |
| `gift_id` | string | 礼物 ID。 |
| `rule_version` | int64 | 当前奖池规则版本。 |
| `experience_pool` | string | `novice``intermediate``advanced`,只影响展示文案或动效强度。 |
| `server_time_ms` | int64 | 服务端当前时间。 |
客户端处理:
| 场景 | 处理 |
| --- | --- |
| `enabled=true` | 允许点击送礼。 |
| `enabled=false` | 置灰幸运抽奖入口,但普通礼物展示可继续。 |
| 接口失败 | 不在本地兜底开奖;可以提示稍后重试或隐藏幸运玩法。 |
## 送礼并抽奖
已存在 App HTTP 入口:
```http
POST /api/v1/rooms/gift/send
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json
```
请求体:
```json
{
"room_id": "lalu_xxx",
"command_id": "gift_1779259000000_10001_01",
"target_type": "user",
"target_user_ids": [10002],
"gift_id": "lucky_star_100",
"gift_count": 1,
"pool_id": "pool_95"
}
```
参数:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 当前房间 ID。 |
| `command_id` | string | 是 | 本次用户动作幂等键;同一次重试必须复用。 |
| `target_type` | string | 否 | 当前主流程使用 `user`;为空按 `user`。 |
| `target_user_ids` | int64[] | 是 | 收礼用户 ID`target_type=user` 时必须且只能 1 个。 |
| `target_user_id` | int64 | 否 | 单目标兼容字段;新代码优先用 `target_user_ids`。 |
| `gift_id` | string | 是 | 礼物 ID。 |
| `gift_count` | int32 | 是 | 礼物数量,必须大于 0。 |
| `pool_id` | string | 否 | 幸运礼物奖池 ID不同奖池完全隔离。普通礼物可不传。 |
成功响应节选:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {
"result": {
"applied": true,
"room_version": 14,
"server_time_ms": 1779259000000
},
"billing_receipt_id": "br_xxx",
"room_heat": 123456,
"gift_rank": [],
"room": {},
"treasure": {},
"lucky_gift": {
"enabled": true,
"draw_id": "lucky_draw_xxx",
"command_id": "gift_1779259000000_10001_01",
"pool_id": "pool_95",
"gift_id": "lucky_star_100",
"rule_version": 12,
"experience_pool": "novice",
"selected_tier_id": "novice_2x",
"multiplier_ppm": 2000000,
"base_reward_coins": 200,
"room_atmosphere_reward_coins": 0,
"activity_subsidy_coins": 0,
"effective_reward_coins": 200,
"reward_status": "granted",
"stage_feedback": false,
"high_multiplier": false,
"created_at_ms": 1779259000000
}
}
}
```
`lucky_gift` 字段为建议新增。普通礼物或奖池未启用时可以为空。
`data` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `result.applied` | bool | 本次命令是否实际落地;幂等重放可能为 `false`。 |
| `result.room_version` | int64 | 房间版本。 |
| `result.server_time_ms` | int64 | 服务端时间。 |
| `billing_receipt_id` | string | 钱包扣费回执;存在表示送礼扣费成功。 |
| `room_heat` | int64 | 房间热度。 |
| `gift_rank` | array | 房间礼物榜快照。 |
| `room` | object | 房间快照。 |
| `treasure` | object | 宝箱状态;如果房间宝箱开启可直接更新。 |
| `lucky_gift` | object | 幸运礼物抽奖结果;建议新增。 |
`lucky_gift` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `enabled` | bool | 本次是否执行了幸运礼物抽奖。 |
| `draw_id` | string | 抽奖记录 ID。 |
| `command_id` | string | 和送礼请求一致的幂等键。 |
| `pool_id` | string | 本次使用的奖池 ID。 |
| `gift_id` | string | 礼物 ID。 |
| `rule_version` | int64 | 抽奖时使用的规则版本。 |
| `experience_pool` | string | `novice``intermediate``advanced`。 |
| `selected_tier_id` | string | 服务端选中的奖档 ID。 |
| `multiplier_ppm` | int64 | 建议新增;用于直接展示倍率。后端暂未返回时客户端不要本地解析倍率。 |
| `base_reward_coins` | int64 | 基础 RTP 返奖。 |
| `room_atmosphere_reward_coins` | int64 | 房间气氛补贴奖励。 |
| `activity_subsidy_coins` | int64 | 活动补贴奖励。 |
| `effective_reward_coins` | int64 | 用户可见总奖励。 |
| `reward_status` | string | `pending``granted``failed`。 |
| `stage_feedback` | bool | 是否触发阶段反馈。可能没有金币奖励。 |
| `high_multiplier` | bool | 是否属于高倍结果,可用于强化动效。 |
| `created_at_ms` | int64 | 抽奖结果生成时间。 |
展示规则:
| 场景 | 处理 |
| --- | --- |
| `lucky_gift` 为空 | 按普通礼物成功展示。 |
| `effective_reward_coins=0``stage_feedback=false` | 展示未中奖或普通送礼效果。 |
| `stage_feedback=true` | 展示阶段进度、幸运值或轻量动画;不要本地加金币。 |
| `effective_reward_coins>0``reward_status=granted` | 展示中奖金额和到账效果;余额仍以钱包 IM 为准。 |
| `effective_reward_coins>0``reward_status=pending` | 展示“奖励处理中”,等待钱包 IM 或余额刷新。 |
| `reward_status=failed` | 不本地加金币;提示稍后刷新或走补偿查询。 |
| `high_multiplier=true` | 播放高倍中奖动效,并等待房间 IM 同步其他用户。 |
## 错误处理
通用错误:
| HTTP | `code` | 说明 | Flutter 处理 |
| --- | --- | --- | --- |
| 400 | `INVALID_JSON` | JSON 解析失败。 | 修正客户端请求体。 |
| 400 | `INVALID_ARGUMENT` | 参数不合法,例如缺 `room_id``gift_id``command_id`。 | 不重试,提示操作失败并打点。 |
| 401 | `UNAUTHORIZED` | token 无效或过期。 | 走登录刷新或重新登录。 |
| 403 | `PROFILE_REQUIRED` | 用户未完成资料。 | 引导补全资料。 |
| 403 | `PERMISSION_DENIED` | 没有权限。 | 提示不可操作。 |
| 409 | `INSUFFICIENT_BALANCE` | 金币不足。 | 弹充值或余额不足提示。 |
| 409 | `ROOM_CLOSED` | 房间已关闭。 | 退出或刷新房间状态。 |
| 409 | `RULE_NOT_ACTIVE` | 幸运礼物奖池未启用。 | 置灰幸运玩法,不本地开奖。 |
| 409 | `IDEMPOTENCY_CONFLICT` | 同一 `command_id` 对应的请求内容不一致。 | 生成新的 `command_id` 重新发起新动作;旧动作不再重放。 |
| 409 | `CONFLICT` | 规则、奖池、水位或风控状态冲突。 | 提示稍后再试,必要时刷新面板。 |
| 502 | `UPSTREAM_ERROR` | room、wallet、activity 等依赖暂不可用。 | 可保留同一个 `command_id` 重试同一次送礼。 |
| 500 | `INTERNAL_ERROR` | 服务端内部异常。 | 记录 `request_id`,提示稍后重试。 |
幂等和重试规则:
| 场景 | 处理 |
| --- | --- |
| HTTP 超时、断网、502 | 使用同一个 `command_id` 重试。 |
| 409 `IDEMPOTENCY_CONFLICT` | 不要复用该 `command_id`;说明客户端把同一个幂等键用于不同请求。 |
| 收到成功响应且有 `billing_receipt_id` | 扣费已成功,不要再次生成新命令补发。 |
| 成功响应丢失但钱包余额已变化 | 以钱包余额和房间 IM 为准,避免重复扣费。 |
| App 进程重启 | 本地持久化最近未完成的 `command_id`,优先用同一键查询或重试。 |
## IM房间送礼消息
已存在房间 IM
| 字段 | 值 |
| --- | --- |
| 腾讯消息类型 | `TIMCustomElem` |
| `Desc` | `room_gift_sent` |
| `Ext` / `extension` | `room_system_message` |
| 群 ID | 当前 `room_id` |
| `CloudCustomData` | 与 `TIMCustomElem.Data` 相同的 JSON 字符串 |
Payload 示例:
```json
{
"event_id": "evt_xxx",
"room_id": "lalu_xxx",
"event_type": "room_gift_sent",
"actor_user_id": 10001,
"target_user_id": 10002,
"gift_value": 100,
"room_heat": 123456,
"room_version": 14,
"attributes": {
"gift_id": "lucky_star_100",
"pool_id": "pool_95",
"gift_count": "1",
"billing_receipt_id": "br_xxx",
"coin_spent": "100",
"gift_point_added": "10",
"price_version": "pv_xxx"
}
}
```
处理规则:
| 场景 | 处理 |
| --- | --- |
| `Ext` 不是 `room_system_message` | 忽略。 |
| `event_type` 不是 `room_gift_sent` | 交给其他房间消息处理器。 |
| `event_id` 已处理 | 忽略。 |
| `room_version` 小于本地当前版本 | 忽略或只展示轻量动画,不回滚房间状态。 |
| `attributes.pool_id` 为空 | 按普通礼物处理。 |
| `attributes.pool_id` 非空 | 展示幸运礼物送出效果,但中奖结果仍等 `lucky_gift_drawn` 或 HTTP `lucky_gift`。 |
## IM幸运礼物开奖结果
建议新增房间 IM
| 字段 | 值 |
| --- | --- |
| 腾讯消息类型 | `TIMCustomElem` |
| `Desc` | `lucky_gift_drawn` |
| `Ext` / `extension` | `room_system_message` |
| 群 ID | 当前 `room_id` |
| `CloudCustomData` | 与 `TIMCustomElem.Data` 相同的 JSON 字符串 |
Payload 示例:
```json
{
"event_id": "lucky_draw_event_xxx",
"room_id": "lalu_xxx",
"event_type": "lucky_gift_drawn",
"room_version": 14,
"draw_id": "lucky_draw_xxx",
"command_id": "gift_1779259000000_10001_01",
"pool_id": "pool_95",
"gift_id": "lucky_star_100",
"gift_count": 1,
"sender_user_id": 10001,
"target_user_id": 10002,
"coin_spent": 100,
"selected_tier_id": "novice_2x",
"multiplier_ppm": 2000000,
"effective_reward_coins": 200,
"reward_status": "granted",
"stage_feedback": false,
"high_multiplier": false,
"created_at_ms": 1779259000000
}
```
字段说明:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `event_id` | string | IM 事件幂等键。 |
| `event_type` | string | 固定 `lucky_gift_drawn`。 |
| `room_id` | string | 房间 ID。 |
| `room_version` | int64 | 房间版本。 |
| `draw_id` | string | 抽奖记录 ID。 |
| `command_id` | string | 送礼幂等键。 |
| `pool_id` | string | 奖池 ID。 |
| `gift_id` | string | 礼物 ID。 |
| `gift_count` | int32 | 礼物数量。 |
| `sender_user_id` | int64 | 送礼用户。 |
| `target_user_id` | int64 | 收礼用户。 |
| `coin_spent` | int64 | 本次实际扣费。 |
| `selected_tier_id` | string | 中奖奖档。 |
| `multiplier_ppm` | int64 | 中奖倍率,建议新增。 |
| `effective_reward_coins` | int64 | 用户可见奖励金币。 |
| `reward_status` | string | `pending``granted``failed`。 |
| `stage_feedback` | bool | 是否阶段反馈。 |
| `high_multiplier` | bool | 是否高倍结果。 |
| `created_at_ms` | int64 | 事件创建时间。 |
展示规则:
| 场景 | 处理 |
| --- | --- |
| 当前用户是 `sender_user_id` | 如果 HTTP 已展示过同一 `draw_id`,只更新状态,不重复播中奖动画。 |
| 当前用户不是 `sender_user_id` | 展示房间内中奖横幅、飘屏或公屏提示。 |
| `effective_reward_coins=0` | 不展示大奖飘屏,只保留普通送礼效果或阶段反馈。 |
| `high_multiplier=true` | 展示高倍动画。 |
| `reward_status=pending` | 文案使用“奖励处理中”,不要展示已到账。 |
| JSON 解析失败或字段缺失 | 忽略并记录客户端日志。 |
## IM钱包余额变更
幸运礼物扣费和返奖都应通过钱包私有 IM 同步余额。该 IM 已在钱包文档中定义。
| 字段 | 值 |
| --- | --- |
| 腾讯消息类型 | `TIMCustomElem` |
| `Desc` | `WalletBalanceChanged` |
| `Ext` / `extension` | `wallet_notice` |
| 通道 | C2C 私有消息 |
Payload 示例:
```json
{
"event_type": "WalletBalanceChanged",
"event_id": "wev_xxx",
"app_code": "lalu",
"transaction_id": "tx_xxx",
"command_id": "gift_1779259000000_10001_01",
"user_id": "10001",
"asset_type": "COIN",
"available_delta": -100,
"frozen_delta": 0,
"available_after": 999900,
"frozen_after": 0,
"balance_version": 18,
"created_at_ms": 1779259000000,
"source_created_at_ms": 1779259000000,
"metadata": {
"biz_type": "gift_debit",
"gift_id": "lucky_star_100",
"pool_id": "pool_95"
}
}
```
返奖到账时 `available_delta` 为正数,`metadata.biz_type` 建议使用 `lucky_gift_reward`
处理规则:
| 场景 | 处理 |
| --- | --- |
| `Ext` 不是 `wallet_notice` | 忽略。 |
| `event_type` 不是 `WalletBalanceChanged` | 忽略。 |
| `event_id` 已处理 | 忽略。 |
| 本地 `asset_type` 的版本 `>= balance_version` | 忽略。 |
| 长时间没有收到余额 IM | 调 `GET /api/v1/wallet/me/balances` 兜底刷新。 |
## IM高倍全站或区域播报
建议新增播报 IM用于高倍中奖或运营配置的大额中奖展示。
| 字段 | 值 |
| --- | --- |
| 腾讯消息类型 | `TIMCustomElem` |
| `Desc` | `lucky_gift_big_win` |
| `Ext` / `extension` | `im_broadcast` |
| 群 ID | 全站播报群或区域播报群 |
Payload 示例:
```json
{
"event_id": "lucky_big_win_xxx",
"broadcast_type": "lucky_gift_big_win",
"scope": "region",
"app_code": "lalu",
"region_id": 1001,
"room_id": "lalu_xxx",
"sender_user_id": 10001,
"target_user_id": 10002,
"pool_id": "pool_95",
"gift_id": "lucky_star_100",
"multiplier_ppm": 100000000,
"effective_reward_coins": 10000,
"sent_at_ms": 1779259000000,
"action": {
"type": "enter_room",
"room_id": "lalu_xxx"
}
}
```
处理规则:
| 场景 | 处理 |
| --- | --- |
| 当前用户已在该房间且已收到 `lucky_gift_drawn` | 可以只展示一次,避免重复刷屏。 |
| 当前用户不在该房间 | 展示全站或区域飘屏,点击 `action` 进房。 |
| `event_id` 已处理 | 忽略。 |
## App 最小对接顺序
1. 登录后调用 `GET /api/v1/im/usersig`,登录腾讯云 IM。
2. 进入房间后加入房间 IM 群,并调用 `GET /api/v1/rooms/{room_id}/gift-panel`
3. 从礼物面板识别幸运礼物和对应 `pool_id`
4. 用户点击送礼时生成 `command_id`,调用 `POST /api/v1/rooms/gift/send`
5. 使用 HTTP 响应里的 `lucky_gift` 做当前用户即时反馈。
6. 监听房间 IM `room_gift_sent``lucky_gift_drawn`,同步其他用户看到的表现。
7. 监听 C2C IM `WalletBalanceChanged`,更新金币余额。
8. 任意 IM 丢失、页面重进或余额不一致时,重新拉取礼物面板和钱包余额纠正状态。

View File

@ -0,0 +1,497 @@
# 用户排行榜 Flutter 对接
本文描述 Flutter App 对接用户排行榜、送礼榜、收礼榜和房间榜的 HTTP 接口。榜单事实来自 `wallet-service` 已成功的礼物扣费流水;客户端只展示 gateway 返回的榜单投影,不在本地重算排名、礼物价值或统计窗口。
## 基础约定
本地开发地址:
```text
http://127.0.0.1:13000
```
业务接口统一响应 envelope
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {}
}
```
| 字段 | 说明 |
| --- | --- |
| `code` | 成功固定 `OK`;失败为稳定错误码。 |
| `message` | 短错误文案,不用于客户端分支判断。 |
| `request_id` | 服务端链路追踪 ID客户端日志要记录。 |
| `data` | 成功业务数据;失败时通常不存在。 |
通用请求头:
| Header | 必填 | 说明 |
| --- | --- | --- |
| `Authorization` | 是 | `Bearer <access_token>`。 |
| `X-App-Code` | 否 | App 编码,默认 `lalu`;登录态接口最终以 token 内 app_code 为准。 |
所有 `*_ms` 都是 Unix epoch milliseconds。榜单统计窗口按 UTC 计算,不按用户本地时区、设备时区或 IP 推断时区计算。
## 榜单接口
```http
GET /api/v1/activities/user-leaderboards
Authorization: Bearer <access_token>
X-App-Code: lalu
```
Query 参数:
| 字段 | 类型 | 必填 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| `board_type` | string | 否 | `sent` | 榜单类型。`sent` 是送礼榜;`received` 是收礼榜;`room` 是房间收礼榜。 |
| `period` | string | 否 | `today` | 统计周期。`today``week``month`。 |
| `page` | int32 | 否 | `1` | 页码,从 1 开始。必须大于 0。 |
| `page_size` | int32 | 否 | `20` | 每页数量。必须大于 0服务端最大返回 `100`。 |
`board_type` 兼容别名:
| 标准值 | 兼容传参 | 说明 |
| --- | --- | --- |
| `sent` | 空字符串、`send``sender``gift_sent``user_sent` | 按送礼用户聚合,展示用户送礼贡献。 |
| `received` | `receive``receiver``gift_received``user_received` | 按收礼用户聚合,展示用户收礼魅力。 |
| `room` | `rooms``room_gift``room_gifts` | 按房间聚合,展示房间礼物流水。 |
`period` 兼容别名:
| 标准值 | 兼容传参 | 统计窗口 |
| --- | --- | --- |
| `today` | 空字符串、`day``daily` | UTC 当日 00:00:00 到当前服务端时间。 |
| `week` | `weekly` | UTC 本周周一 00:00:00 到当前服务端时间。 |
| `month` | `monthly` | UTC 本月 1 日 00:00:00 到当前服务端时间。 |
服务端实际查询范围是 `[start_at_ms, end_at_ms)`,其中 `end_at_ms` 是本次请求的当前服务端时间。排序固定为 `gift_value DESC, last_gift_at_ms DESC, subject_id ASC`;礼物价值相同的情况下,最近送礼时间更晚的排名更靠前,再用用户 ID 或房间 ID 保证稳定顺序。
### 送礼榜
```http
GET /api/v1/activities/user-leaderboards?board_type=sent&period=today&page=1&page_size=20
Authorization: Bearer <access_token>
X-App-Code: lalu
```
送礼榜按 `sender_user_id` 聚合。返回项里的 `user_id``user` 表示送礼用户。
### 收礼榜
```http
GET /api/v1/activities/user-leaderboards?board_type=received&period=week&page=1&page_size=20
Authorization: Bearer <access_token>
X-App-Code: lalu
```
收礼榜按 `target_user_id` 聚合。返回项里的 `user_id``user` 表示收礼用户。
### 房间榜
```http
GET /api/v1/activities/user-leaderboards?board_type=room&period=month&page=1&page_size=20
Authorization: Bearer <access_token>
X-App-Code: lalu
```
房间榜按 `room_id` 聚合。返回项里不会返回 `user``my_rank``room` 会补充房间短号、名称和头像字段,客户端头像优先读 `cover_url`,兼容字段为 `room_avatar`
## 房间内贡献榜边界
本接口是全局活动榜,按 `board_type + period` 查询成功送礼流水。房间页里的当前房间送礼贡献榜不是通过本接口读取:
| 场景 | 接口 | 榜单字段 | 说明 |
| --- | --- | --- | --- |
| 首次进房 | `POST /api/v1/rooms/join` | `data.contribution_rank` | JoinRoom 成功后返回当前房间贡献榜前 10。 |
| 房间页重拉快照 | `GET /api/v1/rooms/snapshot?room_id={room_id}` | `data.contribution_rank` | 只读 Room Cell/snapshot不刷新 presence。 |
| 房间详情首屏 | `GET /api/v1/rooms/{room_id}/detail` | `data.rank_top` | 房间详情聚合版,额外补 viewer 权限、IM/RTC 和资料。 |
房间内贡献榜字段是 `user_id``score``gift_value``updated_at_ms`,只代表当前房间内的本地贡献排名;它不等同于用户送礼榜或收礼榜,也不能和本接口的 `my_rank` 混用。
## 成功返回
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"items": [
{
"rank": 1,
"user_id": "10001",
"gift_value": 188800,
"gift_count": 236,
"transaction_count": 42,
"last_gift_at_ms": 1779259000000,
"user": {
"user_id": "10001",
"display_user_id": "888888",
"username": "Luna",
"avatar": "https://cdn.example/avatar/10001.png"
}
}
],
"total": 168,
"page": 1,
"page_size": 20,
"board_type": "sent",
"period": "today",
"start_at_ms": 1779206400000,
"end_at_ms": 1779259000000,
"server_time_ms": 1779259000000,
"my_rank": {
"rank": 12,
"user_id": "10099",
"gift_value": 9200,
"gift_count": 18,
"transaction_count": 6,
"last_gift_at_ms": 1779258600000,
"user": {
"user_id": "10099",
"display_user_id": "10099",
"username": "Me",
"avatar": "https://cdn.example/avatar/me.png"
}
}
}
}
```
房间榜返回示例:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_room",
"data": {
"items": [
{
"rank": 1,
"room_id": "lalu_534d076a-7cbd-4b23-a297-0c10a3c2fa72",
"gift_value": 660000,
"gift_count": 580,
"transaction_count": 95,
"last_gift_at_ms": 1779259000000,
"room": {
"room_id": "lalu_534d076a-7cbd-4b23-a297-0c10a3c2fa72"
}
}
],
"total": 20,
"page": 1,
"page_size": 20,
"board_type": "room",
"period": "month",
"start_at_ms": 1777564800000,
"end_at_ms": 1779259000000,
"server_time_ms": 1779259000000
}
}
```
`data` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `items` | array | 当前页榜单项。空数组表示当前页无数据。 |
| `total` | int64 | 当前榜单和周期下的总上榜主体数。 |
| `page` | int32 | 服务端解析后的页码。 |
| `page_size` | int32 | 服务端实际使用的每页数量;请求超过 100 时返回 100。 |
| `board_type` | string | 服务端归一化后的榜单类型:`sent``received``room`。 |
| `period` | string | 服务端归一化后的统计周期:`today``week``month`。 |
| `start_at_ms` | int64 | 本次统计窗口开始时间UTC。 |
| `end_at_ms` | int64 | 本次统计窗口结束时间,等于请求时的当前服务端时间。 |
| `server_time_ms` | int64 | 服务端当前时间,可用于前端展示“更新时间”。 |
| `my_rank` | object? | 当前登录用户在送礼榜或收礼榜中的排名;未上榜或 `board_type=room` 时不返回。 |
`items[]` / `my_rank` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `rank` | int64 | 排名,从 1 开始。 |
| `user_id` | string | 用户榜单主体 ID`sent``received` 才有。 |
| `room_id` | string | 房间榜单主体 ID`room` 才有。 |
| `gift_value` | int64 | 统计窗口内礼物价值总和,用于排名和主数值展示。 |
| `gift_count` | int64 | 统计窗口内礼物数量总和。 |
| `transaction_count` | int64 | 统计窗口内成功送礼扣费流水数。 |
| `last_gift_at_ms` | int64 | 该主体最近一次成功送礼流水时间。 |
| `user` | object? | 用户资料;正常会补充展示 ID、昵称和头像本地未注入用户资料 client 时至少返回 `user_id`。 |
| `room` | object? | 房间基础投影;房间榜返回 `room_id``room_short_id``title``cover_url``room_avatar`。 |
`user` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `user_id` | string | 用户长 ID字符串格式返回避免 Flutter/JS 精度问题。 |
| `display_user_id` | string | 展示 ID可优先用于 UI 展示。 |
| `username` | string | 用户昵称。 |
| `avatar` | string | 头像 URL。 |
## Flutter 解析示例
```dart
class ApiEnvelope<T> {
ApiEnvelope({
required this.code,
required this.message,
required this.requestId,
this.data,
});
final String code;
final String message;
final String requestId;
final T? data;
factory ApiEnvelope.fromJson(
Map<String, dynamic> json,
T Function(Object? raw) parseData,
) {
return ApiEnvelope<T>(
code: json['code'] as String? ?? '',
message: json['message'] as String? ?? '',
requestId: json['request_id'] as String? ?? '',
data: json.containsKey('data') ? parseData(json['data']) : null,
);
}
}
class UserLeaderboardPage {
UserLeaderboardPage({
required this.items,
required this.total,
required this.page,
required this.pageSize,
required this.boardType,
required this.period,
required this.startAtMs,
required this.endAtMs,
required this.serverTimeMs,
this.myRank,
});
final List<UserLeaderboardItem> items;
final int total;
final int page;
final int pageSize;
final String boardType;
final String period;
final int startAtMs;
final int endAtMs;
final int serverTimeMs;
final UserLeaderboardItem? myRank;
factory UserLeaderboardPage.fromJson(Map<String, dynamic> json) {
return UserLeaderboardPage(
items: (json['items'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(UserLeaderboardItem.fromJson)
.toList(),
total: (json['total'] as num?)?.toInt() ?? 0,
page: (json['page'] as num?)?.toInt() ?? 1,
pageSize: (json['page_size'] as num?)?.toInt() ?? 20,
boardType: json['board_type'] as String? ?? 'sent',
period: json['period'] as String? ?? 'today',
startAtMs: (json['start_at_ms'] as num?)?.toInt() ?? 0,
endAtMs: (json['end_at_ms'] as num?)?.toInt() ?? 0,
serverTimeMs: (json['server_time_ms'] as num?)?.toInt() ?? 0,
myRank: json['my_rank'] is Map<String, dynamic>
? UserLeaderboardItem.fromJson(json['my_rank'] as Map<String, dynamic>)
: null,
);
}
}
class UserLeaderboardItem {
UserLeaderboardItem({
required this.rank,
required this.giftValue,
required this.giftCount,
required this.transactionCount,
required this.lastGiftAtMs,
this.userId,
this.roomId,
this.user,
this.room,
});
final int rank;
final String? userId;
final String? roomId;
final int giftValue;
final int giftCount;
final int transactionCount;
final int lastGiftAtMs;
final LeaderboardUser? user;
final LeaderboardRoom? room;
factory UserLeaderboardItem.fromJson(Map<String, dynamic> json) {
return UserLeaderboardItem(
rank: (json['rank'] as num?)?.toInt() ?? 0,
userId: json['user_id'] as String?,
roomId: json['room_id'] as String?,
giftValue: (json['gift_value'] as num?)?.toInt() ?? 0,
giftCount: (json['gift_count'] as num?)?.toInt() ?? 0,
transactionCount: (json['transaction_count'] as num?)?.toInt() ?? 0,
lastGiftAtMs: (json['last_gift_at_ms'] as num?)?.toInt() ?? 0,
user: json['user'] is Map<String, dynamic>
? LeaderboardUser.fromJson(json['user'] as Map<String, dynamic>)
: null,
room: json['room'] is Map<String, dynamic>
? LeaderboardRoom.fromJson(json['room'] as Map<String, dynamic>)
: null,
);
}
}
class LeaderboardUser {
LeaderboardUser({
required this.userId,
required this.displayUserId,
required this.username,
required this.avatar,
});
final String userId;
final String displayUserId;
final String username;
final String avatar;
factory LeaderboardUser.fromJson(Map<String, dynamic> json) {
return LeaderboardUser(
userId: json['user_id'] as String? ?? '',
displayUserId: json['display_user_id'] as String? ?? '',
username: json['username'] as String? ?? '',
avatar: json['avatar'] as String? ?? '',
);
}
}
class LeaderboardRoom {
LeaderboardRoom({
required this.roomId,
required this.roomShortId,
required this.title,
required this.coverUrl,
required this.roomAvatar,
});
final String roomId;
final String roomShortId;
final String title;
final String coverUrl;
final String roomAvatar;
factory LeaderboardRoom.fromJson(Map<String, dynamic> json) {
return LeaderboardRoom(
roomId: json['room_id'] as String? ?? '',
roomShortId: json['room_short_id'] as String? ?? '',
title: json['title'] as String? ?? '',
coverUrl: json['cover_url'] as String? ?? '',
roomAvatar: json['room_avatar'] as String? ?? '',
);
}
}
```
请求封装示例:
```dart
enum LeaderboardType { sent, received, room }
enum LeaderboardPeriod { today, week, month }
extension on LeaderboardType {
String get wireName => name;
}
extension on LeaderboardPeriod {
String get wireName => name;
}
Future<UserLeaderboardPage> fetchUserLeaderboard({
required Dio dio,
required LeaderboardType type,
required LeaderboardPeriod period,
int page = 1,
int pageSize = 20,
}) async {
final response = await dio.get<Map<String, dynamic>>(
'/api/v1/activities/user-leaderboards',
queryParameters: {
'board_type': type.wireName,
'period': period.wireName,
'page': page,
'page_size': pageSize,
},
);
final envelope = ApiEnvelope<UserLeaderboardPage>.fromJson(
response.data ?? const <String, dynamic>{},
(raw) => UserLeaderboardPage.fromJson(raw as Map<String, dynamic>),
);
if (response.statusCode != 200 || envelope.code != 'OK' || envelope.data == null) {
throw ApiException(
code: envelope.code,
message: envelope.message,
requestId: envelope.requestId,
);
}
return envelope.data!;
}
class ApiException implements Exception {
ApiException({
required this.code,
required this.message,
required this.requestId,
});
final String code;
final String message;
final String requestId;
@override
String toString() => 'ApiException($code, requestId: $requestId)';
}
```
## 错误处理
所有响应先解析外层 envelope。只有 HTTP status 为 `200``code == "OK"` 时读取 `data`;其他情况不要使用 `data`
错误响应示例:
```json
{
"code": "INVALID_ARGUMENT",
"message": "invalid argument",
"request_id": "req_bad"
}
```
| HTTP | `code` | 场景 | Flutter 处理 |
| --- | --- | --- | --- |
| `400` | `INVALID_ARGUMENT` | `board_type``period` 不支持;`page``page_size` 不是正整数。 | 不重试;修正本地入参,客户端只发送枚举值。 |
| `401` | `UNAUTHORIZED` | access token 缺失、过期或非法。 | 跳登录或刷新登录态;记录 `request_id`。 |
| `401` | `SESSION_REVOKED` | 会话已被服务端撤销。 | 清理本地登录态并重新登录。 |
| `403` | `PROFILE_REQUIRED` | 当前 token 对应用户未完成资料。 | 跳资料补全流程。 |
| `405` | `INVALID_ARGUMENT` | 使用了非 GET 方法。 | 修正客户端请求方法。 |
| `502` | `UPSTREAM_ERROR` | 钱包库、用户资料服务或上游依赖异常。 | 展示通用失败,可保留旧榜单并允许下拉重试;记录 `request_id`。 |
| `503` | `UPSTREAM_ERROR` | gateway 未配置榜单钱包只读库。 | 展示通用失败,记录 `request_id`。 |
## 客户端展示规则
榜单页建议用 `board_type + period + page + page_size` 做缓存 key。用户切换榜单类型或周期时重新请求第一页上拉加载下一页时用返回的 `total` 判断是否还有更多:`page * page_size < total`
`my_rank` 只表示当前登录用户在当前用户榜单中的排名。送礼榜里是“我的送礼排名”,收礼榜里是“我的收礼排名”;房间榜没有个人排名。未上榜时 `my_rank` 不存在,客户端展示“未上榜”即可,不要自行用 `items` 推断。
时间展示用 `server_time_ms``end_at_ms` 标注“更新于”。`today``week``month` 的统计口径固定为 UTC展示文案如果需要本地化只能改时间格式不能改变请求周期含义。
榜单数据当前没有单独 IM 推送。用户送礼后如果当前页面正在展示对应榜单,可以延迟刷新第一页或由用户手动下拉刷新;不要依赖房间公屏 IM 来本地累加全局榜单。

View File

@ -0,0 +1,554 @@
# 礼物 Tab Flutter 对接
本文描述 Flutter App 在语音房内接入礼物 Tab 的接口、字段、交互和 IM 同步规则。礼物配置、价格、扣费、房间表现和统计事实都以后端为准App 只展示 gateway 返回的数据,不在本地推算价格、余额、热度或礼物榜。
## 边界
- 礼物 Tab 基础配置入口是 `GET /api/v1/gift-tabs`,不需要 `room_id`,只返回后台启用的 Tab 配置。
- `GET /api/v1/gift-tabs` 不按礼物数量过滤;只要后台启用了 Tab即使该 Tab 下当前没有礼物也必须返回给 App 展示。
- `GET /api/v1/gift-tabs` 会按 Tab 返回当前 active 礼物列表App 可以用它提前预加载礼物资源;无礼物的 Tab 返回 `gifts: []`
- 房间送礼面板入口是 `GET /api/v1/rooms/{room_id}/gift-panel`,返回当前房间可送礼物、可收礼人、金币余额和房间内可展示 Tab。
- `GET /api/v1/gift-tabs` 不能替代 `GET /api/v1/rooms/{room_id}/gift-panel`App 真正送礼前必须以房间面板返回的数据为准。
- 送礼只调用 `POST /api/v1/rooms/gift/send`
- 金币余额以礼物面板、送礼响应后的钱包通知或 `GET /api/v1/wallet/me/balances` 为准。
- 房间内其他用户看到的礼物表现以腾讯云 IM 自定义消息 `room_gift_sent` 为准。
- `command_id` 是送礼幂等键;同一次用户点击重试必须复用同一个值。
- 当前 App 送礼主流程只按单个用户收礼处理:`target_type=user``target_user_ids` 只传 1 个用户。
## 交互流程
1. 用户进入房间并完成 `JoinRoom`
2. App 可在启动或进入房间前调用 `GET /api/v1/gift-tabs` 预加载 Tab 配置。
3. 打开礼物 Tab 时调用 `GET /api/v1/rooms/{room_id}/gift-panel`
4. 用 `tabs` 渲染当前房间分类,用 `gifts` 渲染礼物网格,用 `recipients` 渲染收礼人。
5. 用户选择收礼人、礼物和数量。
6. 点击发送时生成稳定 `command_id`,调用 `POST /api/v1/rooms/gift/send`
7. 发送成功后用响应里的 `room_heat``gift_rank``treasure` 更新房间 UI。
8. 监听房间 IM `room_gift_sent`,同步播放礼物动效。
9. 监听私有钱包通知或主动刷新余额,不本地扣减余额作为最终值。
## 获取礼物 Tab 配置
```http
GET /api/v1/gift-tabs
X-App-Code: lalu
```
查询参数:
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `region_id` | int64 | 否 | 不传时预加载全部 active 礼物;传入时只返回 GLOBAL 和该区域可用礼物。 |
成功响应:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"items": [
{
"key": "normal",
"gift_type_code": "normal",
"name": "普通礼物",
"label": "Gift",
"tab_key": "Gift",
"status": "active",
"order": 10,
"sort_order": 10,
"created_at_ms": 1710000000000,
"updated_at_ms": 1710000000000,
"gifts": [
{
"gift_id": "rose",
"resource_id": 11,
"gift_type_code": "normal",
"name": "Rose",
"coin_price": 100,
"resource": {
"resource_id": 11,
"resource_code": "gift_rose",
"resource_type": "gift",
"name": "Rose",
"status": "active",
"asset_url": "https://cdn.example/rose.png"
}
}
]
}
],
"total": 1
}
}
```
字段说明:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `key` | string | Tab 稳定 key等同于礼物类型编码。 |
| `gift_type_code` | string | 礼物类型编码,用于和 `gifts[].gift_type_code` 对齐。 |
| `name` | string | 后台配置的中文显示名。 |
| `label` | string | App Tab 展示名,来源于后台 `tab_key`。 |
| `tab_key` | string | 后台配置的 Tab Name。 |
| `status` | string | 当前只返回启用中的配置,正常为 `active`。 |
| `order` | int | App 排序值,等同于 `sort_order`。 |
| `sort_order` | int | 后台配置排序值。 |
| `created_at_ms` | int64 | 创建时间Unix epoch milliseconds。 |
| `updated_at_ms` | int64 | 更新时间Unix epoch milliseconds。 |
| `gifts` | array | 当前 Tab 下的 active 礼物列表;没有礼物时返回空数组。 |
## 获取礼物面板
```http
GET /api/v1/rooms/{room_id}/gift-panel
Authorization: Bearer <access_token>
X-App-Code: lalu
```
路径参数:
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 当前语音房 ID。 |
成功响应:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"coin_balance": 1000000,
"recipients": [
{
"target_type": "user",
"user_id": "10002",
"seat_no": 1,
"label": "Alice",
"profile": {
"user_id": "10002",
"username": "Alice",
"avatar": "https://cdn.example/avatar.png",
"display_user_id": "10002",
"vip": {},
"level": {},
"badges": [],
"avatar_frame": {},
"vehicle": {},
"charm": 0
}
}
],
"tabs": [
{
"key": "all",
"order": 0
},
{
"key": "normal",
"gift_type_code": "normal",
"label": "normal",
"order": 10
}
],
"gifts": [
{
"gift_id": "rose",
"resource_id": 11,
"resource": {
"resource_id": 11,
"resource_code": "gift_rose",
"resource_type": "gift",
"name": "Rose",
"status": "active",
"grantable": true,
"grant_strategy": "increase_quantity",
"usage_scopes": ["gift"],
"asset_url": "https://cdn.example/rose.png",
"preview_url": "",
"animation_url": "https://cdn.example/rose.svga",
"metadata_json": "{}",
"sort_order": 10,
"created_at_ms": 1710000000000,
"updated_at_ms": 1710000000000
},
"status": "active",
"name": "Rose",
"sort_order": 10,
"presentation_json": "{}",
"price_version": "v1",
"gift_type_code": "normal",
"charge_asset_type": "COIN",
"coin_price": 100,
"gift_point_amount": 100,
"heat_value": 100,
"effect_types": ["static", "svga"],
"region_ids": [688],
"created_at_ms": 1710000000000,
"updated_at_ms": 1710000000000
}
],
"quantity_presets": [1, 9, 99, 999]
}
}
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| `coin_balance` | 当前登录用户 COIN 可用余额快照。只用于打开面板时展示,最终余额仍以后续钱包事实为准。 |
| `recipients` | 当前房间可收礼人。当前主流程只启用 `target_type=user` 的单个用户。 |
| `tabs` | 礼物分类。`key=all` 是全量分类;其他 tab 通常对应 `gift_type_code`。 |
| `gifts` | 当前房间国家维度可用的 active 礼物列表。 |
| `quantity_presets` | 数量快捷档位,当前固定 `[1,9,99,999]`。 |
礼物字段:
| 字段 | 说明 |
| --- | --- |
| `gift_id` | 送礼请求使用的礼物 ID。 |
| `name` | 展示名称。 |
| `gift_type_code` | 分类编码,例如 `normal``lucky``super_lucky`。 |
| `charge_asset_type` | 当前收费资产;普通金币礼物为 `COIN`。 |
| `coin_price` | 单个礼物金币价格。仅用于展示和按钮预估,扣费以后端为准。 |
| `gift_point_amount` | 主播收到的礼物积分增量口径。 |
| `heat_value` | 房间热度增量口径。 |
| `price_version` | 后端价格版本。App 不需要上传。 |
| `presentation_json` | 展示扩展 JSON例如幸运礼物奖池 ID、角标、动效配置解析失败时按空对象处理。 |
| `effect_types` | 可用动效类型。App 根据本地能力选择图片、SVGA、Lottie 或兜底静态图。 |
| `resource.asset_url` | 礼物图标。 |
| `resource.preview_url` | 预览图,可为空。 |
| `resource.animation_url` | 礼物动效资源,可为空。 |
| `region_ids` | 后端配置的可用区域面板接口已经按房间区域过滤App 不再二次过滤。 |
## Tab 渲染规则
- `tabs``order` 升序展示。
- `key=all` 展示全部 `gifts`
- 非 all tab 用 `tab.gift_type_code``tab.key` 过滤 `gift.gift_type_code`
- 礼物网格按 `gift.sort_order` 升序展示;相同排序保持接口返回顺序。
- `coin_price * selected_count > coin_balance` 时发送按钮置灰或引导充值,但最终仍以送礼接口错误为准。
- `resource.animation_url` 只在发送成功或收到 IM 后播放,不要在列表滚动中自动播放重动效。
## 送礼
```http
POST /api/v1/rooms/gift/send
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json
```
请求体:
```json
{
"room_id": "lalu_room_001",
"command_id": "gift_1779259000000_10001_rose_01",
"target_type": "user",
"target_user_ids": [10002],
"gift_id": "rose",
"gift_count": 9
}
```
参数:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 当前房间 ID。 |
| `command_id` | string | 是 | 本次送礼动作幂等键。同一次失败重试必须复用。 |
| `target_type` | string | 否 | 当前传 `user`;为空后端按 `user`。 |
| `target_user_ids` | int64[] | 是 | 当前只能传 1 个收礼用户。 |
| `target_user_id` | int64 | 否 | 旧单目标兼容字段;新代码优先用 `target_user_ids`。 |
| `gift_id` | string | 是 | 礼物 ID。 |
| `gift_count` | int32 | 是 | 礼物数量,必须大于 0。 |
| `pool_id` | string | 否 | 幸运礼物奖池 ID普通礼物不传。 |
成功响应:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"result": {
"applied": true,
"room_version": 14,
"server_time_ms": 1779259000000
},
"billing_receipt_id": "br_abc",
"room_heat": 123456,
"gift_rank": [
{
"user_id": "10001",
"score": 900,
"gift_value": 900,
"updated_at_ms": 1779259000000
}
],
"room": {},
"treasure": {}
}
}
```
响应字段:
| 字段 | 说明 |
| --- | --- |
| `result.applied` | 本次命令是否实际落地。重复提交同一个 `command_id` 时可能返回 `false`App 不要重复播放发送者本地扣费动画。 |
| `result.room_version` | 房间版本,可用于丢弃旧房间快照。 |
| `result.server_time_ms` | 服务端时间。 |
| `billing_receipt_id` | 钱包扣费回执;非空表示扣费和房间表现已提交。 |
| `room_heat` | 最新房间热度。 |
| `gift_rank` | 最新房间礼物榜。 |
| `room` | 房间快照字段较大App 可按已有房间快照解析逻辑局部更新。 |
| `treasure` | 房间宝箱状态;宝箱 UI 以该字段或宝箱 IM 事件更新。 |
发送后处理:
- 成功后立即关闭 loading保留礼物面板或按产品交互关闭。
- 用 `room_heat``gift_rank` 更新房间顶部和贡献榜。
- 播放当前用户送礼动效时按 `command_id` 去重,避免 HTTP 响应和 IM 各播放一次。
- 不要本地直接写最终余额;等待 `WalletBalanceChanged` 私有 IM 或主动刷新余额。
## 房间 IM 同步
送礼成功后,房间群会收到腾讯云 IM 自定义消息:
| 字段 | 值 |
| --- | --- |
| `MsgContent.Desc` | `room_gift_sent` |
| `Data.event_type` | `room_gift_sent` |
当前消息核心字段来自 room outbox
```json
{
"event_type": "room_gift_sent",
"event_id": "room_event_xxx",
"room_id": "lalu_room_001",
"room_version": 14,
"occurred_at_ms": 1779259000000,
"actor_user_id": "10001",
"target_user_id": "10002",
"gift_value": 900,
"attributes": {
"gift_id": "rose",
"gift_count": "9",
"billing_receipt_id": "br_abc"
}
}
```
处理规则:
- 按 `event_id` 去重。
- `event_type != room_gift_sent` 时交给其他房间消息处理器。
- `actor_user_id` 是送礼人,`target_user_id` 是收礼人。
- `attributes.gift_id` 对应面板里的礼物 ID本地找不到配置时展示兜底礼物图标。
- `attributes.gift_count` 当前是字符串Flutter 解析时需要 `int.tryParse`
- `gift_value` 是本次房间价值/热度口径,不等同于客户端自己算的 `coin_price * count`
- 自己发送的礼物如果已经由 HTTP 成功响应播放过,收到同一 `command_id` 或短时间同一礼物 IM 时要做播放去重。
## 错误处理
外层 envelope
```json
{
"code": "INVALID_ARGUMENT",
"message": "invalid argument",
"request_id": "req_abc",
"data": null
}
```
处理规则:
| 场景 | 处理 |
| --- | --- |
| `UNAUTHENTICATED` | 重新登录或刷新 token。 |
| `INVALID_ARGUMENT` | 参数错误;记录 `request_id`,检查 `room_id``gift_id`、数量和收礼人。 |
| `NOT_FOUND` | 房间或礼物不存在;刷新房间和礼物面板。 |
| `FAILED_PRECONDITION` | 用户不在房间、房间不可送礼或状态不允许;刷新房间详情。 |
| `INSUFFICIENT_BALANCE` | 金币不足;跳转充值或刷新余额。 |
| `CONFLICT` | 幂等冲突或命令状态冲突;复用原 `command_id` 查询/重试,不生成新命令导致重复送礼。 |
| `UPSTREAM_ERROR` / 5xx | 保留原 `command_id` 允许用户重试。 |
## Flutter 数据模型示例
```dart
class GiftPanel {
GiftPanel({
required this.coinBalance,
required this.recipients,
required this.tabs,
required this.gifts,
required this.quantityPresets,
});
final int coinBalance;
final List<GiftRecipient> recipients;
final List<GiftTab> tabs;
final List<GiftItem> gifts;
final List<int> quantityPresets;
factory GiftPanel.fromJson(Map<String, dynamic> json) {
return GiftPanel(
coinBalance: (json['coin_balance'] as num?)?.toInt() ?? 0,
recipients: (json['recipients'] as List<dynamic>? ?? const [])
.map((item) => GiftRecipient.fromJson(item as Map<String, dynamic>))
.toList(),
tabs: (json['tabs'] as List<dynamic>? ?? const [])
.map((item) => GiftTab.fromJson(item as Map<String, dynamic>))
.toList(),
gifts: (json['gifts'] as List<dynamic>? ?? const [])
.map((item) => GiftItem.fromJson(item as Map<String, dynamic>))
.toList(),
quantityPresets: (json['quantity_presets'] as List<dynamic>? ?? const [])
.map((item) => (item as num).toInt())
.toList(),
);
}
}
class GiftRecipient {
GiftRecipient({
required this.targetType,
required this.userId,
required this.seatNo,
required this.label,
});
final String targetType;
final String userId;
final int seatNo;
final String label;
factory GiftRecipient.fromJson(Map<String, dynamic> json) {
return GiftRecipient(
targetType: json['target_type'] as String? ?? '',
userId: json['user_id'] as String? ?? '',
seatNo: (json['seat_no'] as num?)?.toInt() ?? 0,
label: json['label'] as String? ?? '',
);
}
}
class GiftTab {
GiftTab({
required this.key,
required this.giftTypeCode,
required this.label,
required this.order,
});
final String key;
final String giftTypeCode;
final String label;
final int order;
factory GiftTab.fromJson(Map<String, dynamic> json) {
return GiftTab(
key: json['key'] as String? ?? '',
giftTypeCode: json['gift_type_code'] as String? ?? '',
label: json['label'] as String? ?? '',
order: (json['order'] as num?)?.toInt() ?? 0,
);
}
}
class GiftItem {
GiftItem({
required this.giftId,
required this.name,
required this.giftTypeCode,
required this.coinPrice,
required this.assetUrl,
required this.animationUrl,
required this.presentationJson,
});
final String giftId;
final String name;
final String giftTypeCode;
final int coinPrice;
final String assetUrl;
final String animationUrl;
final String presentationJson;
factory GiftItem.fromJson(Map<String, dynamic> json) {
final resource = json['resource'] as Map<String, dynamic>? ?? const {};
return GiftItem(
giftId: json['gift_id'] as String? ?? '',
name: json['name'] as String? ?? '',
giftTypeCode: json['gift_type_code'] as String? ?? '',
coinPrice: (json['coin_price'] as num?)?.toInt() ?? 0,
assetUrl: resource['asset_url'] as String? ?? '',
animationUrl: resource['animation_url'] as String? ?? '',
presentationJson: json['presentation_json'] as String? ?? '{}',
);
}
}
```
## Flutter 调用示例
```dart
Future<GiftPanel> fetchGiftPanel({
required Dio dio,
required String roomId,
}) async {
final response = await dio.get('/api/v1/rooms/$roomId/gift-panel');
final body = response.data as Map<String, dynamic>;
if (body['code'] != 'OK') {
throw StateError('${body['code']}: ${body['message']}');
}
return GiftPanel.fromJson(body['data'] as Map<String, dynamic>);
}
Future<Map<String, dynamic>> sendGift({
required Dio dio,
required String roomId,
required String commandId,
required int targetUserId,
required String giftId,
required int giftCount,
String? poolId,
}) async {
final response = await dio.post(
'/api/v1/rooms/gift/send',
data: {
'room_id': roomId,
'command_id': commandId,
'target_type': 'user',
'target_user_ids': [targetUserId],
'gift_id': giftId,
'gift_count': giftCount,
if (poolId != null && poolId.isNotEmpty) 'pool_id': poolId,
},
);
final body = response.data as Map<String, dynamic>;
if (body['code'] != 'OK') {
throw StateError('${body['code']}: ${body['message']}');
}
return body['data'] as Map<String, dynamic>;
}
```
## 客户端状态建议
- 礼物面板缓存以 `room_id` 为粒度;切房间必须重新拉取。
- 房间 `visible_region_id` 变化或用户重新 JoinRoom 后重新拉取礼物面板。
- 礼物图片可以缓存;`animation_url` 建议按需预加载当前 tab 前几项。
- 发送按钮只防重复点击当前 `command_id`,不要全局锁死礼物面板。
- HTTP 超时后不要自动生成新 `command_id`;让用户点击重试时复用原值。
- 礼物墙和礼物 Tab 是两个不同接口:礼物 Tab 是可发送配置,礼物墙是当前用户收到过的礼物聚合。

View File

@ -0,0 +1,611 @@
# 语音房宝箱 Flutter 对接
本文描述 Flutter App 对接语音房宝箱所需的 HTTP 接口、请求参数、返回值、错误处理和腾讯云 IM 自定义消息 key。宝箱进度、开箱和发奖事实由 `room-service` Room Cell 持有;客户端只展示服务端返回或 IM 推送的状态,不能本地计算能量、奖励或开箱结果。
## 基础约定
本地开发地址:
```text
http://127.0.0.1:13000
```
业务接口统一响应 envelope
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {}
}
```
| 字段 | 说明 |
| --- | --- |
| `code` | 成功固定 `OK`;失败为稳定错误码。 |
| `message` | 短错误文案,不用于客户端分支判断。 |
| `request_id` | 服务端链路追踪 ID客户端日志要记录。 |
| `data` | 成功业务数据;失败时通常不存在。 |
通用请求头:
| Header | 必填 | 说明 |
| --- | --- | --- |
| `Authorization` | 是 | `Bearer <access_token>`。 |
| `X-App-Code` | 否 | App 编码,默认 `lalu`;登录态接口最终以 token 内 app_code 为准。 |
| `Content-Type` | POST 必填 | `application/json`。 |
所有 `*_ms` 都是 Unix epoch milliseconds。业务切日按 UTC不按用户本地时区。
## 客户端流程
1. 用户成功 `JoinRoom` 后调用 `GET /api/v1/rooms/{room_id}/treasure` 初始化 7 级物料和当前进度。
2. 用户送礼调用 `POST /api/v1/rooms/gift/send`;成功响应里的 `data.treasure` 可立即更新当前宝箱状态。
3. 房间内监听腾讯 IM 群 `TIMCustomElem.Ext=room_system_message`,处理宝箱进度、倒计时、开箱和发奖。
4. App 全局或区域播报监听 `TIMCustomElem.Ext=im_broadcast``broadcast_type=room_treasure`
5. 任意 IM 丢失、字段解析失败、版本倒退或页面重进时,重新调用 `GET /api/v1/rooms/{room_id}/treasure` 纠正本地状态。
客户端必须用 `event_id` 去重,用 `room_version` 丢弃旧房间消息。
## 获取宝箱信息
```http
GET /api/v1/rooms/{room_id}/treasure
Authorization: Bearer <access_token>
X-App-Code: lalu
```
路径参数:
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 当前语音房 ID例如 `lalu_534d076a-7cbd-4b23-a297-0c10a3c2fa72`。 |
成功响应:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {
"enabled": true,
"levels": [
{
"level": 1,
"energy_threshold": 1000,
"cover_url": "https://cdn.example/chest/lv1/cover.png",
"animation_url": "https://cdn.example/chest/lv1/idle.svga",
"opening_animation_url": "https://cdn.example/chest/lv1/opening.svga",
"opened_image_url": "https://cdn.example/chest/lv1/opened.png",
"in_room_rewards": [
{
"reward_item_id": "lv1_in_room_1",
"resource_group_id": 1001,
"weight": 10000,
"display_name": "Gold Pack",
"icon_url": "https://cdn.example/reward/gold.png"
}
],
"top1_rewards": [],
"igniter_rewards": []
}
],
"state": {
"current_level": 1,
"current_progress": 0,
"energy_threshold": 1000,
"status": "idle",
"reset_at_ms": 1779321600000,
"top1_user_id": "",
"igniter_user_id": "",
"box_id": "",
"config_version": 3,
"last_rewards": []
},
"broadcast_scope": "region",
"open_delay_ms": 30000,
"broadcast_delay_ms": 0,
"reward_stack_policy": "allow_stack",
"server_time_ms": 1779259000000
}
}
```
`data` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `enabled` | bool | 宝箱总开关。`false` 时客户端隐藏或置灰宝箱入口。 |
| `levels` | array | 固定 7 个等级的物料、阈值和奖励池。 |
| `state` | object | 当前房间当前 UTC 日的宝箱状态。 |
| `broadcast_scope` | string | `none``region``global`。 |
| `open_delay_ms` | int64 | 满能量后到开箱的倒计时配置。 |
| `broadcast_delay_ms` | int64 | 后台配置字段;当前房间事件会立即进入播报链路。 |
| `reward_stack_policy` | string | `allow_stack``priority_only`。 |
| `server_time_ms` | int64 | 服务端当前时间,倒计时要以它校准本地时钟偏差。 |
`levels[]` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `level` | int32 | 等级1 到 7。 |
| `energy_threshold` | int64 | 当前等级满能量阈值。 |
| `cover_url` | string | 静态封面图。 |
| `animation_url` | string | 未开箱/蓄能态动效。 |
| `opening_animation_url` | string | 开箱动效。 |
| `opened_image_url` | string | 开箱后展示图。 |
| `in_room_rewards` | array | 开箱时仍在房用户的奖励候选展示。 |
| `top1_rewards` | array | 倒计时开始时贡献第一用户奖励候选展示。 |
| `igniter_rewards` | array | 点火人奖励候选展示。 |
奖励候选字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `reward_item_id` | string | 后台配置的奖励项 ID。 |
| `resource_group_id` | int64 | 钱包资源组 ID。 |
| `weight` | int64 | 抽奖权重,只用于展示或调试,不参与客户端抽奖。 |
| `display_name` | string | 奖励展示名称。 |
| `icon_url` | string | 奖励图标。 |
`state` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `current_level` | int32 | 当前等级1 到 7。 |
| `current_progress` | int64 | 当前等级已累计有效能量。 |
| `energy_threshold` | int64 | 当前等级阈值。 |
| `status` | string | 当前返回 `idle``countdown`。 |
| `countdown_started_at_ms` | int64 | 倒计时开始时间;非倒计时可为空或 0。 |
| `open_at_ms` | int64 | 预计开箱时间;`status=countdown` 时有效。 |
| `opened_at_ms` | int64 | 最近一次开箱时间。 |
| `reset_at_ms` | int64 | 下一个 UTC 0 点;到点后进度按新 UTC 日重置。 |
| `top1_user_id` | string | 倒计时开始时锁定的贡献第一GET 接口中按字符串返回。 |
| `igniter_user_id` | string | 点火人GET 接口中按字符串返回。 |
| `box_id` | string | 当前倒计时宝箱或最近状态的单轮幂等 ID。 |
| `config_version` | int64 | 当前状态关联的后台配置版本。 |
| `last_rewards` | array | 最近一次开箱发奖结果;客户端可用于展示开箱结果兜底。 |
`last_rewards[]` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `reward_role` | string | `in_room``top1``igniter`。 |
| `user_id` | string | 获奖用户 ID 字符串。 |
| `reward_item_id` | string | 奖励项 ID。 |
| `resource_group_id` | int64 | 资源组 ID。 |
| `display_name` | string | 奖励展示名称。 |
| `icon_url` | string | 奖励图标。 |
| `grant_id` | string | 钱包发放记录 ID。 |
| `status` | string | 当前成功发放为 `succeeded`。 |
展示规则:
- 进度条使用 `current_progress / energy_threshold`,并且最大显示 100%。
- 倒计时使用 `open_at_ms - server_time_ms` 初始化,再用本地 timer 驱动。
- 不要把送礼溢出能量展示到下一等级;服务端规则是溢出作废。
- 如果 `status=countdown`,继续送礼不会改变宝箱进度、点火人、贡献第一或 `open_at_ms`
## 送礼并更新宝箱
```http
POST /api/v1/rooms/gift/send
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json
```
请求体:
```json
{
"room_id": "lalu_xxx",
"command_id": "cmd_send_gift_lalu_xxx_12345678_1779259000000",
"target_type": "user",
"target_user_ids": [12345678],
"gift_id": "rocket",
"gift_count": 1
}
```
参数:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 当前房间 ID。 |
| `command_id` | string | 是 | 本次送礼动作幂等键;同一次 HTTP 重试必须复用。 |
| `target_type` | string | 否 | 当前只支持 `user`;为空按 `user` 处理。 |
| `target_user_ids` | int64[] | 是 | 收礼用户列表;`target_type=user` 时必须且只能 1 个。 |
| `target_user_id` | int64 | 否 | 旧客户端兼容字段;新客户端优先用 `target_user_ids`。 |
| `gift_id` | string | 是 | 礼物 ID。 |
| `gift_count` | int32 | 是 | 礼物数量,必须大于 0。 |
成功响应核心字段:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {
"result": {
"applied": true,
"room_version": 7,
"server_time_ms": 1779259502200
},
"billing_receipt_id": "bill_xxx",
"room_heat": 500,
"gift_rank": [
{
"user_id": 12345678,
"score": 500,
"gift_value": 500,
"updated_at_ms": 1779259502104
}
],
"room": {},
"treasure": {
"current_level": 2,
"current_progress": 200,
"energy_threshold": 200,
"status": "countdown",
"countdown_started_at_ms": 1779259502104,
"open_at_ms": 1779259512104,
"reset_at_ms": 1779321600000,
"top1_user_id": 12345678,
"igniter_user_id": 12345678,
"box_id": "room_treasure_box_xxx",
"config_version": 3
}
}
}
```
字段说明:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `data.result.applied` | bool | 命令是否产生新状态;同一 `command_id` 重试可能返回历史结果。 |
| `data.result.room_version` | int64 | 房间版本,用于和 IM 消息去重、排序。 |
| `data.billing_receipt_id` | string | 钱包扣费回执。 |
| `data.room_heat` | int64 | 送礼后的房间热度。 |
| `data.gift_rank` | array | 送礼后的本地贡献榜快照。 |
| `data.room` | object | 房间快照。 |
| `data.treasure` | object | 送礼后宝箱状态;送礼人可立即用它刷新 UI。 |
注意:`GET /treasure` 的用户 ID 字段按字符串返回,`POST /gift/send` 透出的 protobuf 状态里部分用户 ID 可能是数字。Flutter 侧建议用统一 helper 兼容 `String``num`
```dart
String readId(dynamic value) {
if (value == null) return '';
if (value is String) return value;
if (value is num) return value.toInt().toString();
return '';
}
```
幂等规则:
| 场景 | 处理 |
| --- | --- |
| HTTP 超时后重试同一次送礼 | 复用原 `command_id`。 |
| 用户点了第二次送礼按钮 | 生成新的 `command_id`。 |
| 同一个 `command_id` 换了礼物、数量或目标 | 服务端返回冲突错误。 |
## 错误处理
失败响应:
```json
{
"code": "PERMISSION_DENIED",
"message": "permission denied",
"request_id": "req_xxx"
}
```
通用错误:
| HTTP | `code` | 场景 | Flutter 处理 |
| --- | --- | --- | --- |
| 400 | `INVALID_JSON` | 请求体不是合法 JSON。 | 修正客户端请求,不重试。 |
| 400 | `INVALID_ARGUMENT` | 缺字段、数量非法、room_id 格式非法、暂不支持的 `target_type`。 | 修正参数,不自动重试。 |
| 401 | `UNAUTHORIZED` | token 缺失、过期或无效。 | 走登录刷新或重新登录。 |
| 403 | `PROFILE_REQUIRED` | 用户资料未完成。 | 跳转资料补全流程。 |
| 403 | `PERMISSION_DENIED` | 不在房间、被 ban、无权限读取或操作。 | 退出房间页或刷新房间状态。 |
| 404 | `NOT_FOUND` | 房间不存在、送礼人不在房、收礼人不在房、礼物不可用。 | 刷新房间状态;礼物不可用时刷新礼物面板。 |
| 409 | `ROOM_CLOSED` | 房间已关闭。 | 退出房间页并刷新列表。 |
| 409 | `CONFLICT` | 状态或唯一约束冲突。 | 刷新后重试。 |
| 409 | `IDEMPOTENCY_CONFLICT` | `command_id` 复用但请求内容不同。 | 新用户动作生成新 `command_id`;重试旧动作必须保持原请求体。 |
| 409 | `INSUFFICIENT_BALANCE` | 钱包余额不足。 | 弹充值/余额不足提示。 |
| 409 | `LEDGER_CONFLICT` | 钱包并发账本冲突。 | 可短延迟重试一次,仍失败则刷新余额。 |
| 502 | `UPSTREAM_ERROR` | room/wallet 等依赖暂不可用。 | 提示稍后重试,可保留原 `command_id` 重试同一次送礼。 |
| 500 | `INTERNAL_ERROR` | 服务端内部错误。 | 提示失败并记录 `request_id`。 |
宝箱相关额外规则:
- 获取宝箱信息要求当前用户仍在房间业务 presence 内;离房后调用会返回 `PERMISSION_DENIED``NOT_FOUND`
- 宝箱关闭时,`GET /treasure` 仍可能返回 `enabled=false` 和 7 级配置;客户端不展示进度入口即可。
- 发奖自动完成,没有 App 领取接口;客户端不要补发或重复调用钱包接口。
## 房间群 IM 消息
房间内宝箱进度、倒计时、开箱和发奖通过腾讯云 IM 房间群 `TIMCustomElem` 投递。
| 字段 | 值 |
| --- | --- |
| `MsgType` | `TIMCustomElem` |
| `MsgContent.Ext` | `room_system_message` |
| `MsgContent.Desc` | 等于 `Data.event_type` |
| `MsgContent.Data` | JSON 字符串 |
| `CloudCustomData` | 与 `MsgContent.Data` 相同的 JSON 字符串 |
`Data` 外层结构:
```json
{
"event_id": "evt_xxx",
"room_id": "lalu_xxx",
"event_type": "room_treasure_progress_changed",
"actor_user_id": 12345678,
"target_user_id": 0,
"room_version": 8,
"attributes": {
"box_id": "room_treasure_box_xxx"
}
}
```
通用字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `event_id` | string | IM 去重键。 |
| `room_id` | string | 房间 ID。 |
| `event_type` | string | 事件类型,见下表。 |
| `actor_user_id` | int64 | 触发用户;倒计时/开箱通常为点火人。 |
| `target_user_id` | int64 | 目标用户;倒计时/开箱通常为 top1。 |
| `room_version` | int64 | 房间版本,本地版本更高时丢弃旧消息。 |
| `attributes` | map<string,string> | 业务字段值全部按字符串传输Flutter 侧按需转 int/bool/JSON。 |
宝箱房间群事件:
| `event_type` | `Desc` | 用途 |
| --- | --- | --- |
| `room_treasure_progress_changed` | `room_treasure_progress_changed` | 有效宝箱能量增加。 |
| `room_treasure_countdown_started` | `room_treasure_countdown_started` | 当前等级满能量,进入倒计时。 |
| `room_treasure_opened` | `room_treasure_opened` | 宝箱已打开,包含公开奖励摘要。 |
| `room_treasure_reward_granted` | `room_treasure_reward_granted` | 发奖结果事件,包含奖励列表。 |
### room_treasure_progress_changed
示例:
```json
{
"event_id": "evt_progress",
"room_id": "lalu_xxx",
"event_type": "room_treasure_progress_changed",
"actor_user_id": 12345678,
"room_version": 6,
"attributes": {
"box_id": "room_treasure_box_xxx",
"level": "2",
"added_energy": "300",
"effective_added_energy": "200",
"overflow_energy": "100",
"current_progress": "200",
"energy_threshold": "200",
"status": "countdown",
"reset_at_ms": "1779321600000",
"gift_id": "rocket",
"gift_count": "3",
"command_id": "cmd_send_gift_xxx"
}
}
```
处理:
- 更新当前等级进度为 `current_progress`
- `effective_added_energy` 是本次实际计入宝箱的能量。
- `overflow_energy` 是作废能量,不能加到下一等级。
- 如果 `status=countdown`,立即切换倒计时态,等待 `room_treasure_countdown_started` 补齐 `open_at_ms`;如该消息丢失,调用 GET 兜底。
### room_treasure_countdown_started
示例:
```json
{
"event_id": "evt_countdown",
"room_id": "lalu_xxx",
"event_type": "room_treasure_countdown_started",
"actor_user_id": 12345678,
"target_user_id": 12345678,
"room_version": 6,
"attributes": {
"box_id": "room_treasure_box_xxx",
"level": "2",
"current_progress": "200",
"energy_threshold": "200",
"countdown_start_ms": "1779259502104",
"open_at_ms": "1779259512104",
"broadcast_scope": "region",
"visible_region_id": "1001",
"top1_user_id": "12345678",
"igniter_user_id": "12345678",
"command_id": "cmd_send_gift_xxx"
}
}
```
处理:
- 按 `open_at_ms` 展示开箱倒计时。
- `top1_user_id``igniter_user_id` 在此刻锁定;后续离房或下线也不影响这两类奖励发放。
- 倒计时期间再收到普通 `room_gift_sent`,只更新礼物表现和热度,不改变宝箱倒计时。
注意:房间群 IM 字段名是 `countdown_start_ms`HTTP state 字段名是 `countdown_started_at_ms`
### room_treasure_opened
示例:
```json
{
"event_id": "evt_opened",
"room_id": "lalu_xxx",
"event_type": "room_treasure_opened",
"actor_user_id": 12345678,
"target_user_id": 12345678,
"room_version": 8,
"attributes": {
"box_id": "room_treasure_box_xxx",
"level": "2",
"next_level": "3",
"opened_at_ms": "1779259512668",
"reset_at_ms": "1779321600000",
"top1_user_id": "12345678",
"igniter_user_id": "12345678",
"rewards_json": "[{\"reward_role\":\"in_room\",\"user_id\":12345678,\"reward_item_id\":\"lv2_in_room_1\",\"resource_group_id\":1001,\"display_name\":\"Gold Pack\",\"icon_url\":\"https://cdn.example/reward/gold.png\",\"grant_id\":\"rgr_xxx\",\"status\":\"succeeded\"}]"
}
}
```
处理:
- 播放当前等级 `opening_animation_url`,再展示 `opened_image_url` 或奖励弹窗。
- 解析 `rewards_json` 得到奖励列表;如果解析失败,调用 GET 获取 `state.last_rewards`
- 本地状态进入 `next_level`,进度归 0状态归 `idle`
### room_treasure_reward_granted
示例:
```json
{
"event_id": "evt_reward",
"room_id": "lalu_xxx",
"event_type": "room_treasure_reward_granted",
"room_version": 8,
"attributes": {
"box_id": "room_treasure_box_xxx",
"level": "2",
"rewards_json": "[{\"reward_role\":\"top1\",\"user_id\":12345678,\"reward_item_id\":\"lv2_top1_1\",\"resource_group_id\":1002,\"display_name\":\"Top Reward\",\"icon_url\":\"https://cdn.example/reward/top.png\",\"grant_id\":\"rgr_xxx\",\"status\":\"succeeded\"}]"
}
}
```
处理:
- 解析 `rewards_json`
- 当前登录用户在奖励列表中时,展示“获得奖励”提示。
- 同一用户可能同时命中 `in_room``top1``igniter`,是否叠加由 `reward_stack_policy` 决定;客户端按服务端发下来的列表展示。
当前没有单独的 C2C 宝箱中奖私信,也没有 App 消息 Tab 宝箱入箱消息。后续如新增私有通知,应以新的 C2C `Ext` 文档为准,不复用房间群消息假装私信。
## 区域/全局播报 IM
满能量进入倒计时后,如果后台配置允许播报,`activity-service` 会向区域或全局播报群发送 `TIMCustomElem`
| 字段 | 值 |
| --- | --- |
| `MsgType` | `TIMCustomElem` |
| `MsgContent.Ext` | `im_broadcast` |
| `MsgContent.Desc` | `room_treasure` |
| `MsgContent.Data` | JSON 字符串 |
| `CloudCustomData` | 与 `MsgContent.Data` 相同的 JSON 字符串 |
`Data` 示例:
```json
{
"event_id": "room_treasure_broadcast:lalu_xxx:room_treasure_box_xxx",
"broadcast_type": "room_treasure",
"scope": "region",
"app_code": "lalu",
"region_id": 1001,
"room_id": "lalu_xxx",
"box_id": "room_treasure_box_xxx",
"level": 2,
"open_at_ms": 1779259512104,
"top1_user_id": 12345678,
"igniter_user_id": 12345678,
"sent_at_ms": 1779259502200,
"room_version": 6,
"source_event_id": "evt_countdown",
"current_progress": 200,
"energy_threshold": 200,
"countdown_started_at_ms": 1779259502104,
"action": {
"type": "enter_room",
"room_id": "lalu_xxx"
}
}
```
处理:
- `broadcast_type=room_treasure` 时展示宝箱倒计时播报。
- 点击播报按 `action.type=enter_room` 跳转或进房。
- `scope=region` 表示区域播报群;`scope=global` 表示全局播报群。
- 播报只负责引导和展示,不代替房间内宝箱状态;进房后仍调用 GET 初始化。
## 普通送礼房间 IM
送礼成功还会产生普通房间群消息:
| 字段 | 值 |
| --- | --- |
| `MsgContent.Ext` | `room_system_message` |
| `MsgContent.Desc` | `room_gift_sent` |
| `Data.event_type` | `room_gift_sent` |
宝箱 UI 不应该只靠 `room_gift_sent` 推算能量;能量以 `room_treasure_progress_changed``room_treasure_countdown_started``POST /gift/send.data.treasure` 或 GET 结果为准。
## Flutter 解析建议
IM `attributes` 的值都是字符串,建议统一封装解析:
```dart
int readInt(dynamic value, [int fallback = 0]) {
if (value == null) return fallback;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? fallback;
return fallback;
}
String readString(dynamic value) {
if (value == null) return '';
if (value is String) return value;
if (value is num) return value.toInt().toString();
return value.toString();
}
List<Map<String, dynamic>> readRewardsJson(dynamic value) {
final raw = readString(value);
if (raw.isEmpty) return const [];
final decoded = jsonDecode(raw);
if (decoded is! List) return const [];
return decoded.whereType<Map>().map((item) {
return item.map((key, val) => MapEntry(key.toString(), val));
}).toList();
}
```
推荐本地状态更新策略:
| 输入 | 处理 |
| --- | --- |
| `GET /treasure` | 覆盖本地完整宝箱状态和 7 级物料。 |
| `POST /gift/send.data.treasure` | 送礼人立即刷新当前状态。 |
| `room_treasure_progress_changed` | 同房间、版本不旧时更新进度。 |
| `room_treasure_countdown_started` | 切换倒计时,并锁定 top1/点火人展示。 |
| `room_treasure_opened` | 播放开箱动效,状态切到下一等级 idle。 |
| `room_treasure_reward_granted` | 展示当前用户中奖提示;按 `event_id` 去重。 |
| `im_broadcast + room_treasure` | 展示区域/全局播报入口,不直接改当前房间状态。 |
如果客户端发现本地状态和服务端事件无法合并,例如当前 `box_id` 不一致、`level` 倒退、`room_version` 缺失,直接调用 `GET /api/v1/rooms/{room_id}/treasure` 重新同步。

View File

@ -155,6 +155,33 @@ MQ 和 MySQL 扫描是同一事实的两种入口:
这套规则同时覆盖普通送礼扣费、幸运礼物中奖返奖、VIP 购买扣费、资源组发金币、币商转币等余额变化。
`VipActivated` 用于通知客户端用户已获得或续期 VIPC2C 自定义消息 `Ext=vip_notice`
```json
{
"event_type": "VipActivated",
"event_id": "wev_xxx",
"app_code": "lalu",
"transaction_id": "wtx_xxx",
"command_id": "vip_purchase_10001_6_1790000000000",
"user_id": "10001",
"asset_type": "VIP",
"source": "vip_purchase",
"action": "upgrade",
"previous_level": 3,
"level": 6,
"vip_name": "VIP6",
"started_at_ms": 1790000000000,
"expires_at_ms": 1792592000000,
"reward_resource_group_id": 101,
"resource_grant_id": "rgr_xxx",
"reward_items": [],
"source_created_at_ms": 1790000000000
}
```
客户端按 `event_id` 去重,收到后刷新全局 VIP 状态;`source` 取值为 `vip_purchase``admin_grant``activity_grant`
## 多实例和重启语义
`notice_delivery_events` 是 worker 的投递位点表:

View File

@ -1898,6 +1898,26 @@ paths:
$ref: "#/responses/BadRequest"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/gift-tabs:
get:
tags:
- resources
summary: App 礼物 Tab 配置列表
operationId: listGiftTabs
parameters:
- name: region_id
in: query
required: false
type: integer
format: int64
description: 不传时预加载全部 active 礼物;传入时只返回 GLOBAL 和该区域可用礼物。
responses:
"200":
description: 返回后台启用的礼物 Tab 配置和当前 active 礼物;不按礼物数量过滤 Tab即使 Tab 下当前没有礼物也返回 gifts 空数组。
schema:
$ref: "#/definitions/GiftTabListEnvelope"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/users/me/resources:
get:
tags:
@ -2567,6 +2587,11 @@ definitions:
updated_at_ms:
type: integer
format: int64
gifts:
type: array
description: 当前 Tab 下的 active 礼物列表;没有礼物时返回空数组。
items:
$ref: "#/definitions/GiftConfigData"
DeletePushTokenData:
type: object
required:
@ -2692,7 +2717,10 @@ definitions:
description: 用户是否拥有 active host profile。
is_agent:
type: boolean
description: 用户是否拥有 active agency owner 身份。
description: 用户是否拥有 Agency 入口身份active Agency owner 或 Agency 来源主播都可能为 true。
is_manager:
type: boolean
description: 用户是否拥有经理中心身份;首版等价于 active Agency owner。
is_bd:
type: boolean
description: 用户是否拥有 active BD 或 BD Leader 身份。
@ -2707,6 +2735,7 @@ definitions:
required:
- is_host
- is_agency
- is_manager
- is_bd
- is_bd_leader
- is_coin_seller
@ -2716,6 +2745,8 @@ definitions:
type: boolean
is_agency:
type: boolean
is_manager:
type: boolean
is_bd:
type: boolean
is_bd_leader:
@ -3145,6 +3176,7 @@ definitions:
type: string
enum:
- host-center
- manager-center
- bd-center
- bd-leader-center
- agency-center
@ -3622,6 +3654,49 @@ definitions:
page_size:
type: integer
format: int32
GiftTabData:
type: object
properties:
key:
type: string
description: Tab 稳定 key等同于礼物类型编码。
gift_type_code:
type: string
description: 礼物类型编码,用于和 gifts[].gift_type_code 对齐。
name:
type: string
description: 后台配置的中文显示名。
label:
type: string
description: App Tab 展示名,来源于后台 tab_key。
tab_key:
type: string
description: 后台配置的 Tab Name。
status:
type: string
order:
type: integer
format: int32
description: App 排序值,等同于 sort_order。
sort_order:
type: integer
format: int32
created_at_ms:
type: integer
format: int64
updated_at_ms:
type: integer
format: int64
GiftTabListData:
type: object
properties:
items:
type: array
items:
$ref: "#/definitions/GiftTabData"
total:
type: integer
format: int32
UserResourceData:
type: object
properties:
@ -4838,6 +4913,13 @@ definitions:
properties:
data:
$ref: "#/definitions/GiftListData"
GiftTabListEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/GiftTabListData"
UserResourceListEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"

View File

@ -10,8 +10,8 @@
| --- | --- | --- |
| `LuckyGiftService.CheckLuckyGift` | done | 房间送礼前检查礼物是否启用,并返回当前体验池 |
| `LuckyGiftService.ExecuteLuckyGiftDraw` | done | 扣费成功后执行抽奖决策,写抽奖事实和 outbox |
| `AdminLuckyGiftService.GetLuckyGiftConfig` | done | 后台读取全局幸运礼物规则;未配置时返回默认草稿 |
| `AdminLuckyGiftService.UpsertLuckyGiftConfig` | done | 后台发布全局幸运礼物规则版本 |
| `AdminLuckyGiftService.GetLuckyGiftConfig` | done | 后台`pool_id` 读取幸运礼物奖池规则;未配置时返回默认草稿 |
| `AdminLuckyGiftService.UpsertLuckyGiftConfig` | done | 后台`pool_id` 发布独立幸运礼物奖池规则版本 |
| `AdminLuckyGiftService.ListLuckyGiftDraws` | done | 后台审计抽奖记录 |
| 三层基础奖池 | done | platform / room / gift |
| RTP 双窗口 | done | global + gift |
@ -25,23 +25,23 @@
```text
room-service SendGift
1. CheckLuckyGift(meta, user_id, room_id, gift_id)
1. CheckLuckyGift(meta, user_id, room_id, gift_id, pool_id)
2. wallet-service 扣费成功
3. ExecuteLuckyGiftDraw(command_id, user_id, device_id, room_id, anchor_id, gift_id, coin_spent, paid_at_ms)
3. ExecuteLuckyGiftDraw(command_id, user_id, device_id, room_id, anchor_id, gift_id, pool_id, coin_spent, paid_at_ms)
4. activity-service 本地事务写 draw fact + outbox
5. 事务后由钱包/房间表现链路处理 reward_status=pending 的结果
```
`command_id` 是抽奖幂等键。重复 `ExecuteLuckyGiftDraw` 会返回同一条 `lucky_draw_records`,不会重复消耗 RTP、奖池或预算。
后台只维护一份全局幸运礼物规则,实际抽奖仍记录用户发送的真实 `gift_id`。后台不暴露单抽消耗;服务端保存固定内部换算单位,不同幸运礼物实际消耗不同,抽奖事务按实际 `coin_spent` 等比缩放基础奖档,并按实际 `coin_spent` 累计 RTP 目标和奖池入账。
后台`pool_id` 维护多份互相独立的幸运礼物奖池规则,实际抽奖仍记录用户发送的真实 `gift_id`。后台不暴露单抽消耗;服务端保存固定内部换算单位,不同幸运礼物实际消耗不同,抽奖事务按实际 `coin_spent` 等比缩放基础奖档,并按实际 `coin_spent` 累计 RTP 目标和奖池入账。同一个 `gift_id` 可以请求不同 `pool_id`,不同 `pool_id` 的 RTP 窗口、基础奖池、房间气氛池、活动预算、用户阶段和风控计数互不共享。
## Transaction Boundary
`ExecuteLuckyGiftDraw` 使用一个 MySQL 本地事务,事务内必须完成:
1. 按 `command_id` 锁定幂等记录。
2. 锁定 `lucky_gift_rules` 全局规则。
2. 锁定 `lucky_gift_rules` `pool_id` 对应规则。
3. 锁定或创建用户体验状态 `lucky_user_states`
4. 锁定或创建全站 RTP 窗口、幸运礼物整体子窗口。
5. 锁定或创建平台、房间、幸运礼物整体三层基础奖池。
@ -82,16 +82,17 @@ effective_reward_coins = base_reward_coins
| Method | Path | Permission | Meaning |
| --- | --- | --- | --- |
| `GET` | `/api/v1/admin/activity/lucky-gifts/config` | `lucky-gift:view` | 读取全局幸运礼物规则;未配置返回默认草稿 |
| `PUT` | `/api/v1/admin/activity/lucky-gifts/config` | `lucky-gift:update` | 发布新的全局幸运礼物规则版本 |
| `GET` | `/api/v1/admin/activity/lucky-gifts/config?pool_id=pool_95` | `lucky-gift:view` | 读取指定奖池规则;未配置返回默认草稿 |
| `PUT` | `/api/v1/admin/activity/lucky-gifts/config?pool_id=pool_95` | `lucky-gift:update` | 发布指定奖池的新规则版本 |
| `GET` | `/api/v1/admin/activity/lucky-gifts/draws` | `lucky-gift:view` | 查询抽奖审计记录 |
| Group | Fields | Meaning |
| --- | --- | --- |
| RTP 成本 | `target_rtp_ppm`, `pool_rate_ppm`, `global_window_draws`, `gift_window_draws` | 控制基础 RTP 和收敛窗口 |
| 三层池 | `platform_pool_weight_ppm`, `room_pool_weight_ppm`, `gift_pool_weight_ppm`, `initial_*_pool`, `*_reserve` | 控制平台、房间、礼物各自承担的基础返奖能力 |
| 体验池 | `novice_draw_limit`, `intermediate_draw_limit`, `tiers` | 控制新手/中级/高级奖档结构 |
| 高倍 | `high_multiplier`, `high_water_pool_multiple`, `large_tier_enabled`, tier `high_water_only` | 控制 100x/500x 等高倍是否可进入候选 |
| 倍率配置 | `multiplier_ppms`, generated `tiers` | 运营只配置用户可能命中的倍率;服务按 RTP 窗口、水位和风控自动生成实时概率 |
| 体验池 | `novice_draw_limit`, `intermediate_draw_limit` | 控制新手/中级/高级阶段,不直接决定成本 |
| 高倍 | `high_multiplier`, `high_water_pool_multiple`, `large_tier_enabled` | 控制 100x/500x 等高倍倍率是否可进入候选 |
| 风控 | `max_single_payout`, `user_hourly_payout_cap`, `user_daily_payout_cap`, `device_daily_payout_cap`, `room_hourly_payout_cap`, `anchor_daily_payout_cap` | 控制单用户、设备、房间、主播关联风险敞口 |
| 娱乐补贴 | `room_atmosphere_rate_ppm`, `room_atmosphere_initial`, `room_atmosphere_reserve`, `activity_budget`, `activity_daily_limit` | 控制房间爆点和活动补贴 |
@ -100,8 +101,8 @@ effective_reward_coins = base_reward_coins
| Goal | Primary Change |
| --- | --- |
| 降成本 | 降 `target_rtp_ppm`,降补贴预算,提高高水位倍数 |
| 增刺激 | 增房间气氛和活动预算,提高中高倍权重 |
| 新手更友好 | 提高新手小奖权重,延长 `novice_draw_limit` |
| 增刺激 | 增加高倍倍率,增房间气氛和活动预算 |
| 新手更友好 | 增加 0.2x/0.5x/1x 等小倍率,延长 `novice_draw_limit` |
| 防套利 | 降低用户/设备/房间/主播上限,关闭高倍 |
| RTP 更稳 | 缩小 RTP 窗口 |
| 体验更自然 | 放大 RTP 窗口,但接受短周期波动 |
@ -110,13 +111,13 @@ effective_reward_coins = base_reward_coins
| Table | Owner Data |
| --- | --- |
| `lucky_gift_rules` | 全局幸运礼物规则和后台可调参数;规则作用域固定为 `__global__` |
| `lucky_rtp_windows` | 全站/幸运礼物整体 RTP 窗口 |
| `lucky_pools` | platform / room / lucky-gift-global 三层基础奖池 |
| `lucky_room_atmosphere_pools` | 房间气氛预算 |
| `lucky_activity_budgets` | 活动补贴预算;`budget_day=__total__` 控总额UTC 日期行控日释放 |
| `lucky_user_states` | 用户在幸运礼物整体上的累计付费抽数 |
| `lucky_risk_counters` | 风控窗口计数 |
| `lucky_gift_rules` | `pool_id` 保存幸运礼物奖池规则和后台可调参数 |
| `lucky_rtp_windows` | `pool_id` 保存 RTP 控制窗口 |
| `lucky_pools` | `pool_id` 保存 platform / room / gift 三层基础奖池 |
| `lucky_room_atmosphere_pools` | `pool_id + room_id` 保存房间气氛预算 |
| `lucky_activity_budgets` | `pool_id` 保存活动补贴预算;`budget_day=__total__` 控总额UTC 日期行控日释放 |
| `lucky_user_states` | 用户在单个 `pool_id`的累计付费抽数 |
| `lucky_risk_counters` | `pool_id` 保存风控窗口计数 |
| `lucky_draw_records` | 每次抽奖事实和审计快照 |
## Verification

View File

@ -49,6 +49,7 @@ Authorization: Bearer <access_token>
"roles": {
"is_host": true,
"is_agency": false,
"is_manager": false,
"is_bd": false,
"is_bd_leader": false,
"is_coin_seller": false,
@ -116,6 +117,7 @@ Authorization: Bearer <access_token>
| `apply_host` | `roles.is_host == false` |
| `host_center` | `roles.is_host == true` |
| `agency_center` | `roles.is_agency == true` |
| `manager_center` | `roles.is_manager == true` |
| `bd_center` | `roles.is_bd == true` |
| `bd_leader_center` | `roles.is_bd_leader == true` |
| `coin_seller_center` | `roles.is_coin_seller == true` |

View File

@ -84,6 +84,7 @@
| GET | `/api/v1/resources` | resources | `listResources` | App 资源列表 |
| GET | `/api/v1/resource-groups/{group_id}` | resources | `getResourceGroup` | App 资源组详情 |
| GET | `/api/v1/gifts` | resources | `listGifts` | App 礼物列表 |
| GET | `/api/v1/gift-tabs` | resources | `listGiftTabs` | App 礼物 Tab 配置和礼物预加载列表,不依赖房间 |
| GET | `/api/v1/users/me/resources` | resources | `listMyResources` | 我的资源权益 |
| POST | `/api/v1/users/me/resources/{resource_id}/equip` | resources | `equipMyResource` | 佩戴我的资源 |
| GET | `/api/v1/messages/tabs` | messages | `listMessageTabs` | 消息 tab 分区摘要 |

View File

@ -0,0 +1,898 @@
# 红包功能技术设计
本文定义普通红包和延迟红包的产品规则、服务边界、账务模型、后台配置、App 接口、区域飘屏、过期退款和验收边界。红包是房间内用户主动发起的金币互动玩法,资金事实必须由 `wallet-service` 持久化,区域飘屏只是已提交红包事实的实时展示入口。所有业务时间统一使用 UTC跨服务字段统一使用 Unix epoch milliseconds字段名保持 `*_ms`
相关现有文档:
```text
docs/钱包服务架构.md
docs/IM全局与区域广播架构.md
docs/资源组与礼物架构.md
docs/语音房客户端接口流程.md
```
## 当前实现依据
| 能力 | 当前事实 | 红包设计决策 |
| --- | --- | --- |
| 账务 owner | `wallet-service` 已拥有 `wallet_accounts``wallet_transactions``wallet_entries``wallet_outbox` | 红包扣款、领取入账和过期退款全部放在 `wallet-service` |
| 账务幂等 | `wallet_transactions.command_id``request_hash` 已作为命令幂等模型 | 发红包、抢红包和退款都必须有稳定 `command_id` |
| 事件投递 | 钱包文档约束 `wallet_outbox` 必须和账务交易同事务写入 | 红包创建成功后写 wallet outbox不让 MQ 成为资金事实来源 |
| 区域播报 | `activity-service` 已有 `BroadcastService.PublishRegionBroadcast``im_broadcast_outbox` | 红包飘屏由 activity 消费红包创建事件后写区域播报 outbox |
| App HTTP | `gateway-service` `/api/v1` 响应使用 `{code,message,request_id,data}` envelope | 红包 App 接口只在 gateway 暴露,内部走 gRPC |
| 后台入口 | `server/admin` 只能通过 protobuf client 或明确集成接口访问 app 后端 | 后台红包配置通过 admin server 调 wallet-service 管理 RPC |
| 时间规则 | 全局业务时间统一 UTC时间范围使用 `[start_ms,end_ms)` | 每日发送次数按 UTC 日期统计,过期和延迟打开都用 UTC epoch ms |
## Goals
- 支持普通红包和延迟红包。
- 后台可配置红包数量档位、红包金额档位、延迟红包秒数、每用户每日发送次数上限和红包过期秒数。
- 发红包时立即扣除发送者金币,并把红包金额拆成服务端随机份额。
- 每个用户每个红包最多领取一次,领取金额随机且总和严格等于红包总金额。
- 红包过期后自动退回未领取金额,退款必须幂等。
- 发红包成功后触发区域飘屏,飘屏失败不能回滚已提交账务事实。
- Flutter 只对接接口和 IM 飘屏消息,不在客户端计算红包金额、过期、限次或随机结果。
## Non-Goals
| 不做 | 原因 |
| --- | --- |
| 客户端提交随机拆分结果 | 金额分配必须由服务端生成并持久化 |
| 只用 Redis 保存红包剩余金额 | 红包资金事实需要 MySQL 事务和账务分录恢复 |
| 把红包状态放进 `room-service` | 红包不是 Room Cell 核心状态,房间只提供入口上下文 |
| 用 MQ 直接表示扣款或退款成功 | MQ 可以投递事实,不能成为账务事实来源 |
| 为每个红包创建 IM 群 | 红包是业务消息,不是会话边界 |
| 后台直接写 wallet 业务表 | 后台必须调用 owner service 管理接口并写审计 |
## 产品规则
| 规则 | 决策 |
| --- | --- |
| 红包类型 | `normal` 普通红包,`delayed` 延迟红包 |
| 普通红包 | 创建成功后立即可领取,`open_at_ms = created_at_ms` |
| 延迟红包 | 创建成功时立即扣款,`open_at_ms = created_at_ms + delay_seconds * 1000` |
| 扣款资产 | 只允许 `COIN` 金币 |
| 金额单位 | 整数金币,不使用小数或浮点数 |
| 档位校验 | `total_amount` 必须命中金额档位,`packet_count` 必须命中数量档位 |
| 最小金额 | `total_amount >= packet_count`,保证每份至少 1 金币 |
| 每日次数 | 按 UTC 日期统计 `sender_user_id` 发红包次数 |
| 过期退款 | 过期后未领取金额退回发送者金币账户 |
| 可见范围 | 红包绑定 `room_id`,飘屏发送到房间所在 `region_id` |
| 发送者自领 | 默认允许发送者领取自己的红包;如果产品要求禁止,只需要在领取校验加 `sender_user_id != claimer_user_id` |
## 服务边界
| 模块 | 拥有 | 不拥有 |
| --- | --- | --- |
| `wallet-service` | 红包配置、发红包扣款、随机份额、领取入账、过期退款、每日发送次数、红包账务 outbox | 房间核心状态、IM 群生命周期、用户资料展示 |
| `gateway-service` | App HTTP envelope、鉴权、协议转换、从 room/user 上下文补齐 `room_id/region_id` 后调用 wallet | 红包金额随机、账务分录、过期扫描 |
| `activity-service` | 消费红包创建事实并写 `im_broadcast_outbox`,调用 `PublishRegionBroadcast` | 红包扣款、领取、退款和剩余金额 |
| `server/admin` | 后台菜单、配置表单、审计、调用 wallet 管理 RPC | 直接修改红包业务表或钱包账本 |
| `hyapp-admin-platform` | `活动管理 / 红包配置` 页面和红包记录查询 | 业务校验最终裁决 |
| `hyapp-flutter` | 展示配置档位、发红包、抢红包、展示领取结果和飘屏入口 | 本地计算可抢状态之外的账务结果 |
## 状态机
```mermaid
stateDiagram-v2
[*] --> created
created --> active: normal created
created --> waiting_open: delayed created
waiting_open --> active: now_ms >= open_at_ms
active --> finished: remaining_count = 0
active --> expired: now_ms >= expires_at_ms
waiting_open --> expired: now_ms >= expires_at_ms
expired --> refunded: refund remaining_amount
finished --> [*]
refunded --> [*]
```
| 状态 | 语义 |
| --- | --- |
| `created` | 红包创建命令开始,通常不作为长期状态暴露 |
| `waiting_open` | 延迟红包已扣款,但未到领取时间 |
| `active` | 可领取 |
| `finished` | 所有份额已领取 |
| `expired` | 已过期,等待或正在执行退款 |
| `refunded` | 未领取金额已退回发送者 |
## 数据库设计
红包表放在 `wallet-service` 所属 MySQL schema和账务交易同库便于在同一事务中维护金额、分录和 outbox。
### 配置表
```sql
CREATE TABLE red_packet_configs (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
enabled TINYINT(1) NOT NULL DEFAULT 0,
delayed_open_seconds INT NOT NULL DEFAULT 0,
expire_seconds INT NOT NULL DEFAULT 300,
daily_send_limit INT NOT NULL DEFAULT 0,
updated_by_admin_id BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code)
);
CREATE TABLE red_packet_count_tiers (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
tier_id BIGINT NOT NULL AUTO_INCREMENT,
packet_count INT NOT NULL,
status VARCHAR(32) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (tier_id),
UNIQUE KEY uk_red_packet_count_tier (app_code, packet_count)
);
CREATE TABLE red_packet_amount_tiers (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
tier_id BIGINT NOT NULL AUTO_INCREMENT,
total_amount BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (tier_id),
UNIQUE KEY uk_red_packet_amount_tier (app_code, total_amount)
);
```
`daily_send_limit = 0` 表示禁止发送,不表示无限制。需要无限制时必须用显式大值,避免运营误配置。
### 红包事实表
```sql
CREATE TABLE red_packets (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
packet_id VARCHAR(96) NOT NULL,
command_id VARCHAR(128) NOT NULL,
sender_user_id BIGINT NOT NULL,
room_id VARCHAR(96) NOT NULL,
region_id BIGINT NOT NULL,
packet_type VARCHAR(32) NOT NULL,
total_amount BIGINT NOT NULL,
packet_count INT NOT NULL,
remaining_amount BIGINT NOT NULL,
remaining_count INT NOT NULL,
status VARCHAR(32) NOT NULL,
open_at_ms BIGINT NOT NULL,
expires_at_ms BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, packet_id),
UNIQUE KEY uk_red_packets_command (app_code, command_id),
KEY idx_red_packets_room_time (app_code, room_id, created_at_ms),
KEY idx_red_packets_expire (app_code, status, expires_at_ms)
);
CREATE TABLE red_packet_allocations (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
packet_id VARCHAR(96) NOT NULL,
allocation_no INT NOT NULL,
amount BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
claimed_by_user_id BIGINT NOT NULL DEFAULT 0,
claim_id VARCHAR(96) NOT NULL DEFAULT '',
claimed_at_ms BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (app_code, packet_id, allocation_no),
KEY idx_red_packet_alloc_claimable (app_code, packet_id, status, allocation_no),
KEY idx_red_packet_alloc_user (app_code, claimed_by_user_id, claimed_at_ms)
);
CREATE TABLE red_packet_claims (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
claim_id VARCHAR(96) NOT NULL,
command_id VARCHAR(128) NOT NULL,
packet_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
amount BIGINT NOT NULL,
wallet_transaction_id VARCHAR(96) NOT NULL,
status VARCHAR(32) NOT NULL,
failure_reason VARCHAR(64) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, claim_id),
UNIQUE KEY uk_red_packet_claim_command (app_code, command_id),
UNIQUE KEY uk_red_packet_claim_user (app_code, packet_id, user_id),
KEY idx_red_packet_claim_packet (app_code, packet_id, created_at_ms)
);
```
### 退款和次数表
```sql
CREATE TABLE red_packet_refunds (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
refund_id VARCHAR(96) NOT NULL,
command_id VARCHAR(128) NOT NULL,
packet_id VARCHAR(96) NOT NULL,
sender_user_id BIGINT NOT NULL,
refund_amount BIGINT NOT NULL,
wallet_transaction_id VARCHAR(96) NOT NULL,
status VARCHAR(32) NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, refund_id),
UNIQUE KEY uk_red_packet_refund_command (app_code, command_id),
UNIQUE KEY uk_red_packet_refund_packet (app_code, packet_id)
);
CREATE TABLE red_packet_user_daily_counters (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
user_id BIGINT NOT NULL,
send_day CHAR(10) NOT NULL,
sent_count INT NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, user_id, send_day)
);
```
`send_day` 使用 UTC 日期字符串,例如 `2026-05-22`。不要使用服务器本地时区或客户端时区。
## 账务分录
红包使用 `COIN` 资产,建议新增账务类型:
| `biz_type` | 场景 | 分录 |
| --- | --- | --- |
| `red_packet_create` | 发送红包 | sender `COIN` `available_delta = -total_amount` |
| `red_packet_claim` | 抢红包成功 | claimer `COIN` `available_delta = +amount` |
| `red_packet_refund` | 过期退款 | sender `COIN` `available_delta = +remaining_amount` |
发红包扣款不是冻结余额,而是实际扣除并形成红包负债。未领取金额由 `red_packets.remaining_amount` 表达,过期后通过退款交易回到发送者账户。这样账本只保留用户余额事实,红包池余额由红包领域表负责审计,避免给用户账户引入复杂冻结语义。
## 随机金额拆分
红包创建时一次性生成所有份额,写入 `red_packet_allocations`。领取时只分配已经生成的未领取份额,不再动态计算金额。
约束:
- 每份最小 `1` 金币。
- 份额数量等于 `packet_count`
- 份额总和严格等于 `total_amount`
- 使用服务端安全随机数。
- 生成后打乱 `allocation_no`,避免领取顺序和金额排序相关。
推荐算法:
```text
remaining_amount = total_amount
remaining_count = packet_count
for i in 1..packet_count:
if remaining_count == 1:
amount = remaining_amount
else:
min_amount = 1
max_amount = remaining_amount - (remaining_count - 1)
avg = remaining_amount / remaining_count
draw_max = min(max_amount, max(1, avg * 2))
amount = secure_random_int(min_amount, draw_max)
append amount
remaining_amount -= amount
remaining_count -= 1
shuffle amounts
```
这个算法保留随机波动,同时保证最后不会出现剩余金额不足以分配最小份额的情况。后续如果产品需要更平滑分布,可以把 `avg * 2` 改成配置项,但不能让客户端控制。
## 发红包流程
```mermaid
sequenceDiagram
participant C as Flutter
participant G as gateway-service
participant R as room-service
participant W as wallet-service
participant DB as wallet MySQL
participant A as activity-service
participant IM as Tencent IM
C->>G: POST /api/v1/rooms/{room_id}/red-packets
G->>R: GetRoomContext(room_id)
R-->>G: room_id, visible_region_id
G->>W: CreateRedPacket(command_id, sender, room, region, type, amount, count)
W->>DB: validate config + daily counter + debit + allocations + wallet_outbox
W-->>G: packet_id, open_at_ms, expires_at_ms
G-->>C: red packet created
A->>DB: scan wallet_outbox red_packet.created
A->>A: PublishRegionBroadcast
A->>IM: region floating broadcast
```
关键规则:
- `gateway-service` 不能信任客户端提交的 `region_id`,必须从房间上下文取房间可见区域。
- `wallet-service` 在同一个 MySQL 事务内完成配置校验、每日次数递增、sender 扣款、红包记录、份额记录、钱包交易、钱包分录和 `wallet_outbox`
- 同一个 `app_code + command_id` 重复调用payload 相同返回原红包payload 不同返回 `REQUEST_CONFLICT`
- 延迟红包创建成功后立即扣款和飘屏,客户端根据 `open_at_ms` 展示倒计时。
## 抢红包流程
```mermaid
sequenceDiagram
participant C as Flutter
participant G as gateway-service
participant W as wallet-service
participant DB as wallet MySQL
C->>G: POST /api/v1/red-packets/{packet_id}/claim
G->>W: ClaimRedPacket(command_id, packet_id, user_id)
W->>DB: lock packet
W->>DB: check open/expire/already claimed
W->>DB: lock one unclaimed allocation
W->>DB: credit claimer + claim + update remaining
W-->>G: amount, claim_id
G-->>C: claim result
```
并发控制:
- `red_packets` 行先加锁,保证剩余金额和数量更新一致。
- `red_packet_claims``uk_red_packet_claim_user(app_code, packet_id, user_id)` 保证每人每红包最多领取一次。
- 未领取份额使用 `SELECT ... FOR UPDATE SKIP LOCKED` 或等价行锁策略获取一条 `unclaimed` allocation。
- 领取成功必须和 claimer 钱包入账、红包剩余数量更新在同一个事务中提交。
- 如果没有未领取份额,返回 `RED_PACKET_SOLD_OUT`
## 过期退款流程
过期退款由 `wallet-service` worker 执行。`cron-service` 可以通过内部 gRPC 触发批处理,但不能直接修改红包或钱包表。
```mermaid
sequenceDiagram
participant Cron as cron-service or wallet worker
participant W as wallet-service
participant DB as wallet MySQL
Cron->>W: ExpireRedPackets(batch_size)
W->>DB: select active/waiting packets expires_at_ms <= now for update
W->>DB: sum unclaimed allocations
W->>DB: credit sender refund
W->>DB: mark allocations expired
W->>DB: mark packet refunded
W->>DB: insert wallet_outbox red_packet.refunded
```
退款命令号固定:
```text
red_packet_refund:<packet_id>
```
规则:
- 退款金额等于未领取 allocation 金额总和,不由客户端或外部任务传入。
- `remaining_amount = 0` 时只把红包改成 `finished``expired`,不产生 0 金额退款交易。
- 重复扫描同一个过期红包必须返回同一退款结果,不能重复加钱。
- 过期判断使用 `expires_at_ms <= now_ms`
## 区域飘屏与 MQ
红包创建成功后,`wallet-service``wallet_outbox`
```json
{
"event_type": "red_packet.created",
"packet_id": "rp_xxx",
"packet_type": "normal",
"sender_user_id": 123,
"room_id": "100001",
"region_id": 86,
"total_amount": 1000,
"packet_count": 10,
"open_at_ms": 1780000000000,
"expires_at_ms": 1780000300000,
"created_at_ms": 1780000000000
}
```
`activity-service` 消费该事实后调用现有播报能力:
```text
BroadcastService.PublishRegionBroadcast(
region_id = event.region_id,
broadcast_type = "red_packet_created",
payload_json = event payload
)
```
MQ 设计结论:
- 可以把 `wallet_outbox` relay 到 MQ提高实时性。
- 不能让 MQ 成为扣款、领取或退款事实来源。
- activity 消费失败只影响飘屏实时性,不回滚红包创建。
- activity 必须按 `event_id` 幂等,避免 outbox 重投导致重复飘屏。
- 如果 MQ 不可用activity 可以像首冲奖励 worker 一样轮询 `wallet_outbox` 作为补偿路径。
## 内部 gRPC 契约
建议在 `api/proto/wallet/v1/wallet.proto` 增加红包 RPC。
```proto
message RedPacketConfig {
bool enabled = 1;
repeated int32 count_tiers = 2;
repeated int64 amount_tiers = 3;
int32 delayed_open_seconds = 4;
int32 expire_seconds = 5;
int32 daily_send_limit = 6;
int64 updated_at_ms = 7;
}
message CreateRedPacketRequest {
common.v1.RequestMeta meta = 1;
string command_id = 2;
string app_code = 3;
int64 sender_user_id = 4;
string room_id = 5;
int64 region_id = 6;
string packet_type = 7;
int64 total_amount = 8;
int32 packet_count = 9;
}
message CreateRedPacketResponse {
string packet_id = 1;
string status = 2;
int64 total_amount = 3;
int32 packet_count = 4;
int64 open_at_ms = 5;
int64 expires_at_ms = 6;
int64 balance_after = 7;
}
message ClaimRedPacketRequest {
common.v1.RequestMeta meta = 1;
string command_id = 2;
string app_code = 3;
string packet_id = 4;
int64 user_id = 5;
}
message ClaimRedPacketResponse {
string claim_id = 1;
string packet_id = 2;
int64 amount = 3;
int64 claimed_at_ms = 4;
int64 balance_after = 5;
}
```
管理侧 RPC
```proto
rpc GetRedPacketConfig(GetRedPacketConfigRequest) returns (GetRedPacketConfigResponse);
rpc UpdateRedPacketConfig(UpdateRedPacketConfigRequest) returns (UpdateRedPacketConfigResponse);
rpc ListRedPackets(ListRedPacketsRequest) returns (ListRedPacketsResponse);
rpc GetRedPacket(GetRedPacketRequest) returns (GetRedPacketResponse);
rpc ExpireRedPackets(ExpireRedPacketsRequest) returns (ExpireRedPacketsResponse);
```
## App HTTP 接口
### 获取红包配置
```http
GET /api/v1/red-packets/config
Authorization: Bearer <access_token>
```
Response `data`
```json
{
"config": {
"enabled": true,
"count_tiers": [5, 10, 20],
"amount_tiers": [100, 500, 1000],
"delayed_open_seconds": 30,
"expire_seconds": 300,
"daily_send_limit": 5
},
"server_time_ms": 1780000000000
}
```
### 发送红包
```http
POST /api/v1/rooms/{room_id}/red-packets/create
Authorization: Bearer <access_token>
Content-Type: application/json
```
Request
```json
{
"command_id": "uuid",
"packet_type": "normal",
"total_amount": 1000,
"packet_count": 10
}
```
`packet_type`
| 值 | 语义 |
| --- | --- |
| `normal` | 普通红包 |
| `delayed` | 延迟红包,打开时间由后台 `delayed_open_seconds` 决定 |
Response `data`
```json
{
"packet": {
"packet_id": "rp_xxx",
"packet_type": "normal",
"status": "active",
"total_amount": 1000,
"packet_count": 10,
"remaining_count": 10,
"open_at_ms": 1780000000000,
"expires_at_ms": 1780000300000
},
"server_time_ms": 1780000000000,
"balance_after": 99000
}
```
### 房间红包列表
```http
GET /api/v1/rooms/{room_id}/red-packets?status=active&page=1&page_size=20
Authorization: Bearer <access_token>
```
Response `data`
```json
{
"packets": [
{
"packet_id": "rp_xxx",
"packet_type": "delayed",
"sender_user_id": 123,
"total_amount": 1000,
"packet_count": 10,
"remaining_count": 10,
"status": "waiting_open",
"open_at_ms": 1780000030000,
"expires_at_ms": 1780000300000,
"created_at_ms": 1780000000000,
"claimed_by_viewer": false
}
],
"total": 1,
"page": 1,
"page_size": 20,
"server_time_ms": 1780000001000
}
```
### 红包详情
```http
GET /api/v1/red-packets/{packet_id}
Authorization: Bearer <access_token>
```
Response `data`
```json
{
"packet": {
"packet_id": "rp_xxx",
"packet_type": "normal",
"sender_user_id": 123,
"room_id": "100001",
"region_id": 86,
"total_amount": 1000,
"packet_count": 10,
"remaining_amount": 420,
"remaining_count": 4,
"status": "active",
"open_at_ms": 1780000000000,
"expires_at_ms": 1780000300000,
"claimed_by_viewer": true,
"viewer_claim_amount": 88,
"claims": [
{
"user_id": 456,
"amount": 120,
"created_at_ms": 1780000005000
}
]
},
"server_time_ms": 1780000010000
}
```
领取列表是否展示完整金额由产品决定;如果需要弱化刺激,可以只展示自己的领取金额和已领取人数。
### 抢红包
```http
POST /api/v1/red-packets/{packet_id}/claim
Authorization: Bearer <access_token>
Content-Type: application/json
```
Request
```json
{
"command_id": "uuid"
}
```
Response `data`
```json
{
"claim": {
"claim_id": "rpc_xxx",
"packet_id": "rp_xxx",
"amount": 88,
"created_at_ms": 1780000010000
},
"server_time_ms": 1780000010000,
"balance_after": 100088
}
```
## 错误码
| code | HTTP | 场景 | Flutter 处理 |
| --- | --- | --- | --- |
| `RED_PACKET_DISABLED` | 409 | 红包功能未启用 | 隐藏入口或提示暂不可用 |
| `INVALID_ARGUMENT` | 400 | 类型不是 `normal/delayed` 或请求字段缺失 | 不重试,刷新配置 |
| `INVALID_AMOUNT_TIER` | 400 | 金额未命中后台档位 | 不重试,刷新配置 |
| `INVALID_COUNT_TIER` | 400 | 数量未命中后台档位 | 不重试,刷新配置 |
| `RED_PACKET_AMOUNT_TOO_SMALL` | 400 | 总金额小于红包数量 | 不重试,前端校验兜底 |
| `DAILY_SEND_LIMIT_REACHED` | 429 | 达到 UTC 当日发送上限 | 禁用发送入口到下一 UTC 日 |
| `INSUFFICIENT_BALANCE` | 409 | 金币不足 | 引导充值或选择低档 |
| `NOT_FOUND` | 404 | 红包不存在 | 关闭详情页并刷新列表 |
| `RED_PACKET_NOT_OPEN` | 409 | 延迟红包未到打开时间 | 展示倒计时,不重试抢 |
| `RED_PACKET_EXPIRED` | 409 | 红包已过期 | 刷新详情和列表 |
| `RED_PACKET_ALREADY_CLAIMED` | 409 | 当前用户已领取 | 展示已领取金额 |
| `RED_PACKET_SOLD_OUT` | 409 | 已被抢完 | 展示已抢完 |
| `REQUEST_CONFLICT` | 409 | 同一 `command_id` payload 不一致 | 重新生成命令号后由用户重新发起 |
## 后台设计
菜单:
```text
活动管理
红包配置
红包记录
```
### 红包配置页
字段:
| 字段 | 控件 | 校验 |
| --- | --- | --- |
| 是否启用 | Switch | 关闭后 App 获取配置仍返回档位,但发送接口拒绝 |
| 红包数量档位 | 可增删数字列表 | 每项 `> 0`,去重,建议排序 |
| 红包金额档位 | 可增删数字列表 | 每项 `> 0`,去重,金额单位金币 |
| 延迟红包秒数 | 数字输入 | `>= 0``0` 表示延迟红包立即可抢,但仍标记为 delayed |
| 每人每日最多发送次数 | 数字输入 | `>= 0``0` 表示禁止发送 |
| 红包过期秒数 | 数字输入 | `> 0`,必须大于延迟秒数,否则延迟红包可能还没打开就过期 |
后台提交时 admin server 生成审计字段:
```text
admin_id
request_id
operation
before_config
after_config
created_at_ms
```
### 红包记录页
筛选:
```text
packet_id
sender_user_id
room_id
region_id
packet_type
status
created_start_ms / created_end_ms
```
列表字段:
```text
packet_id
sender_user_id
room_id
region_id
packet_type
total_amount
packet_count
remaining_amount
remaining_count
status
open_at_ms
expires_at_ms
created_at_ms
```
详情页展示领取明细、退款记录和钱包交易 ID只读不允许后台手动改红包状态。需要人工补偿时走现有后台调账或补偿命令不直接改红包表。
Admin HTTP
```http
GET /api/v1/admin/activity/red-packets/config
PUT /api/v1/admin/activity/red-packets/config
GET /api/v1/admin/activity/red-packets
GET /api/v1/admin/activity/red-packets/{packet_id}
```
## Flutter 对接方案
Flutter 不实现红包金额随机、发送次数、过期退款或账务判断,只消费服务端结果。
### 初始化
进入房间时并行拉取:
```text
GET /api/v1/red-packets/config
GET /api/v1/rooms/{room_id}/red-packets?status=active&page=1&page_size=20
```
UI 使用 `server_time_ms` 计算倒计时偏移:
```text
client_offset_ms = server_time_ms - local_now_ms
effective_now_ms = local_now_ms + client_offset_ms
```
倒计时只用于展示,最终可抢状态以服务端 `ClaimRedPacket` 结果为准。
### 发红包
用户只能从 `count_tiers``amount_tiers` 选择。提交前生成一次性 `command_id`,网络超时可用同一个 `command_id` 重试同一请求。
普通红包:
```json
{
"command_id": "uuid",
"packet_type": "normal",
"total_amount": 1000,
"packet_count": 10
}
```
延迟红包:
```json
{
"command_id": "uuid",
"packet_type": "delayed",
"total_amount": 1000,
"packet_count": 10
}
```
延迟秒数不由 Flutter 传入,只使用后台配置。
### 抢红包
每次点击抢红包生成新的 `command_id`。如果返回超时,可以用同一个 `command_id` 查询或重试。收到 `RED_PACKET_ALREADY_CLAIMED` 时需要拉详情展示已领取金额。
### IM 飘屏
区域播报消息:
```json
{
"broadcast_type": "red_packet_created",
"packet_id": "rp_xxx",
"packet_type": "delayed",
"sender_user_id": 123,
"room_id": "100001",
"region_id": 86,
"total_amount": 1000,
"packet_count": 10,
"open_at_ms": 1780000030000,
"expires_at_ms": 1780000300000,
"created_at_ms": 1780000000000
}
```
Flutter 收到飘屏后:
- 当前在同区域时展示飘屏。
- 点击飘屏进入对应房间或打开红包详情。
- 如果 `effective_now_ms < open_at_ms`,展示倒计时。
- 如果 `effective_now_ms >= expires_at_ms`,点击后直接拉详情,以服务端状态为准。
## 实现顺序
1. `wallet-service` 增加红包表、配置表、领域模型和 MySQL repository。
2. `api/proto/wallet/v1` 增加红包 App RPC、Admin RPC 和 `ExpireRedPackets` 批处理 RPC运行 `make proto`
3. `wallet-service` 实现 `CreateRedPacket`,覆盖档位、余额、每日次数、随机拆分、幂等和 outbox。
4. `wallet-service` 实现 `ClaimRedPacket`,覆盖延迟未开、过期、重复领取、并发抢完和入账。
5. `wallet-service` 实现过期退款 worker 或批处理 RPC。
6. `activity-service` 增加红包 outbox consumer调用 `PublishRegionBroadcast` 写区域播报。
7. `gateway-service` 增加 App HTTP 接口,保持 `{code,message,request_id,data}` envelope。
8. `server/admin` 增加配置和记录查询接口,写后台审计。
9. `hyapp-admin-platform` 增加 `活动管理 / 红包配置` 和红包记录页。
10. 更新 Flutter 对接文档,不改 Flutter 代码,等 App 侧按接口接入。
## 验证计划
### wallet-service 单元和 MySQL 集成
| 场景 | 预期 |
| --- | --- |
| 创建普通红包 | sender 扣款,红包 activeallocation 总和等于总金额 |
| 创建延迟红包 | sender 扣款,红包 waiting_open`open_at_ms` 正确 |
| 非档位金额 | 返回 `INVALID_AMOUNT_TIER`,余额不变 |
| 非档位数量 | 返回 `INVALID_COUNT_TIER`,余额不变 |
| 总金额小于数量 | 返回 `RED_PACKET_AMOUNT_TOO_SMALL` |
| 金币不足 | 返回 `INSUFFICIENT_BALANCE`,不写红包 |
| 每日次数超过 | 返回 `DAILY_SEND_LIMIT_REACHED`,按 UTC 日期切换 |
| 同 command 重试 | 返回同一 `packet_id`,余额只扣一次 |
| 同 command 不同 payload | 返回 `REQUEST_CONFLICT` |
| 未到打开时间领取 | 返回 `RED_PACKET_NOT_OPEN` |
| 重复领取 | 第二次返回 `RED_PACKET_ALREADY_CLAIMED` |
| 并发领取 | 不超发,领取总额等于红包总额 |
| 抢完后领取 | 返回 `RED_PACKET_SOLD_OUT` |
| 过期退款 | 未领取金额退回 sender重复扫描不重复退款 |
### activity-service 验证
| 场景 | 预期 |
| --- | --- |
| 消费 `red_packet.created` | 写入一条 `im_broadcast_outbox` |
| outbox 重投 | 不重复写同一飘屏 |
| 区域 ID 无效 | 记录失败原因,不影响红包账务 |
### gateway/admin 验证
| 场景 | 预期 |
| --- | --- |
| App 接口 envelope | 所有业务响应符合 `{code,message,request_id,data}` |
| gateway 不信任 region_id | 客户端无法伪造区域飘屏 |
| admin 更新配置 | wallet 配置生效,写后台审计 |
| admin 记录查询 | 可按用户、房间、状态和时间范围查询 |
### 本地真实数据流程
```text
1. admin 配置数量档位、金额档位、延迟秒数、过期秒数和每日次数。
2. 用户 A 充值金币或后台入账金币。
3. 用户 A 在房间发普通红包,确认钱包扣款、红包 active、区域飘屏 outbox 产生。
4. 用户 B/C/D 抢红包,确认每人金额随机、总和不超过总额、钱包入账。
5. 用户 A 发延迟红包open_at_ms 前抢返回 RED_PACKET_NOT_OPEN。
6. 到 open_at_ms 后领取成功。
7. 创建一个短过期红包,等待过期 worker 执行,确认未领取金额退回用户 A。
8. 同一 UTC 日连续发送到上限,再发送返回 DAILY_SEND_LIMIT_REACHED。
```
## 待确认产品点
| 问题 | 默认建议 |
| --- | --- |
| 发送者是否可以抢自己的红包 | 默认允许,减少用户困惑;如需禁止,加领取校验即可 |
| 红包详情是否展示所有领取金额 | 默认展示自己的金额和领取人数,完整明细可配置 |
| 飘屏点击是否跨房间跳转 | 默认点击进入对应房间并打开红包详情 |
| 延迟红包倒计时期间是否展示总额 | 默认展示总额、数量、打开时间,不展示任何随机份额 |

View File

@ -0,0 +1,268 @@
# 统计服务技术方案
## 目标
`statistics-service` 是后台数据大屏的统计读模型服务。它只消费各 owner service 已提交并发布到 RocketMQ 的业务事实,写入本服务 MySQL 聚合表,再向 `server/admin` 提供内部查询 API。
硬性规则:
- 统计查询不查全表。
- 统计查询不回查 owner service 明细库。
- 统计服务不直连 `hyapp_room``hyapp_wallet``hyapp_user``hyapp_game` 扫描业务表或 outbox。
- 所有数据大屏指标只能查询 `hyapp_statistics` 聚合表。
- 所有统计时间范围统一使用 UTC `[start_ms, end_ms)`
## 服务边界
| 服务 | 职责 |
| --- | --- |
| `user-service` | 用户注册、用户主数据 owner`user_outbox.UserRegistered` 并发布 MQ |
| `wallet-service` | 账务和充值 owner`wallet_outbox.WalletRechargeRecorded` 并发布 MQ |
| `room-service` | 房间状态 owner`room_outbox.RoomUserJoined``RoomGiftSent` 并发布 MQ |
| `game-service` | 游戏订单 owner`game_outbox.GameOrderSettled` 并发布 MQ |
| `statistics-service` | 消费 MQ 事实,写统计幂等表和聚合表,提供内部统计查询 |
| `server/admin` | 后台管理 HTTP API调用 `statistics-service` 查询数据大屏 |
| `gateway-service` | App HTTP 入口;默认不暴露后台数据大屏统计 |
| RocketMQ/TDMQ | 事件 fanout 和消费位点,不拥有业务状态 |
| MySQL `hyapp_statistics` | 统计服务自己的幂等事实和聚合读模型 |
`statistics-service` 不是业务事实 owner。任何缺失指标都必须先补 owner service 事件,再补统计消费逻辑;不能在统计服务里反查业务表绕过事件边界。
## 总体架构
```mermaid
flowchart LR
User["user-service\nuser_outbox"] --> MQ["RocketMQ / TDMQ"]
Wallet["wallet-service\nwallet_outbox"] --> MQ
Room["room-service\nroom_outbox"] --> MQ
Game["game-service\ngame_outbox"] --> MQ
MQ --> Stats["statistics-service\nconsumer groups"]
Stats --> StatsDB["hyapp_statistics\nidempotency + aggregate tables"]
Admin["server/admin"] --> StatsAPI["statistics-service\n/internal/v1/statistics/overview"]
StatsAPI --> StatsDB
Admin --> Dashboard["数据大屏"]
```
## 事件事实
统计事件只从 MQ 进入。每个事件必须有稳定 `event_id`,统计服务按 `app_code + source + event_id` 幂等。
| Source | Topic | Event | Owner 语义 | 统计用途 |
| --- | --- | --- | --- | --- |
| `user` | `hyapp_user_outbox` | `UserRegistered` | 用户注册事实 | 新增用户、留存 cohort |
| `wallet` | `hyapp_wallet_outbox` | `WalletRechargeRecorded` | 充值到账事实 | 总充值、首充、付费用户、渠道分桶、ARPU、ARPPU、UP 值 |
| `room` | `hyapp_room_outbox` | `RoomUserJoined` | 用户进房成功 | 活跃用户、房间国家维度活跃 |
| `room` | `hyapp_room_outbox` | `RoomGiftSent` | 送礼已扣费且落房间表现 | 礼物消耗、幸运礼物流水、幸运礼物付费用户 |
| `game` | `hyapp_game_outbox` | `GameOrderSettled` | 游戏订单已结算 | 游戏流水、返奖、退款、玩家数、游戏排行 |
事件字段边界:
- `app_code` 必须由 owner service 写入事件,统计服务只做默认值兜底,不做租户推断。
- `occurred_at_ms` 是业务事实发生时间,必须使用 epoch milliseconds。
- 国家维度只能来自事件字段:注册区域、充值目标区域、房间 `visible_region_id`、游戏事件区域。
- `request_id` 只用于链路追踪,不能作为统计幂等键。
- `event_id` 是统计幂等键,重复消息必须被本地幂等表收敛。
## 口径定义
### 用户与活跃
| 指标 | 口径 |
| --- | --- |
| 新增用户 | `UserRegistered` 按 UTC 日、`app_code`、国家维度聚合 |
| 活跃用户 | `RoomUserJoined``GameOrderSettled` 写入 `stat_user_day_activity`,按用户去重 |
| 次日留存 | 注册 cohort 中,注册日 + 1 天存在活跃记录的用户数 / 注册用户数 |
| 7 日留存 | 注册 cohort 中,注册日 + 7 天存在活跃记录的用户数 / 注册用户数 |
| 30 日留存 | 注册 cohort 中,注册日 + 30 天存在活跃记录的用户数 / 注册用户数 |
留存只 join `stat_user_registration``stat_user_day_activity`,不回查用户表、登录表或房间 presence。
### 充值
| 指标 | 口径 |
| --- | --- |
| 总充值 | `WalletRechargeRecorded.recharge_usd_minor` 累加 |
| 新增充值 | `recharge_sequence == 1` 的充值金额累加 |
| 付费用户 | `stat_recharge_day_payers` 按 UTC 日用户去重 |
| 币商充值 | `recharge_type` 为空或非 `mifapay/google` 时归入币商充值 |
| MifaPay 充值 | `recharge_type == mifapay` |
| Google 充值 | `recharge_type == google` |
| ARPU | 总充值 / 活跃用户 |
| ARPPU | 总充值 / 付费用户 |
| UP 值 | 当前与 ARPPU 同口径,即总充值 / 付费用户 |
充值统计以到账事实为准。支付创建、支付处理中、支付失败、币商库存进货都不能直接计入玩家充值。
### 礼物与幸运礼物
| 指标 | 口径 |
| --- | --- |
| 礼物消耗 | `RoomGiftSent.gift_value` 累加 |
| 幸运礼物流水 | `RoomGiftSent.pool_id` 非空时的 `gift_value` 累加 |
| 幸运礼物付费用户 | `pool_id` 非空的送礼用户按 UTC 日去重 |
房间礼物事件必须在 wallet 扣费成功后产生。统计服务不读取钱包流水来反推礼物消耗。
### 游戏
| 指标 | 口径 |
| --- | --- |
| 游戏流水 | `GameOrderSettled.op_type in (debit, bet)``coin_amount` 累加 |
| 游戏返奖 | `op_type in (credit, payout)``coin_amount` 累加 |
| 游戏退款 | `op_type in (refund, reverse)``coin_amount` 累加 |
| 游戏利润 | 游戏流水 - 游戏返奖 - 游戏退款 |
| 游戏利润率 | 游戏利润 / 游戏流水 |
| 游戏参与人数 | 投注事件按用户、游戏、UTC 日去重 |
| 游戏排行 | `stat_game_day_country` 按游戏流水倒序 |
游戏统计以游戏订单结算事实为准。统计服务不调用游戏厂商接口、不读取游戏明细表临时汇总。
## 聚合表设计
所有聚合表位于 `hyapp_statistics`,由 `statistics-service` 迁移和写入。
| 表 | 作用 | 主键/幂等边界 |
| --- | --- | --- |
| `statistics_event_consumption` | 消费幂等表 | `(app_code, source, event_id)` |
| `stat_app_day_country` | App/UTC 日/国家总览 | `(app_code, stat_day, country_id)` |
| `stat_user_day_activity` | 用户日活跃去重 | `(app_code, stat_day, user_id)` |
| `stat_user_registration` | 注册 cohort | `(app_code, user_id)` |
| `stat_recharge_day_payers` | 日付费用户去重 | `(app_code, stat_day, user_id)` |
| `stat_lucky_gift_day_payers` | 幸运礼物日付费用户去重 | `(app_code, stat_day, country_id, user_id)` |
| `stat_game_day_country` | 游戏日聚合和排行 | `(app_code, stat_day, country_id, platform_code, game_id)` |
| `stat_game_day_players` | 游戏玩家去重 | `(app_code, stat_day, country_id, platform_code, game_id, user_id)` |
写入原则:
- 先写 `statistics_event_consumption`,再更新聚合表。
- 已消费事件直接跳过,保证 MQ 重投不重复累加。
- 唯一用户类指标使用 `INSERT IGNORE` 到去重表,再用 affected rows 增量更新聚合表。
- 聚合表只追加或增量更新当前统计事实;修正历史数据必须通过重新投递事实或明确的重算任务,不允许在线查询扫明细补算。
## 查询契约
`statistics-service` 当前提供内部 HTTP 查询 API
```text
GET /internal/v1/statistics/overview?app_code=lalu&start_ms=<UTC ms>&end_ms=<UTC ms>&country_id=<optional>
```
返回范围:
- 总览:新增、活跃、付费、充值渠道、礼物、幸运礼物、游戏流水/返奖/退款/利润。
- ARPU/ARPPU/UP 值。
- 次留、7 留、30 留。
- 游戏排行。
调用边界:
- `server/admin` 调这个内部 API 后再返回后台前端。
- `gateway-service` 默认不调用这个接口。
- 查询参数必须是 UTC epoch milliseconds。
- `country_id` 为空表示汇总全部国家;大于 0 表示指定国家维度。
## 部署方案
当前低成本部署使用:
```text
CVM / Docker Compose
TencentDB for MySQL
TencentDB Redis
TDMQ for RocketMQ
statistics-service
```
组件职责:
| 组件 | 用途 |
| --- | --- |
| CVM / Docker Compose | 部署 Go 服务和 RocketMQ worker 配置 |
| TencentDB for MySQL | 保存 owner service 业务库和 `hyapp_statistics` 聚合库 |
| TencentDB Redis | 房间 lease、路由和热状态 |
| TDMQ for RocketMQ | owner outbox 到下游消费者的事件总线 |
| `statistics-service` | 独立 consumer group 消费统计事件并提供内部查询 |
无 Kubernetes 可以运行。需要保证:
- 每个服务实例配置唯一 `node_id`
- RocketMQ topic 和 consumer group 在线上预先创建或由部署流程管理。
- `statistics-service` 多实例消费时必须使用同一个统计 consumer group依赖本地幂等表防重复。
- MySQL DSN 使用 `loc=UTC`,容器 `TZ=UTC`
- `statistics-service` 查询端口只暴露给内网或 admin 服务,不对公网开放。
## 本地验证流程
本地端口:
| 服务 | 端口 |
| --- | --- |
| `statistics-service` 查询 HTTP | `13010` |
| `statistics-service` health HTTP | `13110` |
| MySQL | `23306` |
| RocketMQ namesrv | `19876` 或 compose 当前映射 |
| RocketMQ broker | `19009` / `19011` 或 compose 当前映射 |
基础验证:
```bash
make proto
make test
docker compose config
docker compose build
docker compose up -d
docker compose ps
```
真实链路验证:
1. 启动 MySQL、Redis、RocketMQ、owner service、`statistics-service`
2. 创建 topic 和 consumer group。
3. 清空 `hyapp_statistics` 聚合表。
4. 将已有 owner outbox 的目标事件标记为 `pending`
5. 启动 owner outbox worker 发布 MQ。
6. 等待 `statistics_event_consumption` 和聚合表增长。
7. 调用 `/internal/v1/statistics/overview` 检查返回。
典型检查 SQL
```sql
SELECT source, event_type, COUNT(*)
FROM hyapp_statistics.statistics_event_consumption
GROUP BY source, event_type
ORDER BY source, event_type;
SELECT COUNT(*) AS rows_count,
SUM(active_users) AS active_users,
SUM(paid_users) AS paid_users,
SUM(recharge_usd_minor) AS recharge_usd_minor,
SUM(game_turnover) AS game_turnover,
SUM(game_payout) AS game_payout,
SUM(game_refund) AS game_refund,
SUM(gift_coin_spent) AS gift_coin_spent
FROM hyapp_statistics.stat_app_day_country;
```
## 当前限制
- 历史库如果没有 `UserRegistered` outbox留存 cohort 会按当前事实为 0。
- 历史库如果没有 `game_outbox.GameOrderSettled`,游戏统计会按当前事实为 0。
- MifaPay/Google 需要 owner 支付成功链路写出 `WalletRechargeRecorded` 且带 `recharge_type`,统计服务才能分桶。
- 老版本 `RoomUserJoined` 如果没有 `visible_region_id`,历史活跃会落到国家 0新事件必须带房间 `visible_region_id`
- 当前 `up_value_usd_minor` 与 ARPPU 同口径;产品调整定义时必须同步修改契约、字段和测试。
## 扩展规则
新增统计指标时按固定顺序推进:
1. 明确 owner service 和事实事件。
2. 在 owner service 同一事务内写 outbox。
3. 发布到 RocketMQ topic。
4. 在 `statistics-service` 新增 consumer 解析和幂等消费。
5. 新增或扩展聚合表。
6. 扩展查询 API。
7. 补 UTC `[start_ms, end_ms)` 边界测试。
8. 用本地 MQ 真实链路验证。
不要从“数据大屏缺一个字段”直接跳到“查某个业务表”。统计系统的可扩展性来自稳定事件事实和聚合表,不来自临时 SQL。

View File

@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "hyapp-prod-497009",
"private_key_id": "876bc686e4b6aef7d654bde2811d4e5982cd8f76",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDSaVTeu9Zov44W\nZEnpnY+5wYeVyVYEX/N0Z2dOpihTq3lTvt8aH5b7yKM33R31PjUS2kcylp8610jc\n2Y3Eyt1LH2ckYjbskm+D0JRxlm3GlJjbvI9grBgzMdCGs53Fojkie1RxfQ7OiqbJ\nd3x9Mg48KM4ECYG7m7NCujKSjSEyDCA5O3TPX+MOZkUTgQ4LjGjUsChvxPaGqgnf\nFgs9CUoMAv/a2vEK5aTprd6N5a3HZk54Dk3Z8pnJsElNxuw2y+uGSLqaAsFxnFYG\nK4EK7B0hAcTZJ+4wBDQbs9xgDMxmnXPY5Olgj9IZu79jQYjRUJaI9Is6EeD/n3N5\nDTTejF83AgMBAAECggEAN8SHJAmxCE5NAI5QMMT8BQXQQ9j7HWm5Aw8tCGFtqs91\n9sZOJ4Rb5hwFdpb6i7eTJY4I9NWLGnNY8oAO3SvPf0uxcKk/WXBucgdkcL6oFcvo\nv8S4U60JNPhtBmaw2GLotcTuWq4EsKV7E0bCDgSoVTWTdOp4IhtgZb0oDkOtmq+0\n3saRqPMN+x/iHPI4dAh2nMO6eXmqA+7Fh8fFAVToRDrrYduACG5judRdrt3p0wDq\nUSlt69BHUmv6fV57eY3/+wmwkwQKFR6F0CSfkxpyZVNUarFi33KwF0iD6c9V/IRG\nBDK+AfOJgIVeZcA4MiqGyFClE2c1C/2mHwSwNby08QKBgQD0yrhyVRiRaXAaygu0\np/KgL4Q4FWEQ337D4bgnEbiETABA/ZFn9GcO0hpO1Bn3zKVTlAaGxKo1QeIgLm2s\noP3Np4xo6+2tRsipRuXzA9EKKSax0tZLa1qdRQmqPihhIx3pRRUJpOSIiC3cTBiz\nXMjlcDlYMj2KTn10Zs2FgPCUkwKBgQDcC6CvifqUMuQbiWMcwMLdLAUSNBG5pq+R\n8QbOqjYhyYOXYmr2iJ6na7q2f4cFdormweaZ4MDf0Zgln9miRUi+f+LotIdP3mp1\n+IIDs04LsOGfLukPnHapSn87Q+On1thu84oKwgrqelByAOzo38/7kgEgKkhoryXM\nEVpBHmX1TQKBgF8JpXqeFx+9c8yyzMCiw8v1JrwvcLQAUVLze2+PrbePWjnhOGbH\nuItEfvpD1qEiTr2YJsCD8iEjJwufeu0ew+roNdc2Ydx4MselwvkKbkonl+JKHPDD\nCTct6oayAzNTUvWR4I0R+7gWRtfUo3Jff4+0dk8LkmD0ADkFAJ3oZX8JAoGAHJ+S\nlmL/anm4PHDBqMLWVkjcnAKT769kFTriJM31Xq3E5VNTEKCy6ppT3lt2Z9qEtQGy\nCoA29qZgATzq5XXXwZgHHzgkhdorxQ6/ctXHKDQpFTjX7kTvFpRvOnlZSsLiwoQB\nh+a33spsXngKWTyL5HCYnuOBKcTmyMXVFLVsk90CgYBRey8XqtLV/Yh7kc5DSXC4\nEHfF11m/C25CCD7jhGT6HpnnqAX7ujZldBQokEon1C97FhyErX8dit8ffPAc5+Ns\ntZVQcgJorlWW3gIwzz1ehCHzy8CYYzloTxI1m/vMkAoGuovzsyvZpsvVKXPD6F0D\n/q6rF/VaFOM83eSkSdTssQ==\n-----END PRIVATE KEY-----\n",
"client_email": "hyapp-play-api@hyapp-prod-497009.iam.gserviceaccount.com",
"client_id": "110702736389997467893",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/hyapp-play-api%40hyapp-prod-497009.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

76
pkg/gamemq/messages.go Normal file
View File

@ -0,0 +1,76 @@
// Package gamemq defines RocketMQ payloads owned by game-service.
package gamemq
import (
"encoding/json"
"errors"
"strings"
)
const (
MessageTypeGameOutboxEvent = "game_outbox_event"
TagGameOutboxEvent = "game_outbox_event"
)
// GameOutboxMessage is the MQ representation of one committed game_outbox fact.
type GameOutboxMessage struct {
MessageType string `json:"message_type"`
AppCode string `json:"app_code"`
EventID string `json:"event_id"`
EventType string `json:"event_type"`
OrderID string `json:"order_id"`
UserID int64 `json:"user_id"`
PlatformCode string `json:"platform_code"`
GameID string `json:"game_id"`
OpType string `json:"op_type"`
CoinAmount int64 `json:"coin_amount"`
PayloadJSON string `json:"payload_json"`
OccurredAtMS int64 `json:"occurred_at_ms"`
}
// EncodeGameOutboxMessage serializes a game fact for fanout through RocketMQ.
func EncodeGameOutboxMessage(message GameOutboxMessage) ([]byte, error) {
message.MessageType = MessageTypeGameOutboxEvent
if strings.TrimSpace(message.AppCode) == "" ||
strings.TrimSpace(message.EventID) == "" ||
strings.TrimSpace(message.EventType) == "" ||
strings.TrimSpace(message.OrderID) == "" ||
message.UserID <= 0 ||
strings.TrimSpace(message.GameID) == "" ||
strings.TrimSpace(message.OpType) == "" ||
message.CoinAmount <= 0 ||
message.OccurredAtMS <= 0 {
return nil, errors.New("game outbox message is incomplete")
}
if strings.TrimSpace(message.PayloadJSON) == "" {
message.PayloadJSON = "{}"
}
return json.Marshal(message)
}
// DecodeGameOutboxMessage validates the MQ body and restores the game fact.
func DecodeGameOutboxMessage(body []byte) (GameOutboxMessage, error) {
var message GameOutboxMessage
if err := json.Unmarshal(body, &message); err != nil {
return GameOutboxMessage{}, err
}
if message.MessageType != MessageTypeGameOutboxEvent {
return GameOutboxMessage{}, errors.New("unexpected game outbox message_type")
}
if strings.TrimSpace(message.AppCode) == "" ||
strings.TrimSpace(message.EventID) == "" ||
strings.TrimSpace(message.EventType) == "" ||
strings.TrimSpace(message.OrderID) == "" ||
message.UserID <= 0 ||
strings.TrimSpace(message.GameID) == "" ||
strings.TrimSpace(message.OpType) == "" ||
message.CoinAmount <= 0 ||
message.OccurredAtMS <= 0 {
return GameOutboxMessage{}, errors.New("game outbox message is incomplete")
}
if strings.TrimSpace(message.PayloadJSON) == "" {
message.PayloadJSON = "{}"
}
return message, nil
}

View File

@ -7,6 +7,7 @@ import (
"context"
"errors"
"fmt"
"net"
"strings"
"time"
@ -51,6 +52,7 @@ type ConsumerConfig struct {
ConsumeRetryDelay time.Duration
ConsumePullBatch int32
ConsumePullTimeout time.Duration
ConsumeGoroutines int
}
// Message is the stable publish shape used by services.
@ -94,7 +96,11 @@ func NewProducer(cfg ProducerConfig) (*Producer, error) {
return nil, errors.New("rocketmq producer group_name is required")
}
options := []producer.Option{producer.WithGroupName(group)}
options = append(options, producerEndpointOptions(cfg.EndpointConfig)...)
endpointOptions, err := producerEndpointOptions(cfg.EndpointConfig)
if err != nil {
return nil, err
}
options = append(options, endpointOptions...)
if cfg.SendTimeout > 0 {
options = append(options, producer.WithSendMsgTimeout(cfg.SendTimeout))
}
@ -180,7 +186,11 @@ func NewConsumer(cfg ConsumerConfig) (*Consumer, error) {
return nil, errors.New("rocketmq consumer group_name is required")
}
options := []consumer.Option{consumer.WithGroupName(group)}
options = append(options, consumerEndpointOptions(cfg.EndpointConfig)...)
endpointOptions, err := consumerEndpointOptions(cfg.EndpointConfig)
if err != nil {
return nil, err
}
options = append(options, endpointOptions...)
if cfg.MaxReconsumeTimes > 0 {
options = append(options, consumer.WithMaxReconsumeTimes(cfg.MaxReconsumeTimes))
}
@ -193,6 +203,9 @@ func NewConsumer(cfg ConsumerConfig) (*Consumer, error) {
if cfg.ConsumePullTimeout > 0 {
options = append(options, consumer.WithConsumeTimeout(cfg.ConsumePullTimeout))
}
if cfg.ConsumeGoroutines > 0 {
options = append(options, consumer.WithConsumeGoroutineNums(cfg.ConsumeGoroutines))
}
client, err := rocketmq.NewPushConsumer(options...)
if err != nil {
return nil, err
@ -262,9 +275,14 @@ func validateEndpoint(cfg EndpointConfig) error {
return nil
}
func producerEndpointOptions(cfg EndpointConfig) []producer.Option {
func producerEndpointOptions(cfg EndpointConfig) ([]producer.Option, error) {
options := make([]producer.Option, 0, 5)
if nameservers := normalizeStringSlice(cfg.NameServers); len(nameservers) > 0 {
var err error
nameservers, err = resolveNameServers(nameservers)
if err != nil {
return nil, err
}
options = append(options, producer.WithNsResolver(primitive.NewPassthroughResolver(nameservers)))
} else if domain := strings.TrimSpace(cfg.NameServerDomain); domain != "" {
options = append(options, producer.WithNameServerDomain(domain))
@ -276,12 +294,17 @@ func producerEndpointOptions(cfg EndpointConfig) []producer.Option {
if strings.TrimSpace(cfg.AccessKey) != "" || strings.TrimSpace(cfg.SecretKey) != "" {
options = append(options, producer.WithCredentials(credentials(cfg)))
}
return options
return options, nil
}
func consumerEndpointOptions(cfg EndpointConfig) []consumer.Option {
func consumerEndpointOptions(cfg EndpointConfig) ([]consumer.Option, error) {
options := make([]consumer.Option, 0, 5)
if nameservers := normalizeStringSlice(cfg.NameServers); len(nameservers) > 0 {
var err error
nameservers, err = resolveNameServers(nameservers)
if err != nil {
return nil, err
}
options = append(options, consumer.WithNsResolver(primitive.NewPassthroughResolver(nameservers)))
} else if domain := strings.TrimSpace(cfg.NameServerDomain); domain != "" {
options = append(options, consumer.WithNameServerDomain(domain))
@ -293,7 +316,30 @@ func consumerEndpointOptions(cfg EndpointConfig) []consumer.Option {
if strings.TrimSpace(cfg.AccessKey) != "" || strings.TrimSpace(cfg.SecretKey) != "" {
options = append(options, consumer.WithCredentials(credentials(cfg)))
}
return options
return options, nil
}
func resolveNameServers(nameservers []string) ([]string, error) {
resolved := make([]string, 0, len(nameservers))
for _, nameserver := range nameservers {
host, port, err := net.SplitHostPort(nameserver)
if err != nil {
return nil, fmt.Errorf("invalid rocketmq name_server %q: %w", nameserver, err)
}
if net.ParseIP(host) != nil {
resolved = append(resolved, net.JoinHostPort(host, port))
continue
}
ips, err := net.LookupHost(host)
if err != nil {
return nil, fmt.Errorf("resolve rocketmq name_server %q: %w", nameserver, err)
}
if len(ips) == 0 {
return nil, fmt.Errorf("resolve rocketmq name_server %q: empty result", nameserver)
}
resolved = append(resolved, net.JoinHostPort(ips[0], port))
}
return resolved, nil
}
func credentials(cfg EndpointConfig) primitive.Credentials {

66
pkg/usermq/messages.go Normal file
View File

@ -0,0 +1,66 @@
// Package usermq defines RocketMQ payloads owned by user-service.
package usermq
import (
"encoding/json"
"errors"
"strings"
)
const (
MessageTypeUserOutboxEvent = "user_outbox_event"
TagUserOutboxEvent = "user_outbox_event"
)
// UserOutboxMessage is the MQ representation of one committed user_outbox fact.
type UserOutboxMessage struct {
MessageType string `json:"message_type"`
AppCode string `json:"app_code"`
EventID string `json:"event_id"`
EventType string `json:"event_type"`
AggregateType string `json:"aggregate_type"`
AggregateID int64 `json:"aggregate_id"`
PayloadJSON string `json:"payload_json"`
OccurredAtMS int64 `json:"occurred_at_ms"`
}
// EncodeUserOutboxMessage serializes a user fact for fanout through RocketMQ.
func EncodeUserOutboxMessage(message UserOutboxMessage) ([]byte, error) {
message.MessageType = MessageTypeUserOutboxEvent
if strings.TrimSpace(message.AppCode) == "" ||
strings.TrimSpace(message.EventID) == "" ||
strings.TrimSpace(message.EventType) == "" ||
strings.TrimSpace(message.AggregateType) == "" ||
message.AggregateID <= 0 ||
message.OccurredAtMS <= 0 {
return nil, errors.New("user outbox message is incomplete")
}
if strings.TrimSpace(message.PayloadJSON) == "" {
message.PayloadJSON = "{}"
}
return json.Marshal(message)
}
// DecodeUserOutboxMessage validates the MQ body and restores the user fact.
func DecodeUserOutboxMessage(body []byte) (UserOutboxMessage, error) {
var message UserOutboxMessage
if err := json.Unmarshal(body, &message); err != nil {
return UserOutboxMessage{}, err
}
if message.MessageType != MessageTypeUserOutboxEvent {
return UserOutboxMessage{}, errors.New("unexpected user outbox message_type")
}
if strings.TrimSpace(message.AppCode) == "" ||
strings.TrimSpace(message.EventID) == "" ||
strings.TrimSpace(message.EventType) == "" ||
strings.TrimSpace(message.AggregateType) == "" ||
message.AggregateID <= 0 ||
message.OccurredAtMS <= 0 {
return UserOutboxMessage{}, errors.New("user outbox message is incomplete")
}
if strings.TrimSpace(message.PayloadJSON) == "" {
message.PayloadJSON = "{}"
}
return message, nil
}

70
pkg/walletmq/messages.go Normal file
View File

@ -0,0 +1,70 @@
// Package walletmq defines RocketMQ payloads owned by wallet-service.
package walletmq
import (
"encoding/json"
"errors"
"strings"
)
const (
MessageTypeWalletOutboxEvent = "wallet_outbox_event"
TagWalletOutboxEvent = "wallet_outbox_event"
)
// WalletOutboxMessage is the MQ representation of one committed wallet_outbox fact.
type WalletOutboxMessage struct {
MessageType string `json:"message_type"`
AppCode string `json:"app_code"`
EventID string `json:"event_id"`
EventType string `json:"event_type"`
TransactionID string `json:"transaction_id"`
CommandID string `json:"command_id"`
UserID int64 `json:"user_id"`
AssetType string `json:"asset_type"`
AvailableDelta int64 `json:"available_delta"`
FrozenDelta int64 `json:"frozen_delta"`
PayloadJSON string `json:"payload_json"`
OccurredAtMS int64 `json:"occurred_at_ms"`
}
// EncodeWalletOutboxMessage serializes a wallet fact for fanout through RocketMQ.
func EncodeWalletOutboxMessage(message WalletOutboxMessage) ([]byte, error) {
message.MessageType = MessageTypeWalletOutboxEvent
if strings.TrimSpace(message.AppCode) == "" ||
strings.TrimSpace(message.EventID) == "" ||
strings.TrimSpace(message.EventType) == "" ||
strings.TrimSpace(message.TransactionID) == "" ||
strings.TrimSpace(message.CommandID) == "" ||
message.OccurredAtMS <= 0 {
return nil, errors.New("wallet outbox message is incomplete")
}
if strings.TrimSpace(message.PayloadJSON) == "" {
message.PayloadJSON = "{}"
}
return json.Marshal(message)
}
// DecodeWalletOutboxMessage validates the MQ body and restores the wallet fact.
func DecodeWalletOutboxMessage(body []byte) (WalletOutboxMessage, error) {
var message WalletOutboxMessage
if err := json.Unmarshal(body, &message); err != nil {
return WalletOutboxMessage{}, err
}
if message.MessageType != MessageTypeWalletOutboxEvent {
return WalletOutboxMessage{}, errors.New("unexpected wallet outbox message_type")
}
if strings.TrimSpace(message.AppCode) == "" ||
strings.TrimSpace(message.EventID) == "" ||
strings.TrimSpace(message.EventType) == "" ||
strings.TrimSpace(message.TransactionID) == "" ||
strings.TrimSpace(message.CommandID) == "" ||
message.OccurredAtMS <= 0 {
return WalletOutboxMessage{}, errors.New("wallet outbox message is incomplete")
}
if strings.TrimSpace(message.PayloadJSON) == "" {
message.PayloadJSON = "{}"
}
return message, nil
}

View File

@ -0,0 +1,29 @@
package walletmq
import "testing"
func TestWalletOutboxMessageRoundTrip(t *testing.T) {
encoded, err := EncodeWalletOutboxMessage(WalletOutboxMessage{
AppCode: "lalu",
EventID: "event-1",
EventType: "WalletRechargeRecorded",
TransactionID: "tx-1",
CommandID: "cmd-1",
UserID: 1001,
AssetType: "COIN",
AvailableDelta: 100,
PayloadJSON: `{"recharge_sequence":1}`,
OccurredAtMS: 1_700_000_000_000,
})
if err != nil {
t.Fatalf("EncodeWalletOutboxMessage failed: %v", err)
}
decoded, err := DecodeWalletOutboxMessage(encoded)
if err != nil {
t.Fatalf("DecodeWalletOutboxMessage failed: %v", err)
}
if decoded.MessageType != MessageTypeWalletOutboxEvent || decoded.EventID != "event-1" || decoded.PayloadJSON == "" {
t.Fatalf("decoded message mismatch: %+v", decoded)
}
}

View File

@ -75,6 +75,17 @@ var catalog = map[Code]Spec{
VIPLevelNotFound: spec(codes.NotFound, httpStatusNotFound, VIPLevelNotFound, "not found"),
VIPLevelDisabled: spec(codes.FailedPrecondition, httpStatusConflict, VIPLevelDisabled, "conflict"),
VIPDowngradeNotAllowed: spec(codes.FailedPrecondition, httpStatusConflict, VIPDowngradeNotAllowed, "conflict"),
VIPRechargeRequired: spec(codes.FailedPrecondition, httpStatusConflict, VIPRechargeRequired, "conflict"),
RedPacketDisabled: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketDisabled, "conflict"),
RedPacketInvalidAmountTier: spec(codes.InvalidArgument, httpStatusBadRequest, RedPacketInvalidAmountTier, "invalid argument"),
RedPacketInvalidCountTier: spec(codes.InvalidArgument, httpStatusBadRequest, RedPacketInvalidCountTier, "invalid argument"),
RedPacketAmountTooSmall: spec(codes.InvalidArgument, httpStatusBadRequest, RedPacketAmountTooSmall, "invalid argument"),
RedPacketDailyLimitReached: spec(codes.ResourceExhausted, 429, RedPacketDailyLimitReached, "rate limited"),
RedPacketNotOpen: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketNotOpen, "conflict"),
RedPacketExpired: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketExpired, "conflict"),
RedPacketAlreadyClaimed: spec(codes.AlreadyExists, httpStatusConflict, RedPacketAlreadyClaimed, "conflict"),
RedPacketSoldOut: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketSoldOut, "conflict"),
RuleNotActive: spec(codes.FailedPrecondition, httpStatusConflict, RuleNotActive, "conflict"),
EventAlreadyConsumed: spec(codes.AlreadyExists, httpStatusConflict, EventAlreadyConsumed, "conflict"),

View File

@ -94,6 +94,27 @@ const (
VIPLevelDisabled Code = "VIP_LEVEL_DISABLED"
// VIPDowngradeNotAllowed 表示用户当前有效 VIP 等级高于本次购买目标等级。
VIPDowngradeNotAllowed Code = "VIP_DOWNGRADE_NOT_ALLOWED"
// VIPRechargeRequired 表示目标 VIP 等级需要用户先达到累计充值门槛。
VIPRechargeRequired Code = "VIP_RECHARGE_REQUIRED"
// RedPacketDisabled 表示红包功能未启用。
RedPacketDisabled Code = "RED_PACKET_DISABLED"
// RedPacketInvalidAmountTier 表示红包金额不在后台配置档位内。
RedPacketInvalidAmountTier Code = "INVALID_AMOUNT_TIER"
// RedPacketInvalidCountTier 表示红包数量不在后台配置档位内。
RedPacketInvalidCountTier Code = "INVALID_COUNT_TIER"
// RedPacketAmountTooSmall 表示红包总金额小于红包份数,无法保证每份至少 1 金币。
RedPacketAmountTooSmall Code = "RED_PACKET_AMOUNT_TOO_SMALL"
// RedPacketDailyLimitReached 表示用户达到 UTC 当日发送次数上限。
RedPacketDailyLimitReached Code = "DAILY_SEND_LIMIT_REACHED"
// RedPacketNotOpen 表示延迟红包尚未到打开时间。
RedPacketNotOpen Code = "RED_PACKET_NOT_OPEN"
// RedPacketExpired 表示红包已经过期。
RedPacketExpired Code = "RED_PACKET_EXPIRED"
// RedPacketAlreadyClaimed 表示当前用户已领取过该红包。
RedPacketAlreadyClaimed Code = "RED_PACKET_ALREADY_CLAIMED"
// RedPacketSoldOut 表示红包已经被抢完。
RedPacketSoldOut Code = "RED_PACKET_SOLD_OUT"
// RuleNotActive 表示活动规则当前不可用。
RuleNotActive Code = "RULE_NOT_ACTIVE"

View File

@ -22,6 +22,7 @@ SQL_FILES=(
"services/cron-service/deploy/mysql/initdb/001_cron_service.sql"
"services/game-service/deploy/mysql/initdb/001_game_service.sql"
"services/notice-service/deploy/mysql/initdb/001_notice_service.sql"
"services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql"
"deploy/mysql/initdb/999_local_grants.sql"
)

View File

@ -100,6 +100,9 @@ print_row "activity-service" "gRPC" "grpc" "activity-service" "13006" "13006"
print_row "cron-service" "gRPC" "grpc" "cron-service" "13007" "13007"
print_row "game-service" "gRPC" "grpc" "game-service" "13008" "13008"
print_row "notice-service" "gRPC" "grpc" "notice-service" "13009" "13009"
print_row "statistics-service" "Health" "http" "statistics-service" "13110" "13110" "/healthz/ready"
print_row "rocketmq-namesrv" "NameServer" "tcp" "rocketmq-namesrv" "9876" "9876"
print_row "rocketmq-broker" "Broker" "tcp" "rocketmq-broker" "10911" "10911"
print_row "mysql" "MySQL" "tcp" "mysql" "3306" "23306"
print_row "redis" "Redis" "tcp" "redis" "6379" "13379"

View File

@ -34,6 +34,7 @@ import (
countryregionmodule "hyapp-admin-server/internal/modules/countryregion"
dailytaskmodule "hyapp-admin-server/internal/modules/dailytask"
dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
gamemanagementmodule "hyapp-admin-server/internal/modules/gamemanagement"
healthmodule "hyapp-admin-server/internal/modules/health"
hostorgmodule "hyapp-admin-server/internal/modules/hostorg"
@ -43,6 +44,7 @@ import (
menumodule "hyapp-admin-server/internal/modules/menu"
paymentmodule "hyapp-admin-server/internal/modules/payment"
rbacmodule "hyapp-admin-server/internal/modules/rbac"
redpacketmodule "hyapp-admin-server/internal/modules/redpacket"
regionblockmodule "hyapp-admin-server/internal/modules/regionblock"
registrationrewardmodule "hyapp-admin-server/internal/modules/registrationreward"
resourcemodule "hyapp-admin-server/internal/modules/resource"
@ -51,6 +53,8 @@ import (
searchmodule "hyapp-admin-server/internal/modules/search"
sevendaycheckinmodule "hyapp-admin-server/internal/modules/sevendaycheckin"
uploadmodule "hyapp-admin-server/internal/modules/upload"
userleaderboardmodule "hyapp-admin-server/internal/modules/userleaderboard"
vipconfigmodule "hyapp-admin-server/internal/modules/vipconfig"
"hyapp-admin-server/internal/platform/logging"
"hyapp-admin-server/internal/platform/tencentcos"
"hyapp-admin-server/internal/repository"
@ -132,15 +136,6 @@ func main() {
}
defer walletDB.Close()
roomDB, err := sql.Open("mysql", cfg.RoomMySQLDSN)
if err != nil {
fatalRuntime("connect_room_mysql_failed", err)
}
if err := roomDB.PingContext(context.Background()); err != nil {
fatalRuntime("ping_room_mysql_failed", err)
}
defer roomDB.Close()
store := repository.New(db)
if cfg.MySQLAutoMigrate {
if err := store.AutoMigrate(); err != nil {
@ -170,6 +165,7 @@ func main() {
fatalRuntime("connect_room_service_failed", err)
}
defer roomConn.Close()
roomClient := roomclient.NewGRPC(roomConn)
activityConn, err := grpc.Dial(cfg.ActivityService.Addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
@ -209,34 +205,38 @@ func main() {
objectUploader = uploader
}
handlers := router.Handlers{
Audit: auditHandler,
Auth: authmodule.New(store, auth, auditHandler, cfg),
AdminUser: adminusermodule.New(store, cfg, auditHandler),
AchievementConfig: achievementconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
AppConfig: appconfigmodule.New(store, auditHandler),
AppRegistry: appregistrymodule.New(userDB),
AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler),
CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler),
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Dashboard: dashboardmodule.New(store),
Game: gamemanagementmodule.New(gameclient.NewGRPC(gameConn), auditHandler),
Health: healthmodule.New(store, redisClient, jobStatus),
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
Job: jobmodule.New(store, cfg, auditHandler),
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Menu: menumodule.New(store, auditHandler),
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
RBAC: rbacmodule.New(store, auditHandler),
RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
RegionBlock: regionblockmodule.New(userDB, auditHandler),
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, auditHandler),
RoomAdmin: roomadminmodule.New(roomDB, userDB, roomclient.NewGRPC(roomConn), auditHandler),
RoomTreasure: roomtreasuremodule.New(roomDB, auditHandler),
Search: searchmodule.New(store),
SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
Audit: auditHandler,
Auth: authmodule.New(store, auth, auditHandler, cfg),
AdminUser: adminusermodule.New(store, cfg, auditHandler),
AchievementConfig: achievementconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
AppConfig: appconfigmodule.New(store, auditHandler),
AppRegistry: appregistrymodule.New(userDB),
AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler),
CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler),
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Dashboard: dashboardmodule.New(store, cfg),
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
Game: gamemanagementmodule.New(gameclient.NewGRPC(gameConn), auditHandler),
Health: healthmodule.New(store, redisClient, jobStatus),
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
Job: jobmodule.New(store, cfg, auditHandler),
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Menu: menumodule.New(store, auditHandler),
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
RBAC: rbacmodule.New(store, auditHandler),
RedPacket: redpacketmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
RegionBlock: regionblockmodule.New(userDB, auditHandler),
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, auditHandler),
RoomAdmin: roomadminmodule.New(userDB, roomClient, auditHandler),
RoomTreasure: roomtreasuremodule.New(roomClient, auditHandler),
Search: searchmodule.New(store),
SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomClient),
VIPConfig: vipconfigmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
}
engine := router.New(cfg, auth, handlers)

View File

@ -11,7 +11,6 @@ http_addr: "127.0.0.1:13100"
mysql_dsn: "admin_user:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC"
user_mysql_dsn: "user_reader:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC"
wallet_mysql_dsn: "wallet_reader:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC"
room_mysql_dsn: "room_reader:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC"
mysql_auto_migrate: false
migrations:
enabled: true
@ -49,6 +48,9 @@ activity_service:
game_service:
addr: "10.2.1.16:13008"
request_timeout: "3s"
statistics_service:
base_url: "http://10.2.1.16:13010"
request_timeout: "3s"
tencent-cos:
enabled: true
secret-id: "REPLACE_ME"

View File

@ -11,7 +11,6 @@ http_addr: ":13100"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC"
user_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC"
wallet_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC"
room_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC"
mysql_auto_migrate: true
migrations:
enabled: true
@ -49,6 +48,9 @@ activity_service:
game_service:
addr: "127.0.0.1:13008"
request_timeout: "3s"
statistics_service:
base_url: "http://127.0.0.1:13010"
request_timeout: "3s"
tencent-cos:
enabled: true
secret-id: "IKIDMchhZEfrsiNo472DAtTpzzmLjttkOnyu"

View File

@ -13,32 +13,32 @@ import (
)
type Config struct {
ServiceName string `yaml:"service_name"`
NodeID string `yaml:"node_id"`
Environment string `yaml:"environment"`
Log logging.Config `yaml:"log"`
HTTPAddr string `yaml:"http_addr"`
MySQLDSN string `yaml:"mysql_dsn"`
UserMySQLDSN string `yaml:"user_mysql_dsn"`
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
RoomMySQLDSN string `yaml:"room_mysql_dsn"`
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Migrations MigrationConfig `yaml:"migrations"`
JWTSecret string `yaml:"jwt_secret"`
AccessTokenTTL time.Duration `yaml:"access_token_ttl"`
RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl"`
CORSAllowedOrigins []string `yaml:"cors_allowed_origins"`
RefreshCookieSecure bool `yaml:"refresh_cookie_secure"`
Bootstrap BootstrapConfig `yaml:"bootstrap"`
BootstrapPassword string `yaml:"bootstrap_password"`
UserService UserServiceConfig `yaml:"user_service"`
TencentCOS TencentCOSConfig `yaml:"tencent-cos"`
Redis RedisConfig `yaml:"redis"`
Jobs JobsConfig `yaml:"jobs"`
WalletService WalletServiceConfig `yaml:"wallet_service"`
RoomService RoomServiceConfig `yaml:"room_service"`
ActivityService ActivityServiceConfig `yaml:"activity_service"`
GameService GameServiceConfig `yaml:"game_service"`
ServiceName string `yaml:"service_name"`
NodeID string `yaml:"node_id"`
Environment string `yaml:"environment"`
Log logging.Config `yaml:"log"`
HTTPAddr string `yaml:"http_addr"`
MySQLDSN string `yaml:"mysql_dsn"`
UserMySQLDSN string `yaml:"user_mysql_dsn"`
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Migrations MigrationConfig `yaml:"migrations"`
JWTSecret string `yaml:"jwt_secret"`
AccessTokenTTL time.Duration `yaml:"access_token_ttl"`
RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl"`
CORSAllowedOrigins []string `yaml:"cors_allowed_origins"`
RefreshCookieSecure bool `yaml:"refresh_cookie_secure"`
Bootstrap BootstrapConfig `yaml:"bootstrap"`
BootstrapPassword string `yaml:"bootstrap_password"`
UserService UserServiceConfig `yaml:"user_service"`
TencentCOS TencentCOSConfig `yaml:"tencent-cos"`
Redis RedisConfig `yaml:"redis"`
Jobs JobsConfig `yaml:"jobs"`
WalletService WalletServiceConfig `yaml:"wallet_service"`
RoomService RoomServiceConfig `yaml:"room_service"`
ActivityService ActivityServiceConfig `yaml:"activity_service"`
GameService GameServiceConfig `yaml:"game_service"`
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
}
type MigrationConfig struct {
@ -72,6 +72,11 @@ type GameServiceConfig struct {
RequestTimeout time.Duration `yaml:"request_timeout"`
}
type StatisticsServiceConfig struct {
BaseURL string `yaml:"base_url"`
RequestTimeout time.Duration `yaml:"request_timeout"`
}
type TencentCOSConfig struct {
Enabled bool `yaml:"enabled"`
SecretID string `yaml:"secret-id"`
@ -117,7 +122,6 @@ func Default() Config {
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC",
UserMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC",
WalletMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC",
RoomMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC",
MySQLAutoMigrate: true,
Migrations: MigrationConfig{
Enabled: true,
@ -161,6 +165,10 @@ func Default() Config {
Addr: "127.0.0.1:13008",
RequestTimeout: 3 * time.Second,
},
StatisticsService: StatisticsServiceConfig{
BaseURL: "http://127.0.0.1:13010",
RequestTimeout: 3 * time.Second,
},
TencentCOS: TencentCOSConfig{
Enabled: false,
ObjectPrefix: "admin",
@ -269,6 +277,13 @@ func (cfg *Config) Normalize() {
if cfg.GameService.RequestTimeout <= 0 {
cfg.GameService.RequestTimeout = 3 * time.Second
}
cfg.StatisticsService.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.StatisticsService.BaseURL), "/")
if cfg.StatisticsService.BaseURL == "" {
cfg.StatisticsService.BaseURL = "http://127.0.0.1:13010"
}
if cfg.StatisticsService.RequestTimeout <= 0 {
cfg.StatisticsService.RequestTimeout = 3 * time.Second
}
cfg.TencentCOS.SecretID = strings.TrimSpace(cfg.TencentCOS.SecretID)
cfg.TencentCOS.SecretKey = strings.TrimSpace(cfg.TencentCOS.SecretKey)
cfg.TencentCOS.BucketName = strings.TrimSpace(cfg.TencentCOS.BucketName)
@ -302,9 +317,6 @@ func (cfg Config) Validate() error {
if strings.TrimSpace(cfg.WalletMySQLDSN) == "" {
return errors.New("wallet_mysql_dsn is required")
}
if strings.TrimSpace(cfg.RoomMySQLDSN) == "" {
return errors.New("room_mysql_dsn is required")
}
if cfg.Migrations.Enabled && strings.TrimSpace(cfg.Migrations.Dir) == "" {
return errors.New("migrations.dir is required when migrations are enabled")
}
@ -367,6 +379,12 @@ func (cfg Config) Validate() error {
if cfg.GameService.RequestTimeout <= 0 {
return errors.New("game_service.request_timeout must be greater than 0")
}
if strings.TrimSpace(cfg.StatisticsService.BaseURL) == "" {
return errors.New("statistics_service.base_url is required")
}
if cfg.StatisticsService.RequestTimeout <= 0 {
return errors.New("statistics_service.request_timeout must be greater than 0")
}
if cfg.TencentCOS.Enabled {
if cfg.TencentCOS.SecretID == "" || cfg.TencentCOS.SecretKey == "" || cfg.TencentCOS.BucketName == "" || cfg.TencentCOS.Region == "" || cfg.TencentCOS.AccessURL == "" {
return errors.New("tencent-cos config is incomplete")

View File

@ -18,12 +18,17 @@ type Client interface {
GetRegistrationRewardConfig(ctx context.Context, req *activityv1.GetRegistrationRewardConfigRequest) (*activityv1.GetRegistrationRewardConfigResponse, error)
UpdateRegistrationRewardConfig(ctx context.Context, req *activityv1.UpdateRegistrationRewardConfigRequest) (*activityv1.UpdateRegistrationRewardConfigResponse, error)
ListRegistrationRewardClaims(ctx context.Context, req *activityv1.ListRegistrationRewardClaimsRequest) (*activityv1.ListRegistrationRewardClaimsResponse, error)
GetFirstRechargeRewardConfig(ctx context.Context, req *activityv1.GetFirstRechargeRewardConfigRequest) (*activityv1.GetFirstRechargeRewardConfigResponse, error)
UpdateFirstRechargeRewardConfig(ctx context.Context, req *activityv1.UpdateFirstRechargeRewardConfigRequest) (*activityv1.UpdateFirstRechargeRewardConfigResponse, error)
ListFirstRechargeRewardClaims(ctx context.Context, req *activityv1.ListFirstRechargeRewardClaimsRequest) (*activityv1.ListFirstRechargeRewardClaimsResponse, error)
GetSevenDayCheckInConfig(ctx context.Context, req *activityv1.GetSevenDayCheckInConfigRequest) (*activityv1.GetSevenDayCheckInConfigResponse, error)
UpdateSevenDayCheckInConfig(ctx context.Context, req *activityv1.UpdateSevenDayCheckInConfigRequest) (*activityv1.UpdateSevenDayCheckInConfigResponse, error)
ListSevenDayCheckInClaims(ctx context.Context, req *activityv1.ListSevenDayCheckInClaimsRequest) (*activityv1.ListSevenDayCheckInClaimsResponse, error)
GetLuckyGiftConfig(ctx context.Context, req *activityv1.GetLuckyGiftConfigRequest) (*activityv1.GetLuckyGiftConfigResponse, error)
UpsertLuckyGiftConfig(ctx context.Context, req *activityv1.UpsertLuckyGiftConfigRequest) (*activityv1.UpsertLuckyGiftConfigResponse, error)
ListLuckyGiftConfigs(ctx context.Context, req *activityv1.ListLuckyGiftConfigsRequest) (*activityv1.ListLuckyGiftConfigsResponse, error)
ListLuckyGiftDraws(ctx context.Context, req *activityv1.ListLuckyGiftDrawsRequest) (*activityv1.ListLuckyGiftDrawsResponse, error)
GetLuckyGiftDrawSummary(ctx context.Context, req *activityv1.GetLuckyGiftDrawSummaryRequest) (*activityv1.GetLuckyGiftDrawSummaryResponse, error)
RemoveRegionBroadcastMember(ctx context.Context, req *activityv1.RemoveRegionBroadcastMemberRequest) (*activityv1.RemoveRegionBroadcastMemberResponse, error)
ListLevelConfig(ctx context.Context, req *activityv1.ListLevelConfigRequest) (*activityv1.ListLevelConfigResponse, error)
UpsertLevelTrack(ctx context.Context, req *activityv1.UpsertLevelTrackRequest) (*activityv1.UpsertLevelTrackResponse, error)
@ -32,24 +37,26 @@ type Client interface {
}
type GRPCClient struct {
taskClient activityv1.AdminTaskServiceClient
achievementClient activityv1.AdminAchievementServiceClient
registrationRewardClient activityv1.AdminRegistrationRewardServiceClient
sevenDayCheckInClient activityv1.AdminSevenDayCheckInServiceClient
luckyGiftClient activityv1.AdminLuckyGiftServiceClient
broadcastClient activityv1.BroadcastServiceClient
growthClient activityv1.AdminGrowthLevelServiceClient
taskClient activityv1.AdminTaskServiceClient
achievementClient activityv1.AdminAchievementServiceClient
registrationRewardClient activityv1.AdminRegistrationRewardServiceClient
firstRechargeRewardClient activityv1.AdminFirstRechargeRewardServiceClient
sevenDayCheckInClient activityv1.AdminSevenDayCheckInServiceClient
luckyGiftClient activityv1.AdminLuckyGiftServiceClient
broadcastClient activityv1.BroadcastServiceClient
growthClient activityv1.AdminGrowthLevelServiceClient
}
func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
return &GRPCClient{
taskClient: activityv1.NewAdminTaskServiceClient(conn),
achievementClient: activityv1.NewAdminAchievementServiceClient(conn),
registrationRewardClient: activityv1.NewAdminRegistrationRewardServiceClient(conn),
sevenDayCheckInClient: activityv1.NewAdminSevenDayCheckInServiceClient(conn),
luckyGiftClient: activityv1.NewAdminLuckyGiftServiceClient(conn),
broadcastClient: activityv1.NewBroadcastServiceClient(conn),
growthClient: activityv1.NewAdminGrowthLevelServiceClient(conn),
taskClient: activityv1.NewAdminTaskServiceClient(conn),
achievementClient: activityv1.NewAdminAchievementServiceClient(conn),
registrationRewardClient: activityv1.NewAdminRegistrationRewardServiceClient(conn),
firstRechargeRewardClient: activityv1.NewAdminFirstRechargeRewardServiceClient(conn),
sevenDayCheckInClient: activityv1.NewAdminSevenDayCheckInServiceClient(conn),
luckyGiftClient: activityv1.NewAdminLuckyGiftServiceClient(conn),
broadcastClient: activityv1.NewBroadcastServiceClient(conn),
growthClient: activityv1.NewAdminGrowthLevelServiceClient(conn),
}
}
@ -85,6 +92,18 @@ func (c *GRPCClient) ListRegistrationRewardClaims(ctx context.Context, req *acti
return c.registrationRewardClient.ListRegistrationRewardClaims(ctx, req)
}
func (c *GRPCClient) GetFirstRechargeRewardConfig(ctx context.Context, req *activityv1.GetFirstRechargeRewardConfigRequest) (*activityv1.GetFirstRechargeRewardConfigResponse, error) {
return c.firstRechargeRewardClient.GetFirstRechargeRewardConfig(ctx, req)
}
func (c *GRPCClient) UpdateFirstRechargeRewardConfig(ctx context.Context, req *activityv1.UpdateFirstRechargeRewardConfigRequest) (*activityv1.UpdateFirstRechargeRewardConfigResponse, error) {
return c.firstRechargeRewardClient.UpdateFirstRechargeRewardConfig(ctx, req)
}
func (c *GRPCClient) ListFirstRechargeRewardClaims(ctx context.Context, req *activityv1.ListFirstRechargeRewardClaimsRequest) (*activityv1.ListFirstRechargeRewardClaimsResponse, error) {
return c.firstRechargeRewardClient.ListFirstRechargeRewardClaims(ctx, req)
}
func (c *GRPCClient) GetSevenDayCheckInConfig(ctx context.Context, req *activityv1.GetSevenDayCheckInConfigRequest) (*activityv1.GetSevenDayCheckInConfigResponse, error) {
return c.sevenDayCheckInClient.GetSevenDayCheckInConfig(ctx, req)
}
@ -105,10 +124,18 @@ func (c *GRPCClient) UpsertLuckyGiftConfig(ctx context.Context, req *activityv1.
return c.luckyGiftClient.UpsertLuckyGiftConfig(ctx, req)
}
func (c *GRPCClient) ListLuckyGiftConfigs(ctx context.Context, req *activityv1.ListLuckyGiftConfigsRequest) (*activityv1.ListLuckyGiftConfigsResponse, error) {
return c.luckyGiftClient.ListLuckyGiftConfigs(ctx, req)
}
func (c *GRPCClient) ListLuckyGiftDraws(ctx context.Context, req *activityv1.ListLuckyGiftDrawsRequest) (*activityv1.ListLuckyGiftDrawsResponse, error) {
return c.luckyGiftClient.ListLuckyGiftDraws(ctx, req)
}
func (c *GRPCClient) GetLuckyGiftDrawSummary(ctx context.Context, req *activityv1.GetLuckyGiftDrawSummaryRequest) (*activityv1.GetLuckyGiftDrawSummaryResponse, error) {
return c.luckyGiftClient.GetLuckyGiftDrawSummary(ctx, req)
}
func (c *GRPCClient) RemoveRegionBroadcastMember(ctx context.Context, req *activityv1.RemoveRegionBroadcastMemberRequest) (*activityv1.RemoveRegionBroadcastMemberResponse, error) {
return c.broadcastClient.RemoveRegionBroadcastMember(ctx, req)
}

View File

@ -15,6 +15,7 @@ type Client interface {
ListCatalog(ctx context.Context, req *gamev1.ListCatalogRequest) (*gamev1.ListCatalogResponse, error)
UpsertCatalog(ctx context.Context, req *gamev1.UpsertCatalogRequest) (*gamev1.CatalogResponse, error)
SetGameStatus(ctx context.Context, req *gamev1.SetGameStatusRequest) (*gamev1.CatalogResponse, error)
DeleteCatalog(ctx context.Context, req *gamev1.DeleteCatalogRequest) (*gamev1.DeleteCatalogResponse, error)
}
type GRPCClient struct {
@ -44,3 +45,7 @@ func (c *GRPCClient) UpsertCatalog(ctx context.Context, req *gamev1.UpsertCatalo
func (c *GRPCClient) SetGameStatus(ctx context.Context, req *gamev1.SetGameStatusRequest) (*gamev1.CatalogResponse, error) {
return c.client.SetGameStatus(ctx, req)
}
func (c *GRPCClient) DeleteCatalog(ctx context.Context, req *gamev1.DeleteCatalogRequest) (*gamev1.DeleteCatalogResponse, error) {
return c.client.DeleteCatalog(ctx, req)
}

View File

@ -13,10 +13,200 @@ import (
)
type Client interface {
ListRooms(ctx context.Context, req ListRoomsRequest) (*ListRoomsResult, error)
GetRoom(ctx context.Context, req GetRoomRequest) (*Room, error)
UpdateRoom(ctx context.Context, req UpdateRoomRequest) (*CloseRoomResult, error)
DeleteRoom(ctx context.Context, req DeleteRoomRequest) (*CloseRoomResult, error)
GetRoomTreasureConfig(ctx context.Context) (RoomTreasureConfig, error)
UpdateRoomTreasureConfig(ctx context.Context, req UpdateRoomTreasureConfigRequest) (RoomTreasureConfig, error)
GetRoomSeatConfig(ctx context.Context) (RoomSeatConfig, error)
UpdateRoomSeatConfig(ctx context.Context, req UpdateRoomSeatConfigRequest) (RoomSeatConfig, error)
ListRoomPins(ctx context.Context, req ListRoomPinsRequest) (ListRoomPinsResult, error)
CreateRoomPin(ctx context.Context, req CreateRoomPinRequest) (RoomPin, error)
CancelRoomPin(ctx context.Context, req CancelRoomPinRequest) (RoomPin, error)
CloseRoom(ctx context.Context, req CloseRoomRequest) (*CloseRoomResult, error)
ReopenRoom(ctx context.Context, req ReopenRoomRequest) (*CloseRoomResult, error)
}
type ListRoomsRequest struct {
Page int
PageSize int
Keyword string
Status string
RegionID int64
SortBy string
SortDirection string
}
type ListRoomsResult struct {
Rooms []Room
Total int64
ServerTimeMs int64
}
type GetRoomRequest struct {
RoomID string
}
type UpdateRoomRequest struct {
RequestID string
RoomID string
ActorUserID int64
Title *string
CoverURL *string
Description *string
Mode *string
Status *string
VisibleRegionID *int64
CloseReason string
AdminID uint64
AdminName string
}
type DeleteRoomRequest struct {
RequestID string
RoomID string
ActorUserID int64
AdminID uint64
AdminName string
}
type Room struct {
RoomID string
RoomShortID string
VisibleRegionID int64
OwnerUserID int64
Title string
CoverURL string
Mode string
Status string
CloseReason string
ClosedByAdminID uint64
ClosedByAdminName string
ClosedAtMS int64
Heat int64
OnlineCount int32
SeatCount int32
OccupiedSeatCount int32
SortScore int64
CreatedAtMS int64
UpdatedAtMS int64
}
type RoomTreasureConfig struct {
AppCode string
Enabled bool
ConfigVersion int64
EnergySource string
OpenDelayMS int64
BroadcastEnabled bool
BroadcastScope string
BroadcastDelayMS int64
RewardStackPolicy string
Levels []RoomTreasureLevelConfig
GiftEnergyRules []GiftEnergyRuleConfig
UpdatedByAdminID int64
CreatedAtMS int64
UpdatedAtMS int64
}
type RoomTreasureLevelConfig struct {
Level int32
EnergyThreshold int64
CoverURL string
AnimationURL string
OpeningAnimationURL string
OpenedImageURL string
InRoomRewards []RoomTreasureRewardItem
Top1Rewards []RoomTreasureRewardItem
IgniterRewards []RoomTreasureRewardItem
}
type RoomTreasureRewardItem struct {
RewardItemID string
ResourceGroupID int64
Weight int64
DisplayName string
IconURL string
}
type GiftEnergyRuleConfig struct {
RuleID string
GiftID string
GiftTypeCode string
MultiplierPPM int64
FixedEnergy int64
Excluded bool
}
type UpdateRoomTreasureConfigRequest struct {
Config RoomTreasureConfig
AdminID int64
}
type RoomSeatConfig struct {
CandidateSeatCounts []int32
AllowedSeatCounts []int32
DefaultSeatCount int32
UpdatedAtMS int64
}
type UpdateRoomSeatConfigRequest struct {
AllowedSeatCounts []int32
DefaultSeatCount int32
}
type RoomPinRoom struct {
RoomID string
RoomShortID string
VisibleRegionID int64
OwnerUserID int64
Title string
CoverURL string
Status string
}
type RoomPin struct {
ID int64
VisibleRegionID int64
RoomID string
Weight int64
Status string
PinnedAtMS int64
ExpiresAtMS int64
CancelledAtMS int64
CreatedByAdminID uint64
CancelledByAdminID uint64
CreatedAtMS int64
UpdatedAtMS int64
Room RoomPinRoom
}
type ListRoomPinsRequest struct {
Page int
PageSize int
Keyword string
Status string
RegionID int64
}
type ListRoomPinsResult struct {
Pins []RoomPin
Total int64
}
type CreateRoomPinRequest struct {
RoomID string
Weight int64
DurationDays int64
AdminID uint64
}
type CancelRoomPinRequest struct {
PinID int64
AdminID uint64
}
type CloseRoomRequest struct {
RequestID string
RoomID string
@ -39,11 +229,163 @@ type ReopenRoomRequest struct {
}
type GRPCClient struct {
client roomv1.RoomCommandServiceClient
client roomv1.RoomCommandServiceClient
queryClient roomv1.RoomQueryServiceClient
}
func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
return &GRPCClient{client: roomv1.NewRoomCommandServiceClient(conn)}
return &GRPCClient{client: roomv1.NewRoomCommandServiceClient(conn), queryClient: roomv1.NewRoomQueryServiceClient(conn)}
}
func (c *GRPCClient) ListRooms(ctx context.Context, req ListRoomsRequest) (*ListRoomsResult, error) {
resp, err := c.queryClient.AdminListRooms(ctx, &roomv1.AdminListRoomsRequest{
Meta: requestMeta(ctx, "", 0, "admin-list-rooms"),
Page: int32(req.Page),
PageSize: int32(req.PageSize),
Keyword: strings.TrimSpace(req.Keyword),
Status: strings.TrimSpace(req.Status),
VisibleRegionId: req.RegionID,
SortBy: strings.TrimSpace(req.SortBy),
SortDirection: strings.TrimSpace(req.SortDirection),
})
if err != nil {
return nil, err
}
rooms := make([]Room, 0, len(resp.GetRooms()))
for _, item := range resp.GetRooms() {
rooms = append(rooms, roomFromProto(item))
}
return &ListRoomsResult{Rooms: rooms, Total: resp.GetTotal(), ServerTimeMs: resp.GetServerTimeMs()}, nil
}
func (c *GRPCClient) GetRoom(ctx context.Context, req GetRoomRequest) (*Room, error) {
resp, err := c.queryClient.AdminGetRoom(ctx, &roomv1.AdminGetRoomRequest{
Meta: requestMeta(ctx, strings.TrimSpace(req.RoomID), 0, "admin-get-room"),
RoomId: strings.TrimSpace(req.RoomID),
})
if err != nil {
return nil, err
}
room := roomFromProto(resp.GetRoom())
return &room, nil
}
func (c *GRPCClient) UpdateRoom(ctx context.Context, req UpdateRoomRequest) (*CloseRoomResult, error) {
resp, err := c.client.AdminUpdateRoom(ctx, &roomv1.AdminUpdateRoomRequest{
Meta: requestMeta(ctx, req.RoomID, req.ActorUserID, firstNonEmpty(req.RequestID, "admin-update-room")),
Title: req.Title,
CoverUrl: req.CoverURL,
Description: req.Description,
Mode: req.Mode,
Status: req.Status,
VisibleRegionId: req.VisibleRegionID,
CloseReason: strings.TrimSpace(req.CloseReason),
AdminId: req.AdminID,
AdminName: strings.TrimSpace(req.AdminName),
})
if err != nil {
return nil, err
}
return closeRoomResultFromCommand(resp.GetResult(), resp.GetRoom()), nil
}
func (c *GRPCClient) DeleteRoom(ctx context.Context, req DeleteRoomRequest) (*CloseRoomResult, error) {
resp, err := c.client.AdminDeleteRoom(ctx, &roomv1.AdminDeleteRoomRequest{
Meta: requestMeta(ctx, req.RoomID, req.ActorUserID, firstNonEmpty(req.RequestID, "admin-delete-room")),
AdminId: req.AdminID,
AdminName: strings.TrimSpace(req.AdminName),
})
if err != nil {
return nil, err
}
return closeRoomResultFromCommand(resp.GetResult(), resp.GetRoom()), nil
}
func (c *GRPCClient) GetRoomTreasureConfig(ctx context.Context) (RoomTreasureConfig, error) {
resp, err := c.queryClient.AdminGetRoomTreasureConfig(ctx, &roomv1.AdminGetRoomTreasureConfigRequest{
Meta: requestMeta(ctx, "", 0, "admin-get-room-treasure-config"),
})
if err != nil {
return RoomTreasureConfig{}, err
}
return roomTreasureConfigFromProto(resp.GetConfig()), nil
}
func (c *GRPCClient) UpdateRoomTreasureConfig(ctx context.Context, req UpdateRoomTreasureConfigRequest) (RoomTreasureConfig, error) {
resp, err := c.client.AdminUpdateRoomTreasureConfig(ctx, &roomv1.AdminUpdateRoomTreasureConfigRequest{
Meta: requestMeta(ctx, "", req.AdminID, "admin-update-room-treasure-config"),
Config: roomTreasureConfigToProto(req.Config),
AdminId: req.AdminID,
})
if err != nil {
return RoomTreasureConfig{}, err
}
return roomTreasureConfigFromProto(resp.GetConfig()), nil
}
func (c *GRPCClient) GetRoomSeatConfig(ctx context.Context) (RoomSeatConfig, error) {
resp, err := c.queryClient.AdminGetRoomSeatConfig(ctx, &roomv1.AdminGetRoomSeatConfigRequest{Meta: requestMeta(ctx, "", 0, "admin-get-room-seat-config")})
if err != nil {
return RoomSeatConfig{}, err
}
return roomSeatConfigFromProto(resp.GetConfig()), nil
}
func (c *GRPCClient) UpdateRoomSeatConfig(ctx context.Context, req UpdateRoomSeatConfigRequest) (RoomSeatConfig, error) {
resp, err := c.client.AdminUpdateRoomSeatConfig(ctx, &roomv1.AdminUpdateRoomSeatConfigRequest{
Meta: requestMeta(ctx, "", 0, "admin-update-room-seat-config"),
AllowedSeatCounts: req.AllowedSeatCounts,
DefaultSeatCount: req.DefaultSeatCount,
})
if err != nil {
return RoomSeatConfig{}, err
}
return roomSeatConfigFromProto(resp.GetConfig()), nil
}
func (c *GRPCClient) ListRoomPins(ctx context.Context, req ListRoomPinsRequest) (ListRoomPinsResult, error) {
resp, err := c.queryClient.AdminListRoomPins(ctx, &roomv1.AdminListRoomPinsRequest{
Meta: requestMeta(ctx, "", 0, "admin-list-room-pins"),
Page: int32(req.Page),
PageSize: int32(req.PageSize),
Keyword: strings.TrimSpace(req.Keyword),
Status: strings.TrimSpace(req.Status),
VisibleRegionId: req.RegionID,
})
if err != nil {
return ListRoomPinsResult{}, err
}
pins := make([]RoomPin, 0, len(resp.GetPins()))
for _, pin := range resp.GetPins() {
pins = append(pins, roomPinFromProto(pin))
}
return ListRoomPinsResult{Pins: pins, Total: resp.GetTotal()}, nil
}
func (c *GRPCClient) CreateRoomPin(ctx context.Context, req CreateRoomPinRequest) (RoomPin, error) {
resp, err := c.client.AdminCreateRoomPin(ctx, &roomv1.AdminCreateRoomPinRequest{
Meta: requestMeta(ctx, strings.TrimSpace(req.RoomID), int64(req.AdminID), "admin-create-room-pin"),
RoomId: strings.TrimSpace(req.RoomID),
Weight: req.Weight,
DurationDays: req.DurationDays,
AdminId: req.AdminID,
})
if err != nil {
return RoomPin{}, err
}
return roomPinFromProto(resp.GetPin()), nil
}
func (c *GRPCClient) CancelRoomPin(ctx context.Context, req CancelRoomPinRequest) (RoomPin, error) {
resp, err := c.client.AdminCancelRoomPin(ctx, &roomv1.AdminCancelRoomPinRequest{
Meta: requestMeta(ctx, "", int64(req.AdminID), "admin-cancel-room-pin"),
PinId: req.PinID,
AdminId: req.AdminID,
})
if err != nil {
return RoomPin{}, err
}
return roomPinFromProto(resp.GetPin()), nil
}
func (c *GRPCClient) CloseRoom(ctx context.Context, req CloseRoomRequest) (*CloseRoomResult, error) {
@ -68,28 +410,23 @@ func (c *GRPCClient) setRoomLifecycle(ctx context.Context, rawRequestID string,
requestID = fmt.Sprintf("admin-room-lifecycle-%s-%d", roomID, time.Now().UnixMilli())
}
resp, err := c.client.CloseRoom(ctx, &roomv1.CloseRoomRequest{
Meta: &roomv1.RequestMeta{
RequestId: requestID,
CommandId: commandID(requestID, reason, roomID),
ActorUserId: actorUserID,
RoomId: roomID,
AppCode: appctx.FromContext(ctx),
SentAtMs: time.Now().UnixMilli(),
},
Meta: requestMeta(ctx, roomID, actorUserID, requestID+":"+reason),
Reason: reason,
})
if err != nil {
return nil, err
}
result := resp.GetResult()
room := resp.GetRoom()
return closeRoomResultFromCommand(resp.GetResult(), resp.GetRoom()), nil
}
func closeRoomResultFromCommand(result *roomv1.CommandResult, room *roomv1.RoomSnapshot) *CloseRoomResult {
return &CloseRoomResult{
Applied: result.GetApplied(),
RoomVersion: result.GetRoomVersion(),
ServerTimeMs: result.GetServerTimeMs(),
RoomStatus: room.GetStatus(),
OnlineUserNum: len(room.GetOnlineUsers()),
}, nil
}
}
func commandID(requestID string, reason string, roomID string) string {
@ -104,6 +441,229 @@ func commandID(requestID string, reason string, roomID string) string {
return value[:128]
}
func requestMeta(ctx context.Context, roomID string, actorUserID int64, seed string) *roomv1.RequestMeta {
requestID := strings.TrimSpace(seed)
if requestID == "" {
requestID = fmt.Sprintf("admin-room-%d", time.Now().UnixMilli())
}
return &roomv1.RequestMeta{
RequestId: requestID,
CommandId: commandID(requestID, "admin", roomID),
ActorUserId: actorUserID,
RoomId: strings.TrimSpace(roomID),
AppCode: appctx.FromContext(ctx),
SentAtMs: time.Now().UnixMilli(),
}
}
func roomFromProto(item *roomv1.AdminRoomListItem) Room {
if item == nil {
return Room{}
}
return Room{
RoomID: item.GetRoomId(),
RoomShortID: item.GetRoomShortId(),
VisibleRegionID: item.GetVisibleRegionId(),
OwnerUserID: item.GetOwnerUserId(),
Title: item.GetTitle(),
CoverURL: item.GetCoverUrl(),
Mode: item.GetMode(),
Status: item.GetStatus(),
CloseReason: item.GetCloseReason(),
ClosedByAdminID: item.GetClosedByAdminId(),
ClosedByAdminName: item.GetClosedByAdminName(),
ClosedAtMS: item.GetClosedAtMs(),
Heat: item.GetHeat(),
OnlineCount: item.GetOnlineCount(),
SeatCount: item.GetSeatCount(),
OccupiedSeatCount: item.GetOccupiedSeatCount(),
SortScore: item.GetSortScore(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func roomTreasureConfigFromProto(input *roomv1.AdminRoomTreasureConfig) RoomTreasureConfig {
if input == nil {
return RoomTreasureConfig{}
}
return RoomTreasureConfig{
AppCode: input.GetAppCode(),
Enabled: input.GetEnabled(),
ConfigVersion: input.GetConfigVersion(),
EnergySource: input.GetEnergySource(),
OpenDelayMS: input.GetOpenDelayMs(),
BroadcastEnabled: input.GetBroadcastEnabled(),
BroadcastScope: input.GetBroadcastScope(),
BroadcastDelayMS: input.GetBroadcastDelayMs(),
RewardStackPolicy: input.GetRewardStackPolicy(),
Levels: roomTreasureLevelsFromProto(input.GetLevels()),
GiftEnergyRules: roomTreasureEnergyRulesFromProto(input.GetGiftEnergyRules()),
UpdatedByAdminID: input.GetUpdatedByAdminId(),
CreatedAtMS: input.GetCreatedAtMs(),
UpdatedAtMS: input.GetUpdatedAtMs(),
}
}
func roomSeatConfigFromProto(input *roomv1.AdminRoomSeatConfig) RoomSeatConfig {
if input == nil {
return RoomSeatConfig{}
}
return RoomSeatConfig{
CandidateSeatCounts: input.GetCandidateSeatCounts(),
AllowedSeatCounts: input.GetAllowedSeatCounts(),
DefaultSeatCount: input.GetDefaultSeatCount(),
UpdatedAtMS: input.GetUpdatedAtMs(),
}
}
func roomPinFromProto(input *roomv1.AdminRoomPin) RoomPin {
if input == nil {
return RoomPin{}
}
room := input.GetRoom()
return RoomPin{
ID: input.GetId(),
VisibleRegionID: input.GetVisibleRegionId(),
RoomID: input.GetRoomId(),
Weight: input.GetWeight(),
Status: input.GetStatus(),
PinnedAtMS: input.GetPinnedAtMs(),
ExpiresAtMS: input.GetExpiresAtMs(),
CancelledAtMS: input.GetCancelledAtMs(),
CreatedByAdminID: input.GetCreatedByAdminId(),
CancelledByAdminID: input.GetCancelledByAdminId(),
CreatedAtMS: input.GetCreatedAtMs(),
UpdatedAtMS: input.GetUpdatedAtMs(),
Room: RoomPinRoom{
RoomID: room.GetRoomId(),
RoomShortID: room.GetRoomShortId(),
VisibleRegionID: room.GetVisibleRegionId(),
OwnerUserID: room.GetOwnerUserId(),
Title: room.GetTitle(),
CoverURL: room.GetCoverUrl(),
Status: room.GetStatus(),
},
}
}
func roomTreasureConfigToProto(input RoomTreasureConfig) *roomv1.AdminRoomTreasureConfig {
return &roomv1.AdminRoomTreasureConfig{
Enabled: input.Enabled,
EnergySource: input.EnergySource,
OpenDelayMs: input.OpenDelayMS,
BroadcastEnabled: input.BroadcastEnabled,
BroadcastScope: input.BroadcastScope,
BroadcastDelayMs: input.BroadcastDelayMS,
RewardStackPolicy: input.RewardStackPolicy,
Levels: roomTreasureLevelsToProto(input.Levels),
GiftEnergyRules: roomTreasureEnergyRulesToProto(input.GiftEnergyRules),
}
}
func roomTreasureLevelsFromProto(input []*roomv1.RoomTreasureLevel) []RoomTreasureLevelConfig {
out := make([]RoomTreasureLevelConfig, 0, len(input))
for _, level := range input {
if level == nil {
continue
}
out = append(out, RoomTreasureLevelConfig{
Level: level.GetLevel(),
EnergyThreshold: level.GetEnergyThreshold(),
CoverURL: level.GetCoverUrl(),
AnimationURL: level.GetAnimationUrl(),
OpeningAnimationURL: level.GetOpeningAnimationUrl(),
OpenedImageURL: level.GetOpenedImageUrl(),
InRoomRewards: roomTreasureRewardsFromProto(level.GetInRoomRewards()),
Top1Rewards: roomTreasureRewardsFromProto(level.GetTop1Rewards()),
IgniterRewards: roomTreasureRewardsFromProto(level.GetIgniterRewards()),
})
}
return out
}
func roomTreasureLevelsToProto(input []RoomTreasureLevelConfig) []*roomv1.RoomTreasureLevel {
out := make([]*roomv1.RoomTreasureLevel, 0, len(input))
for _, level := range input {
out = append(out, &roomv1.RoomTreasureLevel{
Level: level.Level,
EnergyThreshold: level.EnergyThreshold,
CoverUrl: level.CoverURL,
AnimationUrl: level.AnimationURL,
OpeningAnimationUrl: level.OpeningAnimationURL,
OpenedImageUrl: level.OpenedImageURL,
InRoomRewards: roomTreasureRewardsToProto(level.InRoomRewards),
Top1Rewards: roomTreasureRewardsToProto(level.Top1Rewards),
IgniterRewards: roomTreasureRewardsToProto(level.IgniterRewards),
})
}
return out
}
func roomTreasureRewardsFromProto(input []*roomv1.RoomTreasureRewardItem) []RoomTreasureRewardItem {
out := make([]RoomTreasureRewardItem, 0, len(input))
for _, reward := range input {
if reward == nil {
continue
}
out = append(out, RoomTreasureRewardItem{
RewardItemID: reward.GetRewardItemId(),
ResourceGroupID: reward.GetResourceGroupId(),
Weight: reward.GetWeight(),
DisplayName: reward.GetDisplayName(),
IconURL: reward.GetIconUrl(),
})
}
return out
}
func roomTreasureRewardsToProto(input []RoomTreasureRewardItem) []*roomv1.RoomTreasureRewardItem {
out := make([]*roomv1.RoomTreasureRewardItem, 0, len(input))
for _, reward := range input {
out = append(out, &roomv1.RoomTreasureRewardItem{
RewardItemId: reward.RewardItemID,
ResourceGroupId: reward.ResourceGroupID,
Weight: reward.Weight,
DisplayName: reward.DisplayName,
IconUrl: reward.IconURL,
})
}
return out
}
func roomTreasureEnergyRulesFromProto(input []*roomv1.RoomTreasureGiftEnergyRule) []GiftEnergyRuleConfig {
out := make([]GiftEnergyRuleConfig, 0, len(input))
for _, rule := range input {
if rule == nil {
continue
}
out = append(out, GiftEnergyRuleConfig{
RuleID: rule.GetRuleId(),
GiftID: rule.GetGiftId(),
GiftTypeCode: rule.GetGiftTypeCode(),
MultiplierPPM: rule.GetMultiplierPpm(),
FixedEnergy: rule.GetFixedEnergy(),
Excluded: rule.GetExcluded(),
})
}
return out
}
func roomTreasureEnergyRulesToProto(input []GiftEnergyRuleConfig) []*roomv1.RoomTreasureGiftEnergyRule {
out := make([]*roomv1.RoomTreasureGiftEnergyRule, 0, len(input))
for _, rule := range input {
out = append(out, &roomv1.RoomTreasureGiftEnergyRule{
RuleId: rule.RuleID,
GiftId: rule.GiftID,
GiftTypeCode: rule.GiftTypeCode,
MultiplierPpm: rule.MultiplierPPM,
FixedEnergy: rule.FixedEnergy,
Excluded: rule.Excluded,
})
}
return out
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {

View File

@ -36,6 +36,13 @@ type Client interface {
CreateRechargeProduct(ctx context.Context, req *walletv1.CreateRechargeProductRequest) (*walletv1.RechargeProductResponse, error)
UpdateRechargeProduct(ctx context.Context, req *walletv1.UpdateRechargeProductRequest) (*walletv1.RechargeProductResponse, error)
DeleteRechargeProduct(ctx context.Context, req *walletv1.DeleteRechargeProductRequest) (*walletv1.DeleteRechargeProductResponse, error)
ListAdminVipLevels(ctx context.Context, req *walletv1.ListAdminVipLevelsRequest) (*walletv1.ListAdminVipLevelsResponse, error)
UpdateAdminVipLevels(ctx context.Context, req *walletv1.UpdateAdminVipLevelsRequest) (*walletv1.UpdateAdminVipLevelsResponse, error)
GrantVip(ctx context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error)
GetRedPacketConfig(ctx context.Context, req *walletv1.GetRedPacketConfigRequest) (*walletv1.GetRedPacketConfigResponse, error)
UpdateRedPacketConfig(ctx context.Context, req *walletv1.UpdateRedPacketConfigRequest) (*walletv1.UpdateRedPacketConfigResponse, error)
ListRedPackets(ctx context.Context, req *walletv1.ListRedPacketsRequest) (*walletv1.ListRedPacketsResponse, error)
GetRedPacket(ctx context.Context, req *walletv1.GetRedPacketRequest) (*walletv1.GetRedPacketResponse, error)
}
type GRPCClient struct {
@ -149,3 +156,31 @@ func (c *GRPCClient) UpdateRechargeProduct(ctx context.Context, req *walletv1.Up
func (c *GRPCClient) DeleteRechargeProduct(ctx context.Context, req *walletv1.DeleteRechargeProductRequest) (*walletv1.DeleteRechargeProductResponse, error) {
return c.client.DeleteRechargeProduct(ctx, req)
}
func (c *GRPCClient) ListAdminVipLevels(ctx context.Context, req *walletv1.ListAdminVipLevelsRequest) (*walletv1.ListAdminVipLevelsResponse, error) {
return c.client.ListAdminVipLevels(ctx, req)
}
func (c *GRPCClient) UpdateAdminVipLevels(ctx context.Context, req *walletv1.UpdateAdminVipLevelsRequest) (*walletv1.UpdateAdminVipLevelsResponse, error) {
return c.client.UpdateAdminVipLevels(ctx, req)
}
func (c *GRPCClient) GrantVip(ctx context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error) {
return c.client.GrantVip(ctx, req)
}
func (c *GRPCClient) GetRedPacketConfig(ctx context.Context, req *walletv1.GetRedPacketConfigRequest) (*walletv1.GetRedPacketConfigResponse, error) {
return c.client.GetRedPacketConfig(ctx, req)
}
func (c *GRPCClient) UpdateRedPacketConfig(ctx context.Context, req *walletv1.UpdateRedPacketConfigRequest) (*walletv1.UpdateRedPacketConfigResponse, error) {
return c.client.UpdateRedPacketConfig(ctx, req)
}
func (c *GRPCClient) ListRedPackets(ctx context.Context, req *walletv1.ListRedPacketsRequest) (*walletv1.ListRedPacketsResponse, error) {
return c.client.ListRedPackets(ctx, req)
}
func (c *GRPCClient) GetRedPacket(ctx context.Context, req *walletv1.GetRedPacketRequest) (*walletv1.GetRedPacketResponse, error) {
return c.client.GetRedPacket(ctx, req)
}

View File

@ -132,6 +132,21 @@ func (AppVersion) TableName() string {
return "admin_app_versions"
}
type AppExploreTab struct {
ID uint `gorm:"primaryKey" json:"id"`
AppCode string `gorm:"size:32;uniqueIndex:uk_admin_app_explore_tabs_tab;index:idx_admin_app_explore_tabs_app_sort,not null;default:lalu" json:"appCode"`
Tab string `gorm:"size:80;uniqueIndex:uk_admin_app_explore_tabs_tab,not null" json:"tab"`
H5URL string `gorm:"column:h5_url;size:2048;not null" json:"h5Url"`
Enabled bool `gorm:"index:idx_admin_app_explore_tabs_app_sort,not null;default:true" json:"enabled"`
SortOrder int `gorm:"index:idx_admin_app_explore_tabs_app_sort,not null;default:0" json:"sortOrder"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
}
func (AppExploreTab) TableName() string {
return "admin_app_explore_tabs"
}
type RefreshToken struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index;not null" json:"userId"`

View File

@ -47,6 +47,55 @@ func (h *Handler) UpdateH5Links(c *gin.Context) {
response.OK(c, gin.H{"items": items, "total": len(items)})
}
func (h *Handler) CreateH5Link(c *gin.Context) {
var request h5LinkPayload
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "参数不正确")
return
}
item, err := h.service.CreateH5Link(request)
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLog(c, h.audit, "create-app-h5-link", "admin_app_configs", "success", fmt.Sprintf("h5_key=%s", item.Key))
response.Created(c, item)
}
func (h *Handler) UpdateH5Link(c *gin.Context) {
key := strings.TrimSpace(c.Param("key"))
if key == "" {
response.BadRequest(c, "h5 config key is invalid")
return
}
var request h5LinkPayload
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "参数不正确")
return
}
item, err := h.service.UpdateH5Link(key, request)
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLog(c, h.audit, "update-app-h5-link", "admin_app_configs", "success", fmt.Sprintf("h5_key=%s", item.Key))
response.OK(c, item)
}
func (h *Handler) DeleteH5Link(c *gin.Context) {
key := strings.TrimSpace(c.Param("key"))
if key == "" {
response.BadRequest(c, "h5 config key is invalid")
return
}
if err := h.service.DeleteH5Link(key); err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLog(c, h.audit, "delete-app-h5-link", "admin_app_configs", "success", fmt.Sprintf("h5_key=%s", key))
response.OK(c, gin.H{"deleted": true})
}
func (h *Handler) ListBanners(c *gin.Context) {
options := shared.ListOptions(c)
items, err := h.service.ListBanners(appctx.FromContext(c.Request.Context()), repository.AppBannerListOptions{
@ -170,6 +219,75 @@ func (h *Handler) DeleteAppVersion(c *gin.Context) {
response.OK(c, gin.H{"deleted": true})
}
func (h *Handler) ListExploreTabs(c *gin.Context) {
var enabled *bool
switch strings.ToLower(strings.TrimSpace(c.Query("enabled"))) {
case "true", "1", "enabled":
value := true
enabled = &value
case "false", "0", "disabled":
value := false
enabled = &value
}
items, err := h.service.ListExploreTabs(appctx.FromContext(c.Request.Context()), repository.AppExploreTabListOptions{
Keyword: strings.TrimSpace(c.Query("keyword")),
Enabled: enabled,
})
if err != nil {
response.ServerError(c, "获取 Explore 配置失败")
return
}
response.OK(c, gin.H{"items": items, "total": len(items)})
}
func (h *Handler) CreateExploreTab(c *gin.Context) {
var request exploreTabRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "参数不正确")
return
}
item, err := h.service.CreateExploreTab(appctx.FromContext(c.Request.Context()), request)
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLog(c, h.audit, "create-app-explore-tab", "admin_app_explore_tabs", "success", fmt.Sprintf("explore_tab_id=%d", item.ID))
response.Created(c, item)
}
func (h *Handler) UpdateExploreTab(c *gin.Context) {
id, ok := exploreTabID(c)
if !ok {
return
}
var request exploreTabRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "参数不正确")
return
}
item, err := h.service.UpdateExploreTab(appctx.FromContext(c.Request.Context()), id, request)
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLog(c, h.audit, "update-app-explore-tab", "admin_app_explore_tabs", "success", fmt.Sprintf("explore_tab_id=%d", item.ID))
response.OK(c, item)
}
func (h *Handler) DeleteExploreTab(c *gin.Context) {
id, ok := exploreTabID(c)
if !ok {
return
}
if err := h.service.DeleteExploreTab(appctx.FromContext(c.Request.Context()), id); err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLog(c, h.audit, "delete-app-explore-tab", "admin_app_explore_tabs", "success", fmt.Sprintf("explore_tab_id=%d", id))
response.OK(c, gin.H{"deleted": true})
}
func bannerID(c *gin.Context) (uint, bool) {
value, err := strconv.ParseUint(c.Param("banner_id"), 10, 64)
if err != nil || value == 0 {
@ -188,6 +306,15 @@ func appVersionID(c *gin.Context) (uint, bool) {
return uint(value), true
}
func exploreTabID(c *gin.Context) (uint, bool) {
value, err := strconv.ParseUint(c.Param("tab_id"), 10, 64)
if err != nil || value == 0 {
response.BadRequest(c, "explore tab id is invalid")
return 0, false
}
return uint(value), true
}
func parseOptionalInt64(values ...string) int64 {
for _, value := range values {
value = strings.TrimSpace(value)

View File

@ -5,8 +5,9 @@ type updateH5LinksRequest struct {
}
type h5LinkPayload struct {
Key string `json:"key" binding:"required"`
URL string `json:"url"`
Key string `json:"key" binding:"required"`
Label string `json:"label" binding:"required"`
URL string `json:"url" binding:"required"`
}
type bannerRequest struct {
@ -33,3 +34,10 @@ type appVersionRequest struct {
DownloadURL string `json:"downloadUrl" binding:"required"`
Description string `json:"description"`
}
type exploreTabRequest struct {
Tab string `json:"tab" binding:"required"`
H5URL string `json:"h5Url" binding:"required"`
Enabled bool `json:"enabled"`
SortOrder int `json:"sortOrder"`
}

View File

@ -12,7 +12,15 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
}
protected.GET("/admin/app-config/h5-links", middleware.RequirePermission("app-config:view"), h.ListH5Links)
protected.POST("/admin/app-config/h5-links", middleware.RequirePermission("app-config:update"), h.CreateH5Link)
protected.PUT("/admin/app-config/h5-links", middleware.RequirePermission("app-config:update"), h.UpdateH5Links)
protected.PUT("/admin/app-config/h5-links/:key", middleware.RequirePermission("app-config:update"), h.UpdateH5Link)
protected.DELETE("/admin/app-config/h5-links/:key", middleware.RequirePermission("app-config:update"), h.DeleteH5Link)
protected.GET("/admin/app-config/explore-tabs", middleware.RequirePermission("app-config:view"), h.ListExploreTabs)
protected.POST("/admin/app-config/explore-tabs", middleware.RequirePermission("app-config:update"), h.CreateExploreTab)
protected.PUT("/admin/app-config/explore-tabs/:tab_id", middleware.RequirePermission("app-config:update"), h.UpdateExploreTab)
protected.DELETE("/admin/app-config/explore-tabs/:tab_id", middleware.RequirePermission("app-config:update"), h.DeleteExploreTab)
protected.GET("/admin/app-config/banners", middleware.RequirePermission("app-config:view"), h.ListBanners)
protected.POST("/admin/app-config/banners", middleware.RequirePermission("app-config:update"), h.CreateBanner)

View File

@ -25,23 +25,10 @@ const (
bannerStatusExpired = "expired"
)
var h5LinkDefinitions = []H5LinkDefinition{
{Key: "host-center", Label: "Host Center"},
{Key: "bd-center", Label: "BD Center"},
{Key: "bd-leader-center", Label: "BD Leader Center"},
{Key: "agency-center", Label: "Agency Center"},
{Key: "invite-user", Label: "Invite User"},
}
type AppConfigService struct {
store *repository.Store
}
type H5LinkDefinition struct {
Key string `json:"key"`
Label string `json:"label"`
}
type H5Link struct {
Key string `json:"key"`
Label string `json:"label"`
@ -82,6 +69,17 @@ type AppVersion struct {
UpdatedAtMs int64 `json:"updatedAtMs"`
}
type ExploreTab struct {
ID uint `json:"id"`
AppCode string `json:"appCode"`
Tab string `json:"tab"`
H5URL string `json:"h5Url"`
Enabled bool `json:"enabled"`
SortOrder int `json:"sortOrder"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
}
func NewService(store *repository.Store) *AppConfigService {
return &AppConfigService{store: store}
}
@ -92,22 +90,9 @@ func (s *AppConfigService) ListH5Links() ([]H5Link, error) {
return nil, err
}
configByKey := make(map[string]model.AppConfig, len(configs))
items := make([]H5Link, 0, len(configs))
for _, config := range configs {
configByKey[config.Key] = config
}
items := make([]H5Link, 0, len(h5LinkDefinitions))
for _, definition := range h5LinkDefinitions {
item := H5Link{
Key: definition.Key,
Label: definition.Label,
}
if config, ok := configByKey[definition.Key]; ok {
item.URL = config.Value
item.UpdatedAtMs = config.UpdatedAtMS
}
items = append(items, item)
items = append(items, h5LinkFromModel(config))
}
return items, nil
}
@ -117,29 +102,19 @@ func (s *AppConfigService) UpdateH5Links(request updateH5LinksRequest) ([]H5Link
return nil, errors.New("items are required")
}
allowed := h5LinkDefinitionSet()
items := make([]model.AppConfig, 0, len(request.Items))
seen := make(map[string]struct{}, len(request.Items))
for _, item := range request.Items {
key := strings.TrimSpace(item.Key)
if _, ok := allowed[key]; !ok {
return nil, fmt.Errorf("invalid h5 link key: %s", key)
config, err := h5LinkModelFromPayload(item)
if err != nil {
return nil, err
}
key := config.Key
if _, ok := seen[key]; ok {
return nil, fmt.Errorf("duplicate h5 link key: %s", key)
}
seen[key] = struct{}{}
link := strings.TrimSpace(item.URL)
if err := validateH5URL(link); err != nil {
return nil, err
}
items = append(items, model.AppConfig{
Group: h5LinkGroup,
Key: key,
Value: link,
Description: allowed[key],
})
items = append(items, config)
}
if err := s.store.UpsertAppConfigs(items); err != nil {
@ -148,6 +123,39 @@ func (s *AppConfigService) UpdateH5Links(request updateH5LinksRequest) ([]H5Link
return s.ListH5Links()
}
func (s *AppConfigService) CreateH5Link(req h5LinkPayload) (H5Link, error) {
item, err := h5LinkModelFromPayload(req)
if err != nil {
return H5Link{}, err
}
if err := s.store.CreateAppConfig(&item); err != nil {
return H5Link{}, err
}
return h5LinkFromModel(item), nil
}
func (s *AppConfigService) UpdateH5Link(key string, req h5LinkPayload) (H5Link, error) {
item, err := s.store.GetAppConfig(h5LinkGroup, key)
if err != nil {
return H5Link{}, err
}
req.Key = item.Key
updated, err := h5LinkModelFromPayload(req)
if err != nil {
return H5Link{}, err
}
item.Value = updated.Value
item.Description = updated.Description
if err := s.store.UpdateAppConfig(&item); err != nil {
return H5Link{}, err
}
return h5LinkFromModel(item), nil
}
func (s *AppConfigService) DeleteH5Link(key string) error {
return s.store.DeleteAppConfig(h5LinkGroup, key)
}
func (s *AppConfigService) ListBanners(appCode string, options repository.AppBannerListOptions) ([]AppBanner, error) {
options.AppCode = appctx.Normalize(appCode)
options.Status = normalizeBannerStatus(options.Status)
@ -265,12 +273,52 @@ func (s *AppConfigService) DeleteAppVersion(appCode string, id uint) error {
return s.store.DeleteAppVersion(appctx.Normalize(appCode), id)
}
func h5LinkDefinitionSet() map[string]string {
out := make(map[string]string, len(h5LinkDefinitions))
for _, definition := range h5LinkDefinitions {
out[definition.Key] = definition.Label
func (s *AppConfigService) ListExploreTabs(appCode string, options repository.AppExploreTabListOptions) ([]ExploreTab, error) {
options.AppCode = appctx.Normalize(appCode)
options.Keyword = strings.TrimSpace(options.Keyword)
items, err := s.store.ListAppExploreTabs(options)
if err != nil {
return nil, err
}
return out
out := make([]ExploreTab, 0, len(items))
for _, item := range items {
out = append(out, exploreTabFromModel(item))
}
return out, nil
}
func (s *AppConfigService) CreateExploreTab(appCode string, req exploreTabRequest) (ExploreTab, error) {
item, err := exploreTabModelFromRequest(appctx.Normalize(appCode), req)
if err != nil {
return ExploreTab{}, err
}
if err := s.store.CreateAppExploreTab(&item); err != nil {
return ExploreTab{}, err
}
return exploreTabFromModel(item), nil
}
func (s *AppConfigService) UpdateExploreTab(appCode string, id uint, req exploreTabRequest) (ExploreTab, error) {
item, err := s.store.GetAppExploreTab(appctx.Normalize(appCode), id)
if err != nil {
return ExploreTab{}, err
}
updated, err := exploreTabModelFromRequest(item.AppCode, req)
if err != nil {
return ExploreTab{}, err
}
item.Tab = updated.Tab
item.H5URL = updated.H5URL
item.Enabled = updated.Enabled
item.SortOrder = updated.SortOrder
if err := s.store.UpdateAppExploreTab(&item); err != nil {
return ExploreTab{}, err
}
return exploreTabFromModel(item), nil
}
func (s *AppConfigService) DeleteExploreTab(appCode string, id uint) error {
return s.store.DeleteAppExploreTab(appctx.Normalize(appCode), id)
}
func validateH5URL(value string) error {
@ -288,6 +336,60 @@ func validateH5URL(value string) error {
return nil
}
func h5LinkModelFromPayload(req h5LinkPayload) (model.AppConfig, error) {
key := strings.TrimSpace(req.Key)
label := strings.TrimSpace(req.Label)
url := strings.TrimSpace(req.URL)
if err := validateH5Key(key); err != nil {
return model.AppConfig{}, err
}
if label == "" || utf8.RuneCountInString(label) > 80 {
return model.AppConfig{}, errors.New("h5 config label is invalid")
}
if url == "" {
return model.AppConfig{}, errors.New("h5 link is required")
}
if err := validateH5URL(url); err != nil {
return model.AppConfig{}, err
}
return model.AppConfig{
Group: h5LinkGroup,
Key: key,
Value: url,
Description: label,
}, nil
}
func validateH5Key(value string) error {
if value == "" || len(value) > 80 {
return errors.New("h5 config key is invalid")
}
for _, char := range value {
switch {
case char >= 'a' && char <= 'z':
case char >= 'A' && char <= 'Z':
case char >= '0' && char <= '9':
case char == '-' || char == '_' || char == '.' || char == ':':
default:
return errors.New("h5 config key is invalid")
}
}
return nil
}
func h5LinkFromModel(config model.AppConfig) H5Link {
label := strings.TrimSpace(config.Description)
if label == "" {
label = config.Key
}
return H5Link{
Key: config.Key,
Label: label,
URL: config.Value,
UpdatedAtMs: config.UpdatedAtMS,
}
}
func bannerModelFromRequest(appCode string, req bannerRequest) (model.AppBanner, error) {
item := model.AppBanner{
AppCode: appctx.Normalize(appCode),
@ -436,6 +538,39 @@ func appVersionFromModel(item model.AppVersion) AppVersion {
}
}
func exploreTabModelFromRequest(appCode string, req exploreTabRequest) (model.AppExploreTab, error) {
item := model.AppExploreTab{
AppCode: appctx.Normalize(appCode),
Tab: strings.TrimSpace(req.Tab),
H5URL: strings.TrimSpace(req.H5URL),
Enabled: req.Enabled,
SortOrder: req.SortOrder,
}
if item.Tab == "" || utf8.RuneCountInString(item.Tab) > 40 {
return model.AppExploreTab{}, errors.New("explore tab is invalid")
}
if item.H5URL == "" || len(item.H5URL) > 2048 {
return model.AppExploreTab{}, errors.New("explore h5 url is invalid")
}
if err := validateH5URL(item.H5URL); err != nil {
return model.AppExploreTab{}, err
}
return item, nil
}
func exploreTabFromModel(item model.AppExploreTab) ExploreTab {
return ExploreTab{
ID: item.ID,
AppCode: item.AppCode,
Tab: item.Tab,
H5URL: item.H5URL,
Enabled: item.Enabled,
SortOrder: item.SortOrder,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
}
}
func normalizeBannerType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}

View File

@ -5,6 +5,31 @@ import (
"time"
)
func TestH5LinkModelFromPayloadValidatesDynamicConfig(t *testing.T) {
item, err := h5LinkModelFromPayload(h5LinkPayload{
Key: "host-center.v2",
Label: "Host Center",
URL: "https://h5.example.com/host",
})
if err != nil {
t.Fatalf("h5 config should be valid: %v", err)
}
if item.Group != h5LinkGroup || item.Key != "host-center.v2" || item.Description != "Host Center" || item.Value != "https://h5.example.com/host" {
t.Fatalf("h5 config model mismatch: %+v", item)
}
}
func TestH5LinkModelFromPayloadRejectsInvalidKey(t *testing.T) {
_, err := h5LinkModelFromPayload(h5LinkPayload{
Key: "host center",
Label: "Host Center",
URL: "https://h5.example.com/host",
})
if err == nil {
t.Fatal("expected invalid key to fail")
}
}
func TestBannerModelFromRequestRequiresRoomSmallImageForRoomScope(t *testing.T) {
_, err := bannerModelFromRequest("lalu", bannerRequest{
CoverURL: "https://cdn.example.com/banner.png",
@ -65,3 +90,29 @@ func TestBannerModelFromRequestExpiresEndedActiveBanner(t *testing.T) {
t.Fatalf("non-room banner must not retain room small image: %+v", item)
}
}
func TestExploreTabModelFromRequestValidatesRequiredFields(t *testing.T) {
item, err := exploreTabModelFromRequest("lalu", exploreTabRequest{
Enabled: true,
H5URL: "https://h5.example.com/explore/live",
SortOrder: 3,
Tab: "Live",
})
if err != nil {
t.Fatalf("explore tab should be valid: %v", err)
}
if item.AppCode != "lalu" || item.Tab != "Live" || item.H5URL != "https://h5.example.com/explore/live" || !item.Enabled || item.SortOrder != 3 {
t.Fatalf("explore tab model mismatch: %+v", item)
}
}
func TestExploreTabModelFromRequestRejectsWhitespaceURL(t *testing.T) {
_, err := exploreTabModelFromRequest("lalu", exploreTabRequest{
Enabled: true,
H5URL: "https://h5.example.com/explore live",
Tab: "Live",
})
if err == nil {
t.Fatal("expected whitespace h5 url to fail")
}
}

View File

@ -1,7 +1,10 @@
package dashboard
import (
"strconv"
"github.com/gin-gonic/gin"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response"
)
@ -10,8 +13,8 @@ type Handler struct {
service *DashboardService
}
func New(store *repository.Store) *Handler {
return &Handler{service: NewService(store)}
func New(store *repository.Store, cfg config.Config) *Handler {
return &Handler{service: NewService(store, cfg)}
}
func (h *Handler) DashboardOverview(c *gin.Context) {
@ -22,3 +25,22 @@ func (h *Handler) DashboardOverview(c *gin.Context) {
}
response.OK(c, overview)
}
func (h *Handler) StatisticsOverview(c *gin.Context) {
overview, err := h.service.StatisticsOverview(c.Request.Context(), StatisticsQuery{
AppCode: c.DefaultQuery("app_code", "lalu"),
StartMS: parseInt64(c.Query("start_ms")),
EndMS: parseInt64(c.Query("end_ms")),
CountryID: parseInt64(c.Query("country_id")),
})
if err != nil {
response.ServerError(c, "获取统计总览失败")
return
}
response.OK(c, overview)
}
func parseInt64(value string) int64 {
parsed, _ := strconv.ParseInt(value, 10, 64)
return parsed
}

View File

@ -8,4 +8,5 @@ import (
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/dashboard/overview", middleware.RequirePermission("overview:view"), h.DashboardOverview)
protected.GET("/statistics/overview", middleware.RequirePermission("overview:view"), h.StatisticsOverview)
}

View File

@ -1,15 +1,70 @@
package dashboard
import "hyapp-admin-server/internal/repository"
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/repository"
)
type DashboardService struct {
store *repository.Store
store *repository.Store
cfg config.Config
httpClient *http.Client
}
func NewService(store *repository.Store) *DashboardService {
return &DashboardService{store: store}
type StatisticsQuery struct {
AppCode string
StartMS int64
EndMS int64
CountryID int64
}
func NewService(store *repository.Store, cfg config.Config) *DashboardService {
return &DashboardService{
store: store,
cfg: cfg,
httpClient: &http.Client{Timeout: cfg.StatisticsService.RequestTimeout},
}
}
func (s *DashboardService) Overview() (*repository.DashboardOverview, error) {
return s.store.DashboardOverview()
}
func (s *DashboardService) StatisticsOverview(ctx context.Context, query StatisticsQuery) (map[string]any, error) {
baseURL := s.cfg.StatisticsService.BaseURL + "/internal/v1/statistics/overview"
values := url.Values{}
values.Set("app_code", query.AppCode)
if query.StartMS > 0 {
values.Set("start_ms", strconv.FormatInt(query.StartMS, 10))
}
if query.EndMS > 0 {
values.Set("end_ms", strconv.FormatInt(query.EndMS, 10))
}
if query.CountryID > 0 {
values.Set("country_id", strconv.FormatInt(query.CountryID, 10))
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"?"+values.Encode(), nil)
if err != nil {
return nil, err
}
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("statistics service returned status %d", resp.StatusCode)
}
var out map[string]any
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return out, nil
}

View File

@ -0,0 +1,348 @@
package firstrechargereward
import (
"context"
"database/sql"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
activityv1 "hyapp.local/api/proto/activity/v1"
"github.com/gin-gonic/gin"
)
type Handler struct {
activity activityclient.Client
userDB *sql.DB
audit shared.OperationLogger
}
func New(activity activityclient.Client, userDB *sql.DB, audit shared.OperationLogger) *Handler {
return &Handler{activity: activity, userDB: userDB, audit: audit}
}
type configRequest struct {
Enabled bool `json:"enabled"`
Tiers []tierDTO `json:"tiers"`
}
type configDTO struct {
AppCode string `json:"app_code"`
Enabled bool `json:"enabled"`
Tiers []tierDTO `json:"tiers"`
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type tierDTO struct {
TierID int64 `json:"tier_id"`
TierCode string `json:"tier_code"`
TierName string `json:"tier_name"`
MinCoinAmount int64 `json:"min_coin_amount"`
MaxCoinAmount int64 `json:"max_coin_amount"`
ResourceGroupID int64 `json:"resource_group_id"`
Status string `json:"status"`
SortOrder int32 `json:"sort_order"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type claimDTO struct {
ClaimID string `json:"claim_id"`
EventID string `json:"event_id"`
TransactionID string `json:"transaction_id"`
CommandID string `json:"command_id"`
UserID int64 `json:"user_id"`
User *userDTO `json:"user,omitempty"`
TierID int64 `json:"tier_id"`
TierCode string `json:"tier_code"`
ResourceGroupID int64 `json:"resource_group_id"`
RechargeCoinAmount int64 `json:"recharge_coin_amount"`
RechargeSequence int64 `json:"recharge_sequence"`
RechargeType string `json:"recharge_type"`
Status string `json:"status"`
WalletCommandID string `json:"wallet_command_id"`
WalletGrantID string `json:"wallet_grant_id"`
FailureReason string `json:"failure_reason"`
RechargedAtMS int64 `json:"recharged_at_ms"`
GrantedAtMS int64 `json:"granted_at_ms"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type userDTO struct {
UserID int64 `json:"user_id"`
DisplayUserID string `json:"display_user_id"`
Username string `json:"username"`
Avatar string `json:"avatar"`
}
func (h *Handler) GetConfig(c *gin.Context) {
resp, err := h.activity.GetFirstRechargeRewardConfig(c.Request.Context(), &activityv1.GetFirstRechargeRewardConfigRequest{Meta: h.meta(c)})
if err != nil {
response.ServerError(c, "获取首冲奖励配置失败")
return
}
response.OK(c, configFromProto(resp.GetConfig()))
}
func (h *Handler) UpdateConfig(c *gin.Context) {
var req configRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "首冲奖励配置参数不正确")
return
}
tiers := make([]*activityv1.FirstRechargeRewardTier, 0, len(req.Tiers))
for _, tier := range req.Tiers {
tiers = append(tiers, &activityv1.FirstRechargeRewardTier{
TierId: tier.TierID,
TierCode: strings.TrimSpace(tier.TierCode),
TierName: strings.TrimSpace(tier.TierName),
MinCoinAmount: tier.MinCoinAmount,
MaxCoinAmount: tier.MaxCoinAmount,
ResourceGroupId: tier.ResourceGroupID,
Status: strings.TrimSpace(tier.Status),
SortOrder: tier.SortOrder,
})
}
resp, err := h.activity.UpdateFirstRechargeRewardConfig(c.Request.Context(), &activityv1.UpdateFirstRechargeRewardConfigRequest{
Meta: h.meta(c),
Enabled: req.Enabled,
Tiers: tiers,
OperatorAdminId: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := configFromProto(resp.GetConfig())
shared.OperationLogWithResourceID(c, h.audit, "update-first-recharge-reward", "first_recharge_reward_configs", item.AppCode, "success", "")
response.OK(c, item)
}
func (h *Handler) ListClaims(c *gin.Context) {
options := shared.ListOptions(c)
userID, matched, ok := h.resolveClaimUserID(c.Request.Context(), strings.TrimSpace(options.Keyword))
if !ok {
response.ServerError(c, "查询用户信息失败")
return
}
if options.Keyword != "" && !matched {
response.OK(c, response.Page{Items: []claimDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0})
return
}
resp, err := h.activity.ListFirstRechargeRewardClaims(c.Request.Context(), &activityv1.ListFirstRechargeRewardClaimsRequest{
Meta: h.meta(c),
Status: strings.TrimSpace(options.Status),
UserId: userID,
Page: int32(options.Page),
PageSize: int32(options.PageSize),
})
if err != nil {
response.ServerError(c, "获取首冲奖励发放记录失败")
return
}
items := make([]claimDTO, 0, len(resp.GetClaims()))
for _, claim := range resp.GetClaims() {
items = append(items, claimFromProto(claim))
}
if err := h.fillClaimUsers(c.Request.Context(), items); err != nil {
response.ServerError(c, "补全用户信息失败")
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta {
return &activityv1.RequestMeta{
RequestId: middleware.CurrentRequestID(c),
Caller: "admin-server",
AppCode: appctx.FromContext(c.Request.Context()),
SentAtMs: time.Now().UnixMilli(),
}
}
func (h *Handler) resolveClaimUserID(ctx context.Context, keyword string) (int64, bool, bool) {
if keyword == "" {
return 0, false, true
}
if h.userDB == nil {
return 0, false, true
}
appCode := appctx.FromContext(ctx)
rows, err := h.userDB.QueryContext(ctx, `
SELECT user_id
FROM users
WHERE app_code = ?
AND (
CAST(user_id AS CHAR) = ?
OR current_display_user_id = ?
OR username LIKE ?
)
ORDER BY
CASE
WHEN CAST(user_id AS CHAR) = ? THEN 0
WHEN current_display_user_id = ? THEN 1
ELSE 2
END,
user_id DESC
LIMIT 1`,
appCode, keyword, keyword, "%"+keyword+"%", keyword, keyword,
)
if err != nil {
return 0, false, false
}
defer rows.Close()
if !rows.Next() {
return 0, false, rows.Err() == nil
}
var userID int64
if err := rows.Scan(&userID); err != nil {
return 0, false, false
}
return userID, true, rows.Err() == nil
}
func (h *Handler) fillClaimUsers(ctx context.Context, claims []claimDTO) error {
if h.userDB == nil || len(claims) == 0 {
return nil
}
ids := collectClaimUserIDs(claims)
if len(ids) == 0 {
return nil
}
args := append([]any{appctx.FromContext(ctx)}, int64Args(ids)...)
rows, err := h.userDB.QueryContext(ctx, `
SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '')
FROM users
WHERE app_code = ? AND user_id IN (`+placeholders(len(ids))+`)`,
args...,
)
if err != nil {
return err
}
defer rows.Close()
users := make(map[int64]userDTO, len(ids))
for rows.Next() {
var user userDTO
if err := rows.Scan(&user.UserID, &user.DisplayUserID, &user.Username, &user.Avatar); err != nil {
return err
}
users[user.UserID] = user
}
if err := rows.Err(); err != nil {
return err
}
for index := range claims {
if user, ok := users[claims[index].UserID]; ok {
claims[index].User = &user
continue
}
claims[index].User = &userDTO{UserID: claims[index].UserID}
}
return nil
}
func configFromProto(config *activityv1.FirstRechargeRewardConfig) configDTO {
if config == nil {
return configDTO{Tiers: []tierDTO{}}
}
tiers := make([]tierDTO, 0, len(config.GetTiers()))
for _, tier := range config.GetTiers() {
tiers = append(tiers, tierFromProto(tier))
}
return configDTO{
AppCode: config.GetAppCode(),
Enabled: config.GetEnabled(),
Tiers: tiers,
UpdatedByAdminID: config.GetUpdatedByAdminId(),
CreatedAtMS: config.GetCreatedAtMs(),
UpdatedAtMS: config.GetUpdatedAtMs(),
}
}
func tierFromProto(tier *activityv1.FirstRechargeRewardTier) tierDTO {
if tier == nil {
return tierDTO{}
}
return tierDTO{
TierID: tier.GetTierId(),
TierCode: tier.GetTierCode(),
TierName: tier.GetTierName(),
MinCoinAmount: tier.GetMinCoinAmount(),
MaxCoinAmount: tier.GetMaxCoinAmount(),
ResourceGroupID: tier.GetResourceGroupId(),
Status: tier.GetStatus(),
SortOrder: tier.GetSortOrder(),
CreatedAtMS: tier.GetCreatedAtMs(),
UpdatedAtMS: tier.GetUpdatedAtMs(),
}
}
func claimFromProto(claim *activityv1.FirstRechargeRewardClaim) claimDTO {
if claim == nil {
return claimDTO{}
}
return claimDTO{
ClaimID: claim.GetClaimId(),
EventID: claim.GetEventId(),
TransactionID: claim.GetTransactionId(),
CommandID: claim.GetCommandId(),
UserID: claim.GetUserId(),
TierID: claim.GetTierId(),
TierCode: claim.GetTierCode(),
ResourceGroupID: claim.GetResourceGroupId(),
RechargeCoinAmount: claim.GetRechargeCoinAmount(),
RechargeSequence: claim.GetRechargeSequence(),
RechargeType: claim.GetRechargeType(),
Status: claim.GetStatus(),
WalletCommandID: claim.GetWalletCommandId(),
WalletGrantID: claim.GetWalletGrantId(),
FailureReason: claim.GetFailureReason(),
RechargedAtMS: claim.GetRechargedAtMs(),
GrantedAtMS: claim.GetGrantedAtMs(),
CreatedAtMS: claim.GetCreatedAtMs(),
UpdatedAtMS: claim.GetUpdatedAtMs(),
}
}
func collectClaimUserIDs(claims []claimDTO) []int64 {
seen := make(map[int64]struct{}, len(claims))
ids := make([]int64, 0, len(claims))
for _, claim := range claims {
if claim.UserID <= 0 {
continue
}
if _, ok := seen[claim.UserID]; ok {
continue
}
seen[claim.UserID] = struct{}{}
ids = append(ids, claim.UserID)
}
return ids
}
func int64Args(ids []int64) []any {
args := make([]any, 0, len(ids))
for _, id := range ids {
args = append(args, id)
}
return args
}
func placeholders(count int) string {
if count <= 0 {
return ""
}
parts := make([]string, count)
for i := range parts {
parts[i] = "?"
}
return strings.Join(parts, ",")
}

View File

@ -0,0 +1,16 @@
package firstrechargereward
import (
"hyapp-admin-server/internal/middleware"
"github.com/gin-gonic/gin"
)
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
if h == nil {
return
}
protected.GET("/admin/activity/first-recharge-reward/config", middleware.RequirePermission("first-recharge-reward:view"), h.GetConfig)
protected.PUT("/admin/activity/first-recharge-reward/config", middleware.RequirePermission("first-recharge-reward:update"), h.UpdateConfig)
protected.GET("/admin/activity/first-recharge-reward/claims", middleware.RequirePermission("first-recharge-reward:view"), h.ListClaims)
}

View File

@ -8,28 +8,38 @@ type platformDTO struct {
PlatformName string `json:"platformName"`
Status string `json:"status"`
APIBaseURL string `json:"apiBaseUrl"`
SortOrder int32 `json:"sortOrder"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
// adapterType 决定服务端按哪家厂商协议拼启动 URL 和处理回调。
AdapterType string `json:"adapterType"`
// callbackSecret 是后台配置页可见的厂商 key/AppSecret只对有 game:view 权限的管理员返回。
CallbackSecret string `json:"callbackSecret"`
CallbackSecretSet bool `json:"callbackSecretSet"`
CallbackIPWhitelist []string `json:"callbackIpWhitelist"`
// adapterConfigJson 承载 uid_mode/default_lang/token_ttl_seconds 等可热更新配置。
AdapterConfigJSON string `json:"adapterConfigJson"`
SortOrder int32 `json:"sortOrder"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type catalogDTO struct {
AppCode string `json:"appCode"`
GameID string `json:"gameId"`
PlatformCode string `json:"platformCode"`
ProviderGameID string `json:"providerGameId"`
GameName string `json:"gameName"`
Category string `json:"category"`
IconURL string `json:"iconUrl"`
CoverURL string `json:"coverUrl"`
LaunchMode string `json:"launchMode"`
Orientation string `json:"orientation"`
MinCoin int64 `json:"minCoin"`
Status string `json:"status"`
SortOrder int32 `json:"sortOrder"`
Tags []string `json:"tags"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
AppCode string `json:"appCode"`
GameID string `json:"gameId"`
PlatformCode string `json:"platformCode"`
ProviderGameID string `json:"providerGameId"`
GameName string `json:"gameName"`
Category string `json:"category"`
IconURL string `json:"iconUrl"`
CoverURL string `json:"coverUrl"`
// launchUrl 只在厂商列表预览时返回,正式目录仍由 platform.adapterConfigJson.game_urls 按 providerGameId 解析。
LaunchURL string `json:"launchUrl,omitempty"`
LaunchMode string `json:"launchMode"`
Orientation string `json:"orientation"`
MinCoin int64 `json:"minCoin"`
Status string `json:"status"`
SortOrder int32 `json:"sortOrder"`
Tags []string `json:"tags"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
func platformFromProto(item *gamev1.GamePlatform) platformDTO {
@ -37,14 +47,19 @@ func platformFromProto(item *gamev1.GamePlatform) platformDTO {
return platformDTO{}
}
return platformDTO{
AppCode: item.GetAppCode(),
PlatformCode: item.GetPlatformCode(),
PlatformName: item.GetPlatformName(),
Status: item.GetStatus(),
APIBaseURL: item.GetApiBaseUrl(),
SortOrder: item.GetSortOrder(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
AppCode: item.GetAppCode(),
PlatformCode: item.GetPlatformCode(),
PlatformName: item.GetPlatformName(),
Status: item.GetStatus(),
APIBaseURL: item.GetApiBaseUrl(),
AdapterType: item.GetAdapterType(),
CallbackSecret: item.GetCallbackSecret(),
CallbackSecretSet: item.GetCallbackSecretSet(),
CallbackIPWhitelist: item.GetCallbackIpWhitelist(),
AdapterConfigJSON: item.GetAdapterConfigJson(),
SortOrder: item.GetSortOrder(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}

View File

@ -0,0 +1,24 @@
package gamemanagement
import (
"testing"
gamev1 "hyapp.local/api/proto/game/v1"
)
func TestPlatformFromProtoReturnsCallbackSecret(t *testing.T) {
const secret = "yomi-or-lingxian-secret"
got := platformFromProto(&gamev1.GamePlatform{
PlatformCode: "yomi",
CallbackSecret: secret,
CallbackSecretSet: true,
})
if got.CallbackSecret != secret {
t.Fatalf("CallbackSecret = %q, want %q", got.CallbackSecret, secret)
}
if !got.CallbackSecretSet {
t.Fatal("CallbackSecretSet = false, want true")
}
}

View File

@ -46,7 +46,7 @@ func (h *Handler) UpdatePlatform(c *gin.Context) {
h.upsertPlatform(c, strings.TrimSpace(c.Param("platform_code")))
}
// upsertPlatform 创建或更新第三方游戏平台配置;平台密钥后续走单独的密钥托管接口
// upsertPlatform 创建或更新第三方游戏平台配置;callbackSecret 非空时才会轮换厂商密钥
func (h *Handler) upsertPlatform(c *gin.Context, platformCode string) {
var req platformRequest
if err := c.ShouldBindJSON(&req); err != nil {
@ -146,6 +146,24 @@ func (h *Handler) SetGameStatus(c *gin.Context) {
response.OK(c, item)
}
// DeleteCatalog 删除后台游戏目录记录,同时由 game-service 清理展示规则;历史订单和启动会话保留用于审计。
func (h *Handler) DeleteCatalog(c *gin.Context) {
gameID := strings.TrimSpace(c.Param("game_id"))
if gameID == "" {
response.BadRequest(c, "游戏 ID 参数不正确")
return
}
if _, err := h.game.DeleteCatalog(c.Request.Context(), &gamev1.DeleteCatalogRequest{
Meta: requestMeta(c),
GameId: gameID,
}); err != nil {
response.BadRequest(c, err.Error())
return
}
h.auditLog(c, "delete-game-catalog", "game_catalog", gameID, "deleted")
response.OK(c, gin.H{"gameId": gameID})
}
func (h *Handler) auditLog(c *gin.Context, action string, resource string, resourceID string, detail string) {
shared.OperationLogWithResourceID(c, h.audit, action, resource, resourceID, "success", detail)
}

View File

@ -16,7 +16,14 @@ type platformRequest struct {
PlatformName string `json:"platformName"`
Status string `json:"status"`
APIBaseURL string `json:"apiBaseUrl"`
SortOrder int32 `json:"sortOrder"`
// AdapterType 只允许服务端白名单中的值,避免后台误填导致回调走错协议。
AdapterType string `json:"adapterType"`
// CallbackSecret 只有提交非空时才覆盖旧密钥,编辑其它字段时前端传空即可。
CallbackSecret string `json:"callbackSecret"`
CallbackIPWhitelist []string `json:"callbackIpWhitelist"`
// AdapterConfigJSON 是厂商扩展配置,保持 JSON 字符串透传给 game-service。
AdapterConfigJSON string `json:"adapterConfigJson"`
SortOrder int32 `json:"sortOrder"`
}
type catalogRequest struct {
@ -44,11 +51,15 @@ func (r platformRequest) toProto(platformCode string) *gamev1.GamePlatform {
platformCode = r.PlatformCode
}
return &gamev1.GamePlatform{
PlatformCode: strings.TrimSpace(platformCode),
PlatformName: strings.TrimSpace(r.PlatformName),
Status: strings.TrimSpace(r.Status),
ApiBaseUrl: strings.TrimSpace(r.APIBaseURL),
SortOrder: r.SortOrder,
PlatformCode: strings.TrimSpace(platformCode),
PlatformName: strings.TrimSpace(r.PlatformName),
Status: strings.TrimSpace(r.Status),
ApiBaseUrl: strings.TrimSpace(r.APIBaseURL),
AdapterType: strings.TrimSpace(r.AdapterType),
CallbackSecret: strings.TrimSpace(r.CallbackSecret),
CallbackIpWhitelist: compactTags(r.CallbackIPWhitelist),
AdapterConfigJson: strings.TrimSpace(r.AdapterConfigJSON),
SortOrder: r.SortOrder,
}
}

View File

@ -14,8 +14,10 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/admin/game/platforms", middleware.RequirePermission("game:view"), h.ListPlatforms)
protected.POST("/admin/game/platforms", middleware.RequirePermission("game:update"), h.CreatePlatform)
protected.PATCH("/admin/game/platforms/:platform_code", middleware.RequirePermission("game:update"), h.UpdatePlatform)
protected.POST("/admin/game/platforms/:platform_code/sync-games", middleware.RequirePermission("game:update"), h.SyncPlatformGames)
protected.GET("/admin/game/games", middleware.RequirePermission("game:view"), h.ListCatalog)
protected.POST("/admin/game/games", middleware.RequirePermission("game:create"), h.CreateCatalog)
protected.PATCH("/admin/game/games/:game_id", middleware.RequirePermission("game:update"), h.UpdateCatalog)
protected.PATCH("/admin/game/games/:game_id/status", middleware.RequirePermission("game:status"), h.SetGameStatus)
protected.DELETE("/admin/game/games/:game_id", middleware.RequireAnyPermission("game:delete", "game:update"), h.DeleteCatalog)
}

View File

@ -0,0 +1,656 @@
package gamemanagement
import (
"bytes"
"context"
"crypto/md5"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/response"
gamev1 "hyapp.local/api/proto/game/v1"
"github.com/gin-gonic/gin"
)
const (
adapterBaishunV1 = "baishun_v1"
adapterZeeOneV1 = "zeeone_v1"
defaultGameStatus = "disabled"
defaultGameCategory = "casino"
defaultLaunchMode = "full_screen"
defaultOrientation = "portrait"
)
type syncGamesRequest struct {
DryRun bool `json:"dryRun"`
Status string `json:"status"`
Category string `json:"category"`
LaunchMode string `json:"launchMode"`
MinCoin int64 `json:"minCoin"`
Tags []string `json:"tags"`
GameListType int `json:"gameListType"`
ProviderGameIDs []string `json:"providerGameIds"`
}
type providerGameSyncPlan struct {
Games []catalogRequest
GameURLs map[string]string
}
// SyncPlatformGames 从厂商后台 API 拉取游戏清单,格式化成本地 game_catalog再由 game-service 落库并补默认展示规则。
func (h *Handler) SyncPlatformGames(c *gin.Context) {
platformCode := strings.TrimSpace(c.Param("platform_code"))
if platformCode == "" {
response.BadRequest(c, "平台 Code 参数不正确")
return
}
var req syncGamesRequest
if c.Request.ContentLength > 0 {
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "同步参数不正确")
return
}
}
platform, err := h.findGamePlatform(c.Request.Context(), c, platformCode)
if err != nil {
response.BadRequest(c, err.Error())
return
}
plan, err := fetchProviderGameSyncPlan(c.Request.Context(), platform, req)
if err != nil {
response.BadRequest(c, err.Error())
return
}
games := selectProviderGames(plan.Games, req.ProviderGameIDs)
items := make([]catalogDTO, 0, len(games))
if req.DryRun {
for _, game := range games {
item := catalogFromProto(game.toProto(""))
item.LaunchURL = strings.TrimSpace(plan.GameURLs[game.ProviderGameID])
items = append(items, item)
}
response.OK(c, gin.H{"platformCode": platformCode, "synced": len(items), "dryRun": true, "items": items})
return
}
if len(games) == 0 {
response.BadRequest(c, "请选择要添加的游戏")
return
}
adapterConfigJSON := mergeAdapterGameURLs(platform.GetAdapterConfigJson(), selectedGameURLs(plan.GameURLs, games))
if strings.TrimSpace(adapterConfigJSON) != "" && strings.TrimSpace(adapterConfigJSON) != strings.TrimSpace(platform.GetAdapterConfigJson()) {
if _, err := h.game.UpsertPlatform(c.Request.Context(), &gamev1.UpsertPlatformRequest{
Meta: requestMeta(c),
Platform: platformWithAdapterConfig(platform, adapterConfigJSON),
}); err != nil {
response.BadRequest(c, "同步游戏平台配置失败: "+err.Error())
return
}
}
for _, game := range games {
resp, err := h.game.UpsertCatalog(c.Request.Context(), &gamev1.UpsertCatalogRequest{
Meta: requestMeta(c),
Game: game.toProto(""),
})
if err != nil {
response.BadRequest(c, "同步游戏目录失败: "+err.Error())
return
}
items = append(items, catalogFromProto(resp.GetGame()))
}
h.auditLog(c, "sync-provider-games", "game_platforms", platformCode, fmt.Sprintf("synced=%d", len(items)))
response.OK(c, gin.H{"platformCode": platformCode, "synced": len(items), "dryRun": false, "items": items})
}
func (h *Handler) findGamePlatform(ctx context.Context, c *gin.Context, platformCode string) (*gamev1.GamePlatform, error) {
resp, err := h.game.ListPlatforms(ctx, &gamev1.ListPlatformsRequest{Meta: requestMeta(c)})
if err != nil {
return nil, fmt.Errorf("获取游戏平台失败")
}
for _, platform := range resp.GetPlatforms() {
if strings.EqualFold(strings.TrimSpace(platform.GetPlatformCode()), platformCode) {
return platform, nil
}
}
return nil, fmt.Errorf("游戏平台不存在")
}
func fetchProviderGameSyncPlan(ctx context.Context, platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) {
switch strings.ToLower(strings.TrimSpace(platform.GetAdapterType())) {
case adapterBaishunV1:
return fetchBaishunGameSyncPlan(ctx, platform, req)
case adapterZeeOneV1:
return fetchZeeOneGameSyncPlan(platform, req)
default:
// 不在配置文件写死游戏:每家厂商新增“列表 API”时只加一个 adapter fetcher后台入口保持不变。
return providerGameSyncPlan{}, fmt.Errorf("当前适配器暂未接入厂商游戏列表 API: %s", platform.GetAdapterType())
}
}
func platformWithAdapterConfig(platform *gamev1.GamePlatform, adapterConfigJSON string) *gamev1.GamePlatform {
return &gamev1.GamePlatform{
AppCode: platform.GetAppCode(),
PlatformCode: platform.GetPlatformCode(),
PlatformName: platform.GetPlatformName(),
Status: platform.GetStatus(),
ApiBaseUrl: platform.GetApiBaseUrl(),
AdapterType: platform.GetAdapterType(),
CallbackSecret: platform.GetCallbackSecret(),
CallbackSecretSet: platform.GetCallbackSecretSet(),
CallbackIpWhitelist: platform.GetCallbackIpWhitelist(),
AdapterConfigJson: adapterConfigJSON,
SortOrder: platform.GetSortOrder(),
CreatedAtMs: platform.GetCreatedAtMs(),
UpdatedAtMs: platform.GetUpdatedAtMs(),
}
}
type baishunSyncConfig struct {
AppID int64 `json:"app_id"`
AppIDCamel int64 `json:"appId"`
AppChannel string `json:"app_channel"`
AppChannelCamel string `json:"appChannel"`
GameListType int `json:"game_list_type"`
GameListTypeCamel int `json:"gameListType"`
ProviderAPIBaseURL string `json:"provider_api_base_url"`
ProviderAPIBaseURLAlt string `json:"providerApiBaseUrl"`
GameListURL string `json:"game_list_url"`
GameListURLCamel string `json:"gameListUrl"`
GameURLs map[string]string `json:"game_urls"`
}
type zeeoneSyncConfig struct {
// ZeeOne 测试资料给的是逐游戏 H5 地址,不是列表 API后台“获取游戏列表”直接把这个映射转换为候选目录。
GameURLs map[string]string `json:"game_urls"`
GameNames map[string]string `json:"game_names"`
GameIcons map[string]string `json:"game_icons"`
GameCovers map[string]string `json:"game_covers"`
}
func fetchZeeOneGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) {
config, err := decodeZeeOneSyncConfig(platform.GetAdapterConfigJson())
if err != nil {
return providerGameSyncPlan{}, err
}
games, gameURLs := zeeoneCatalogItems(platform.GetPlatformCode(), config, req)
if len(games) == 0 {
return providerGameSyncPlan{}, fmt.Errorf("ZeeOne 游戏列表为空:请在 adapterConfigJson.game_urls 配置游戏 ID 到 H5 URL 的映射")
}
return providerGameSyncPlan{
Games: games,
GameURLs: gameURLs,
}, nil
}
func decodeZeeOneSyncConfig(raw string) (zeeoneSyncConfig, error) {
if strings.TrimSpace(raw) == "" {
return zeeoneSyncConfig{}, nil
}
var config zeeoneSyncConfig
if err := json.Unmarshal([]byte(raw), &config); err != nil {
return zeeoneSyncConfig{}, fmt.Errorf("ZeeOne 适配器配置 JSON 不合法")
}
return config, nil
}
func zeeoneCatalogItems(platformCode string, config zeeoneSyncConfig, req syncGamesRequest) ([]catalogRequest, map[string]string) {
keys := make([]string, 0, len(config.GameURLs))
for providerGameID, launchURL := range config.GameURLs {
if strings.TrimSpace(providerGameID) != "" && strings.TrimSpace(launchURL) != "" {
keys = append(keys, strings.TrimSpace(providerGameID))
}
}
sort.Strings(keys)
items := make([]catalogRequest, 0, len(keys))
gameURLs := make(map[string]string, len(keys))
for index, providerGameID := range keys {
launchURL := strings.TrimSpace(config.GameURLs[providerGameID])
gameURLs[providerGameID] = launchURL
name := firstNonEmpty(config.GameNames[providerGameID], zeeoneGameNameFromURL(launchURL), providerGameID)
iconURL := strings.TrimSpace(config.GameIcons[providerGameID])
coverURL := firstNonEmpty(config.GameCovers[providerGameID], iconURL)
items = append(items, catalogRequest{
GameID: stableGameID(platformCode, providerGameID),
PlatformCode: strings.TrimSpace(platformCode),
ProviderGameID: providerGameID,
GameName: name,
Category: defaulted(req.Category, defaultGameCategory),
IconURL: iconURL,
CoverURL: coverURL,
LaunchMode: defaulted(req.LaunchMode, defaultLaunchMode),
Orientation: defaultOrientation,
MinCoin: req.MinCoin,
Status: defaulted(req.Status, defaultGameStatus),
SortOrder: int32((index + 1) * 10),
Tags: compactTags(append([]string{strings.TrimSpace(platformCode), adapterZeeOneV1}, req.Tags...)),
})
}
return items, gameURLs
}
func zeeoneGameNameFromURL(raw string) string {
parsed, err := url.Parse(strings.TrimSpace(raw))
pathValue := strings.TrimSpace(raw)
if err == nil && parsed.Path != "" {
pathValue = parsed.Path
}
parts := strings.Split(strings.Trim(pathValue, "/"), "/")
candidate := ""
for index := len(parts) - 1; index >= 0; index-- {
part := strings.TrimSpace(parts[index])
if part == "" || strings.EqualFold(part, "index.html") || strings.EqualFold(part, "index.htm") {
continue
}
candidate = strings.TrimSuffix(strings.TrimSuffix(part, ".html"), ".htm")
break
}
return titleGameName(candidate)
}
func titleGameName(value string) string {
tokens := splitGameNameTokens(value)
if len(tokens) == 0 {
return ""
}
smallWords := map[string]struct{}{"and": {}, "of": {}, "the": {}}
words := make([]string, 0, len(tokens))
for _, token := range tokens {
lower := strings.ToLower(token)
if _, err := strconv.Atoi(lower); err == nil {
continue
}
if len(words) > 0 {
if _, ok := smallWords[lower]; ok {
words = append(words, lower)
continue
}
}
words = append(words, strings.ToUpper(lower[:1])+lower[1:])
}
return strings.Join(words, " ")
}
func splitGameNameTokens(value string) []string {
var tokens []string
var builder strings.Builder
var previous rune
flush := func() {
if builder.Len() > 0 {
tokens = append(tokens, builder.String())
builder.Reset()
}
}
for _, r := range strings.TrimSpace(value) {
if !asciiAlphaNumeric(r) {
flush()
previous = 0
continue
}
if builder.Len() > 0 && asciiUpper(r) && (asciiLower(previous) || asciiDigit(previous)) {
flush()
}
builder.WriteRune(r)
previous = r
}
flush()
return tokens
}
func asciiAlphaNumeric(r rune) bool {
return asciiLower(r) || asciiUpper(r) || asciiDigit(r)
}
func asciiLower(r rune) bool {
return r >= 'a' && r <= 'z'
}
func asciiUpper(r rune) bool {
return r >= 'A' && r <= 'Z'
}
func asciiDigit(r rune) bool {
return r >= '0' && r <= '9'
}
func fetchBaishunGameSyncPlan(ctx context.Context, platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) {
config, err := decodeBaishunSyncConfig(platform.GetAdapterConfigJson())
if err != nil {
return providerGameSyncPlan{}, err
}
appID := config.appID()
appChannel := config.appChannel()
appKey := strings.TrimSpace(platform.GetCallbackSecret())
endpoint := baishunGameListEndpoint(platform.GetApiBaseUrl(), config)
if missing := missingBaishunSyncFields(appID, appChannel, appKey, endpoint); len(missing) > 0 {
return providerGameSyncPlan{}, fmt.Errorf("百顺列表同步缺少配置: %s", strings.Join(missing, "、"))
}
body := baishunSignedListRequest(appID, appChannel, baishunGameListType(config, req), appKey)
raw, err := json.Marshal(body)
if err != nil {
return providerGameSyncPlan{}, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(raw))
if err != nil {
return providerGameSyncPlan{}, err
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(httpReq)
if err != nil {
return providerGameSyncPlan{}, fmt.Errorf("请求百顺游戏列表失败: %w", err)
}
defer resp.Body.Close()
respRaw, err := io.ReadAll(resp.Body)
if err != nil {
return providerGameSyncPlan{}, fmt.Errorf("读取百顺游戏列表失败: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return providerGameSyncPlan{}, fmt.Errorf("百顺游戏列表 HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respRaw)))
}
var list baishunGameListResponse
if err := json.Unmarshal(respRaw, &list); err != nil {
return providerGameSyncPlan{}, fmt.Errorf("解析百顺游戏列表失败: %w", err)
}
if list.Code != 0 {
return providerGameSyncPlan{}, fmt.Errorf("百顺游戏列表返回失败: code=%d message=%s", list.Code, firstNonEmpty(list.Message, list.Msg))
}
games, gameURLs := baishunCatalogItems(platform.GetPlatformCode(), list.Data, req)
if len(games) == 0 {
return providerGameSyncPlan{}, fmt.Errorf("百顺游戏列表为空")
}
return providerGameSyncPlan{
Games: games,
GameURLs: gameURLs,
}, nil
}
func decodeBaishunSyncConfig(raw string) (baishunSyncConfig, error) {
if strings.TrimSpace(raw) == "" {
return baishunSyncConfig{}, nil
}
var config baishunSyncConfig
if err := json.Unmarshal([]byte(raw), &config); err != nil {
return baishunSyncConfig{}, fmt.Errorf("百顺适配器配置 JSON 不合法")
}
return config, nil
}
func missingBaishunSyncFields(appID int64, appChannel string, appKey string, endpoint string) []string {
missing := make([]string, 0, 4)
if appID == 0 {
missing = append(missing, "adapterConfigJson.app_id")
}
if strings.TrimSpace(appChannel) == "" {
missing = append(missing, "adapterConfigJson.app_channel")
}
if strings.TrimSpace(appKey) == "" {
missing = append(missing, "回调密钥")
}
if strings.TrimSpace(endpoint) == "" {
missing = append(missing, "adapterConfigJson.game_list_url 或 provider_api_base_url")
}
return missing
}
func (c baishunSyncConfig) appID() int64 {
if c.AppID != 0 {
return c.AppID
}
return c.AppIDCamel
}
func (c baishunSyncConfig) appChannel() string {
return firstNonEmpty(c.AppChannel, c.AppChannelCamel)
}
func baishunGameListType(config baishunSyncConfig, req syncGamesRequest) int {
if req.GameListType != 0 {
return req.GameListType
}
if config.GameListType != 0 {
return config.GameListType
}
if config.GameListTypeCamel != 0 {
return config.GameListTypeCamel
}
return 1
}
func baishunGameListEndpoint(apiBaseURL string, config baishunSyncConfig) string {
if endpoint := strings.TrimSpace(firstNonEmpty(config.GameListURL, config.GameListURLCamel)); endpoint != "" {
return endpoint
}
base := strings.TrimSpace(firstNonEmpty(config.ProviderAPIBaseURL, config.ProviderAPIBaseURLAlt, apiBaseURL))
if base == "" {
return ""
}
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
base = "https://" + base
}
if strings.Contains(base, "/v1/api/gamelist") {
return base
}
parsed, err := url.Parse(base)
if err != nil || parsed.Host == "" {
return ""
}
parsed.Path = strings.TrimRight(parsed.Path, "/") + "/v1/api/gamelist"
parsed.RawQuery = ""
return parsed.String()
}
func baishunSignedListRequest(appID int64, appChannel string, gameListType int, appKey string) map[string]any {
timestamp := time.Now().Unix()
nonce := randomHex(8)
signature := baishunMD5(nonce + appKey + strconv.FormatInt(timestamp, 10))
return map[string]any{
"game_list_type": gameListType,
"app_channel": appChannel,
"app_id": appID,
"signature": signature,
"signature_nonce": nonce,
"timestamp": timestamp,
}
}
type baishunGameListResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Msg string `json:"msg"`
Data []baishunGameListItem `json:"data"`
}
type baishunGameListItem struct {
GameID any `json:"game_id"`
Name string `json:"name"`
GameName string `json:"game_name"`
PreviewURL string `json:"preview_url"`
IconURL string `json:"icon_url"`
CoverURL string `json:"cover_url"`
DownloadURL string `json:"download_url"`
GameOrientation any `json:"game_orientation"`
}
func baishunCatalogItems(platformCode string, providerItems []baishunGameListItem, req syncGamesRequest) ([]catalogRequest, map[string]string) {
items := make([]catalogRequest, 0, len(providerItems))
gameURLs := make(map[string]string, len(providerItems))
for index, providerItem := range providerItems {
providerGameID := stringFromJSONValue(providerItem.GameID)
if providerGameID == "" {
continue
}
name := firstNonEmpty(providerItem.Name, providerItem.GameName, providerGameID)
iconURL := firstNonEmpty(providerItem.IconURL, providerItem.PreviewURL)
coverURL := firstNonEmpty(providerItem.CoverURL, providerItem.PreviewURL, iconURL)
gameURLs[providerGameID] = strings.TrimSpace(providerItem.DownloadURL)
items = append(items, catalogRequest{
GameID: stableGameID(platformCode, providerGameID),
PlatformCode: strings.TrimSpace(platformCode),
ProviderGameID: providerGameID,
GameName: name,
Category: defaulted(req.Category, defaultGameCategory),
IconURL: iconURL,
CoverURL: coverURL,
LaunchMode: defaulted(req.LaunchMode, defaultLaunchMode),
Orientation: baishunOrientation(providerItem.GameOrientation),
MinCoin: req.MinCoin,
Status: defaulted(req.Status, defaultGameStatus),
SortOrder: int32((index + 1) * 10),
Tags: compactTags(append([]string{strings.TrimSpace(platformCode), adapterBaishunV1}, req.Tags...)),
})
}
return items, gameURLs
}
func selectProviderGames(games []catalogRequest, providerGameIDs []string) []catalogRequest {
if len(providerGameIDs) == 0 {
return games
}
allowed := make(map[string]struct{}, len(providerGameIDs))
for _, id := range providerGameIDs {
if strings.TrimSpace(id) != "" {
allowed[strings.TrimSpace(id)] = struct{}{}
}
}
out := make([]catalogRequest, 0, len(games))
for _, game := range games {
if _, ok := allowed[game.ProviderGameID]; ok {
out = append(out, game)
}
}
return out
}
func selectedGameURLs(all map[string]string, games []catalogRequest) map[string]string {
out := make(map[string]string, len(games))
for _, game := range games {
if launchURL := strings.TrimSpace(all[game.ProviderGameID]); launchURL != "" {
out[game.ProviderGameID] = launchURL
}
}
return out
}
func mergeAdapterGameURLs(raw string, gameURLs map[string]string) string {
config := map[string]any{}
if strings.TrimSpace(raw) != "" {
_ = json.Unmarshal([]byte(raw), &config)
}
current := map[string]any{}
if existing, ok := config["game_urls"].(map[string]any); ok {
for key, value := range existing {
current[key] = value
}
}
for providerGameID, launchURL := range gameURLs {
if strings.TrimSpace(providerGameID) != "" && strings.TrimSpace(launchURL) != "" {
current[providerGameID] = strings.TrimSpace(launchURL)
}
}
config["game_urls"] = current
out, err := json.Marshal(config)
if err != nil {
return strings.TrimSpace(raw)
}
return string(out)
}
func stableGameID(platformCode string, providerGameID string) string {
prefix := asciiIDPart(platformCode)
suffix := asciiIDPart(providerGameID)
if prefix == "" {
prefix = "game"
}
if suffix == "" {
suffix = "unknown"
}
value := prefix + "_" + suffix
if len(value) <= 96 {
return value
}
sum := sha256.Sum256([]byte(platformCode + ":" + providerGameID))
return prefix[:min(len(prefix), 32)] + "_" + hex.EncodeToString(sum[:16])
}
func asciiIDPart(value string) string {
var builder strings.Builder
lastUnderscore := false
for _, r := range strings.ToLower(strings.TrimSpace(value)) {
keep := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
if keep {
builder.WriteRune(r)
lastUnderscore = false
continue
}
if !lastUnderscore {
builder.WriteByte('_')
lastUnderscore = true
}
}
return strings.Trim(builder.String(), "_")
}
func baishunOrientation(value any) string {
switch strings.TrimSpace(stringFromJSONValue(value)) {
case "2", "landscape":
return "landscape"
default:
return defaultOrientation
}
}
func stringFromJSONValue(value any) string {
switch typed := value.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(typed)
case float64:
if typed == float64(int64(typed)) {
return strconv.FormatInt(int64(typed), 10)
}
return strconv.FormatFloat(typed, 'f', -1, 64)
case json.Number:
return typed.String()
default:
return strings.TrimSpace(fmt.Sprint(typed))
}
}
func baishunMD5(value string) string {
sum := md5.Sum([]byte(value))
return hex.EncodeToString(sum[:])
}
func randomHex(length int) string {
buf := make([]byte, length)
if _, err := rand.Read(buf); err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 16)
}
return hex.EncodeToString(buf)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func defaulted(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return strings.TrimSpace(value)
}

View File

@ -0,0 +1,134 @@
package gamemanagement
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
gamev1 "hyapp.local/api/proto/game/v1"
)
func TestFetchBaishunGameSyncPlanFetchesSignsAndFormatsCatalog(t *testing.T) {
const appKey = "baishun-test-key"
var receivedPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedPath = r.URL.Path
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
nonce := stringFromJSONValue(body["signature_nonce"])
timestamp := stringFromJSONValue(body["timestamp"])
if got, want := body["signature"], baishunMD5(nonce+appKey+timestamp); got != want {
t.Fatalf("signature = %v, want %s body=%+v", got, want, body)
}
if body["app_channel"] != "test-channel" || stringFromJSONValue(body["app_id"]) != "21397507" || stringFromJSONValue(body["game_list_type"]) != "1" {
t.Fatalf("request body mismatch: %+v", body)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"message":"ok","data":[{"game_id":1006,"name":"Gold of Test","preview_url":"https://cdn.test/1006.png","download_url":"https://h5.test/1006/index.html","game_orientation":2}]}`))
}))
defer server.Close()
plan, err := fetchBaishunGameSyncPlan(t.Context(), &gamev1.GamePlatform{
PlatformCode: "baishun",
AdapterType: adapterBaishunV1,
CallbackSecret: appKey,
AdapterConfigJson: `{
"app_id": 21397507,
"app_channel": "test-channel",
"provider_api_base_url": ` + strconv.Quote(server.URL) + `
}`,
}, syncGamesRequest{})
if err != nil {
t.Fatalf("fetchBaishunGameSyncPlan failed: %v", err)
}
if receivedPath != "/v1/api/gamelist" {
t.Fatalf("path = %q, want /v1/api/gamelist", receivedPath)
}
if len(plan.Games) != 1 {
t.Fatalf("games len = %d, want 1", len(plan.Games))
}
game := plan.Games[0]
if game.GameID != "baishun_1006" || game.ProviderGameID != "1006" || game.GameName != "Gold of Test" {
t.Fatalf("catalog identity mismatch: %+v", game)
}
if game.IconURL != "https://cdn.test/1006.png" || game.CoverURL != "https://cdn.test/1006.png" || game.Orientation != "landscape" || game.Status != "disabled" || game.LaunchMode != "full_screen" {
t.Fatalf("catalog presentation mismatch: %+v", game)
}
var merged struct {
GameURLs map[string]string `json:"game_urls"`
}
if err := json.Unmarshal([]byte(mergeAdapterGameURLs(`{}`, selectedGameURLs(plan.GameURLs, plan.Games))), &merged); err != nil {
t.Fatalf("decode merged config: %v", err)
}
if merged.GameURLs["1006"] != "https://h5.test/1006/index.html" {
t.Fatalf("merged game url = %q", merged.GameURLs["1006"])
}
}
func TestFetchZeeOneGameSyncPlanReadsConfiguredGameURLs(t *testing.T) {
plan, err := fetchProviderGameSyncPlan(t.Context(), &gamev1.GamePlatform{
PlatformCode: "zeeone",
AdapterType: adapterZeeOneV1,
AdapterConfigJson: `{
"game_urls": {
"1028": "https://dx3kkv99b7clg.cloudfront.net/test/1028_starShinePrincess/index.html",
"1021": "https://dx3kkv99b7clg.cloudfront.net/test/1021_gold_of_olympus/index.html"
}
}`,
}, syncGamesRequest{})
if err != nil {
t.Fatalf("fetchProviderGameSyncPlan failed: %v", err)
}
if len(plan.Games) != 2 {
t.Fatalf("games len = %d, want 2", len(plan.Games))
}
first := plan.Games[0]
if first.GameID != "zeeone_1021" || first.ProviderGameID != "1021" || first.GameName != "Gold of Olympus" {
t.Fatalf("first game mismatch: %+v", first)
}
second := plan.Games[1]
if second.GameID != "zeeone_1028" || second.ProviderGameID != "1028" || second.GameName != "Star Shine Princess" {
t.Fatalf("second game mismatch: %+v", second)
}
if plan.GameURLs["1021"] != "https://dx3kkv99b7clg.cloudfront.net/test/1021_gold_of_olympus/index.html" {
t.Fatalf("game url mismatch: %+v", plan.GameURLs)
}
if first.Status != "disabled" || first.LaunchMode != "full_screen" || first.Orientation != "portrait" {
t.Fatalf("default fields mismatch: %+v", first)
}
}
func TestSelectProviderGamesKeepsOnlySelectedIDs(t *testing.T) {
games := []catalogRequest{
{ProviderGameID: "1001", GameName: "one"},
{ProviderGameID: "1002", GameName: "two"},
}
selected := selectProviderGames(games, []string{"1002"})
if len(selected) != 1 || selected[0].ProviderGameID != "1002" {
t.Fatalf("selected games mismatch: %+v", selected)
}
}
func TestMissingBaishunSyncFieldsNamesExactInputs(t *testing.T) {
missing := missingBaishunSyncFields(0, "", "", "")
want := []string{
"adapterConfigJson.app_id",
"adapterConfigJson.app_channel",
"回调密钥",
"adapterConfigJson.game_list_url 或 provider_api_base_url",
}
if len(missing) != len(want) {
t.Fatalf("missing len = %d, want %d: %+v", len(missing), len(want), missing)
}
for index := range want {
if missing[index] != want[index] {
t.Fatalf("missing[%d] = %q, want %q", index, missing[index], want[index])
}
}
}

View File

@ -24,6 +24,7 @@ func New(activity activityclient.Client, audit shared.OperationLogger) *Handler
}
type configRequest struct {
PoolID string `json:"pool_id"`
Enabled bool `json:"enabled"`
GiftPrice int64 `json:"gift_price"`
TargetRTPPPM int64 `json:"target_rtp_ppm"`
@ -55,6 +56,7 @@ type configRequest struct {
ActivityBudget int64 `json:"activity_budget"`
ActivityDailyLimit int64 `json:"activity_daily_limit"`
LargeTierEnabled bool `json:"large_tier_enabled"`
MultiplierPPMs []int64 `json:"multiplier_ppms"`
Tiers []tierDTO `json:"tiers"`
}
@ -72,6 +74,7 @@ type tierDTO struct {
Pool string `json:"pool"`
TierID string `json:"tier_id"`
RewardCoins int64 `json:"reward_coins"`
MultiplierPPM int64 `json:"multiplier_ppm"`
Weight int64 `json:"weight"`
HighWaterOnly bool `json:"high_water_only"`
Enabled bool `json:"enabled"`
@ -80,6 +83,7 @@ type tierDTO struct {
type drawDTO struct {
DrawID string `json:"draw_id"`
CommandID string `json:"command_id"`
PoolID string `json:"pool_id"`
GiftID string `json:"gift_id"`
RuleVersion int64 `json:"rule_version"`
ExperiencePool string `json:"experience_pool"`
@ -99,9 +103,26 @@ type drawDTO struct {
CreatedAtMS int64 `json:"created_at_ms"`
}
type drawSummaryDTO struct {
PoolID string `json:"pool_id"`
TotalDraws int64 `json:"total_draws"`
UniqueUsers int64 `json:"unique_users"`
UniqueRooms int64 `json:"unique_rooms"`
TotalSpentCoins int64 `json:"total_spent_coins"`
TotalRewardCoins int64 `json:"total_reward_coins"`
BaseRewardCoins int64 `json:"base_reward_coins"`
RoomAtmosphereRewardCoins int64 `json:"room_atmosphere_reward_coins"`
ActivitySubsidyCoins int64 `json:"activity_subsidy_coins"`
ActualRTPPPM int64 `json:"actual_rtp_ppm"`
PendingDraws int64 `json:"pending_draws"`
GrantedDraws int64 `json:"granted_draws"`
FailedDraws int64 `json:"failed_draws"`
}
func (h *Handler) GetLuckyGiftConfig(c *gin.Context) {
resp, err := h.activity.GetLuckyGiftConfig(c.Request.Context(), &activityv1.GetLuckyGiftConfigRequest{
Meta: h.meta(c),
Meta: h.meta(c),
PoolId: strings.TrimSpace(c.Query("pool_id")),
})
if err != nil {
response.ServerError(c, "获取幸运礼物配置失败")
@ -116,6 +137,9 @@ func (h *Handler) UpsertLuckyGiftConfig(c *gin.Context) {
response.BadRequest(c, "幸运礼物配置参数不正确")
return
}
if req.PoolID == "" {
req.PoolID = strings.TrimSpace(c.Query("pool_id"))
}
resp, err := h.activity.UpsertLuckyGiftConfig(c.Request.Context(), &activityv1.UpsertLuckyGiftConfigRequest{
Meta: h.meta(c),
Config: configToProto(req),
@ -126,14 +150,30 @@ func (h *Handler) UpsertLuckyGiftConfig(c *gin.Context) {
return
}
item := configFromProto(resp.GetConfig())
shared.OperationLogWithResourceID(c, h.audit, "upsert-lucky-gift-config", "lucky_gift_rules", "global", "success", "")
shared.OperationLogWithResourceID(c, h.audit, "upsert-lucky-gift-config", "lucky_gift_rules", item.PoolID, "success", "")
response.OK(c, item)
}
func (h *Handler) ListLuckyGiftConfigs(c *gin.Context) {
resp, err := h.activity.ListLuckyGiftConfigs(c.Request.Context(), &activityv1.ListLuckyGiftConfigsRequest{
Meta: h.meta(c),
})
if err != nil {
response.ServerError(c, "获取幸运礼物奖池列表失败")
return
}
items := make([]configDTO, 0, len(resp.GetConfigs()))
for _, config := range resp.GetConfigs() {
items = append(items, configFromProto(config))
}
response.OK(c, items)
}
func (h *Handler) ListLuckyGiftDraws(c *gin.Context) {
options := shared.ListOptions(c)
resp, err := h.activity.ListLuckyGiftDraws(c.Request.Context(), &activityv1.ListLuckyGiftDrawsRequest{
Meta: h.meta(c),
PoolId: strings.TrimSpace(c.Query("pool_id")),
GiftId: strings.TrimSpace(c.Query("gift_id")),
UserId: parseOptionalInt64(c.Query("user_id")),
RoomId: strings.TrimSpace(c.Query("room_id")),
@ -152,6 +192,22 @@ func (h *Handler) ListLuckyGiftDraws(c *gin.Context) {
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
func (h *Handler) GetLuckyGiftDrawSummary(c *gin.Context) {
resp, err := h.activity.GetLuckyGiftDrawSummary(c.Request.Context(), &activityv1.GetLuckyGiftDrawSummaryRequest{
Meta: h.meta(c),
PoolId: strings.TrimSpace(c.Query("pool_id")),
GiftId: strings.TrimSpace(c.Query("gift_id")),
UserId: parseOptionalInt64(c.Query("user_id")),
RoomId: strings.TrimSpace(c.Query("room_id")),
Status: strings.TrimSpace(c.Query("status")),
})
if err != nil {
response.ServerError(c, "获取幸运礼物抽奖汇总失败")
return
}
response.OK(c, drawSummaryFromProto(resp.GetSummary()))
}
func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta {
return &activityv1.RequestMeta{
RequestId: middleware.CurrentRequestID(c),
@ -168,12 +224,14 @@ func configToProto(req configRequest) *activityv1.LuckyGiftConfig {
Pool: tier.Pool,
TierId: tier.TierID,
RewardCoins: tier.RewardCoins,
MultiplierPpm: tier.MultiplierPPM,
Weight: tier.Weight,
HighWaterOnly: tier.HighWaterOnly,
Enabled: tier.Enabled,
})
}
return &activityv1.LuckyGiftConfig{
PoolId: strings.TrimSpace(req.PoolID),
Enabled: req.Enabled,
GiftPrice: req.GiftPrice,
TargetRtpPpm: req.TargetRTPPPM,
@ -205,6 +263,7 @@ func configToProto(req configRequest) *activityv1.LuckyGiftConfig {
ActivityBudget: req.ActivityBudget,
ActivityDailyLimit: req.ActivityDailyLimit,
LargeTierEnabled: req.LargeTierEnabled,
MultiplierPpms: append([]int64(nil), req.MultiplierPPMs...),
Tiers: tiers,
}
}
@ -219,11 +278,16 @@ func configFromProto(config *activityv1.LuckyGiftConfig) configDTO {
Pool: tier.GetPool(),
TierID: tier.GetTierId(),
RewardCoins: tier.GetRewardCoins(),
MultiplierPPM: tier.GetMultiplierPpm(),
Weight: tier.GetWeight(),
HighWaterOnly: tier.GetHighWaterOnly(),
Enabled: tier.GetEnabled(),
})
}
poolID := strings.TrimSpace(config.GetPoolId())
if poolID == "" {
poolID = strings.TrimSpace(config.GetGiftId())
}
return configDTO{
AppCode: config.GetAppCode(),
GiftID: config.GetGiftId(),
@ -232,6 +296,7 @@ func configFromProto(config *activityv1.LuckyGiftConfig) configDTO {
CreatedAtMS: config.GetCreatedAtMs(),
UpdatedAtMS: config.GetUpdatedAtMs(),
configRequest: configRequest{
PoolID: poolID,
Enabled: config.GetEnabled(),
GiftPrice: config.GetGiftPrice(),
TargetRTPPPM: config.GetTargetRtpPpm(),
@ -263,6 +328,7 @@ func configFromProto(config *activityv1.LuckyGiftConfig) configDTO {
ActivityBudget: config.GetActivityBudget(),
ActivityDailyLimit: config.GetActivityDailyLimit(),
LargeTierEnabled: config.GetLargeTierEnabled(),
MultiplierPPMs: append([]int64(nil), config.GetMultiplierPpms()...),
Tiers: tiers,
},
}
@ -275,6 +341,7 @@ func drawFromProto(draw *activityv1.LuckyGiftDrawResult) drawDTO {
return drawDTO{
DrawID: draw.GetDrawId(),
CommandID: draw.GetCommandId(),
PoolID: draw.GetPoolId(),
GiftID: draw.GetGiftId(),
RuleVersion: draw.GetRuleVersion(),
ExperiencePool: draw.GetExperiencePool(),
@ -295,6 +362,27 @@ func drawFromProto(draw *activityv1.LuckyGiftDrawResult) drawDTO {
}
}
func drawSummaryFromProto(summary *activityv1.LuckyGiftDrawSummary) drawSummaryDTO {
if summary == nil {
return drawSummaryDTO{}
}
return drawSummaryDTO{
PoolID: summary.GetPoolId(),
TotalDraws: summary.GetTotalDraws(),
UniqueUsers: summary.GetUniqueUsers(),
UniqueRooms: summary.GetUniqueRooms(),
TotalSpentCoins: summary.GetTotalSpentCoins(),
TotalRewardCoins: summary.GetTotalRewardCoins(),
BaseRewardCoins: summary.GetBaseRewardCoins(),
RoomAtmosphereRewardCoins: summary.GetRoomAtmosphereRewardCoins(),
ActivitySubsidyCoins: summary.GetActivitySubsidyCoins(),
ActualRTPPPM: summary.GetActualRtpPpm(),
PendingDraws: summary.GetPendingDraws(),
GrantedDraws: summary.GetGrantedDraws(),
FailedDraws: summary.GetFailedDraws(),
}
}
func parseOptionalInt64(value string) int64 {
value = strings.TrimSpace(value)
if value == "" {

View File

@ -13,5 +13,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/admin/activity/lucky-gifts/config", middleware.RequirePermission("lucky-gift:view"), h.GetLuckyGiftConfig)
protected.PUT("/admin/activity/lucky-gifts/config", middleware.RequirePermission("lucky-gift:update"), h.UpsertLuckyGiftConfig)
protected.GET("/admin/activity/lucky-gifts/configs", middleware.RequirePermission("lucky-gift:view"), h.ListLuckyGiftConfigs)
protected.GET("/admin/activity/lucky-gifts/draws", middleware.RequirePermission("lucky-gift:view"), h.ListLuckyGiftDraws)
protected.GET("/admin/activity/lucky-gifts/draw-summary", middleware.RequirePermission("lucky-gift:view"), h.GetLuckyGiftDrawSummary)
}

View File

@ -0,0 +1,254 @@
package redpacket
import (
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/gin-gonic/gin"
)
type Handler struct {
wallet walletclient.Client
audit shared.OperationLogger
}
func New(wallet walletclient.Client, audit shared.OperationLogger) *Handler {
return &Handler{wallet: wallet, audit: audit}
}
type configRequest struct {
Enabled bool `json:"enabled"`
CountTiers []int32 `json:"count_tiers"`
AmountTiers []int64 `json:"amount_tiers"`
DelayedOpenSeconds int32 `json:"delayed_open_seconds"`
ExpireSeconds int32 `json:"expire_seconds"`
DailySendLimit int32 `json:"daily_send_limit"`
}
type configDTO struct {
AppCode string `json:"app_code"`
Enabled bool `json:"enabled"`
CountTiers []int32 `json:"count_tiers"`
AmountTiers []int64 `json:"amount_tiers"`
DelayedOpenSeconds int32 `json:"delayed_open_seconds"`
ExpireSeconds int32 `json:"expire_seconds"`
DailySendLimit int32 `json:"daily_send_limit"`
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type packetDTO struct {
AppCode string `json:"app_code"`
PacketID string `json:"packet_id"`
CommandID string `json:"command_id"`
SenderUserID int64 `json:"sender_user_id"`
RoomID string `json:"room_id"`
RegionID int64 `json:"region_id"`
PacketType string `json:"packet_type"`
TotalAmount int64 `json:"total_amount"`
PacketCount int32 `json:"packet_count"`
RemainingAmount int64 `json:"remaining_amount"`
RemainingCount int32 `json:"remaining_count"`
Status string `json:"status"`
OpenAtMS int64 `json:"open_at_ms"`
ExpiresAtMS int64 `json:"expires_at_ms"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
Claims []claimDTO `json:"claims,omitempty"`
}
type claimDTO struct {
AppCode string `json:"app_code"`
ClaimID string `json:"claim_id"`
CommandID string `json:"command_id"`
PacketID string `json:"packet_id"`
UserID int64 `json:"user_id"`
Amount int64 `json:"amount"`
WalletTransactionID string `json:"wallet_transaction_id"`
Status string `json:"status"`
FailureReason string `json:"failure_reason"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
func (h *Handler) GetConfig(c *gin.Context) {
if h.wallet == nil {
response.ServerError(c, "钱包服务未配置")
return
}
resp, err := h.wallet.GetRedPacketConfig(c.Request.Context(), &walletv1.GetRedPacketConfigRequest{
AppCode: appctx.FromContext(c.Request.Context()),
})
if err != nil {
response.ServerError(c, "获取红包配置失败")
return
}
response.OK(c, configFromProto(resp.GetConfig()))
}
func (h *Handler) UpdateConfig(c *gin.Context) {
if h.wallet == nil {
response.ServerError(c, "钱包服务未配置")
return
}
var req configRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "红包配置参数不正确")
return
}
resp, err := h.wallet.UpdateRedPacketConfig(c.Request.Context(), &walletv1.UpdateRedPacketConfigRequest{
AppCode: appctx.FromContext(c.Request.Context()),
Enabled: req.Enabled,
CountTiers: req.CountTiers,
AmountTiers: req.AmountTiers,
DelayedOpenSeconds: req.DelayedOpenSeconds,
ExpireSeconds: req.ExpireSeconds,
DailySendLimit: req.DailySendLimit,
OperatorUserId: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := configFromProto(resp.GetConfig())
shared.OperationLogWithResourceID(c, h.audit, "update-red-packet-config", "red_packet_configs", item.AppCode, "success", "")
response.OK(c, item)
}
func (h *Handler) ListPackets(c *gin.Context) {
if h.wallet == nil {
response.ServerError(c, "钱包服务未配置")
return
}
options := shared.ListOptions(c)
req := &walletv1.ListRedPacketsRequest{
AppCode: appctx.FromContext(c.Request.Context()),
RoomId: strings.TrimSpace(c.Query("room_id")),
PacketType: strings.TrimSpace(c.Query("packet_type")),
Status: strings.TrimSpace(options.Status),
Page: int32(options.Page),
PageSize: int32(options.PageSize),
SenderUserId: parseInt64Query(c, "sender_user_id"),
RegionId: parseInt64Query(c, "region_id"),
StartAtMs: parseInt64Query(c, "start_at_ms"),
EndAtMs: parseInt64Query(c, "end_at_ms"),
}
resp, err := h.wallet.ListRedPackets(c.Request.Context(), req)
if err != nil {
response.ServerError(c, "获取红包列表失败")
return
}
items := make([]packetDTO, 0, len(resp.GetPackets()))
for _, packet := range resp.GetPackets() {
items = append(items, packetFromProto(packet))
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
func (h *Handler) GetPacket(c *gin.Context) {
if h.wallet == nil {
response.ServerError(c, "钱包服务未配置")
return
}
packetID := strings.TrimSpace(c.Param("packet_id"))
if packetID == "" {
response.BadRequest(c, "红包 ID 不正确")
return
}
resp, err := h.wallet.GetRedPacket(c.Request.Context(), &walletv1.GetRedPacketRequest{
AppCode: appctx.FromContext(c.Request.Context()),
PacketId: packetID,
IncludeClaims: true,
})
if err != nil {
response.ServerError(c, "获取红包详情失败")
return
}
response.OK(c, packetFromProto(resp.GetPacket()))
}
func configFromProto(config *walletv1.RedPacketConfig) configDTO {
if config == nil {
return configDTO{}
}
return configDTO{
AppCode: config.GetAppCode(),
Enabled: config.GetEnabled(),
CountTiers: append([]int32(nil), config.GetCountTiers()...),
AmountTiers: append([]int64(nil), config.GetAmountTiers()...),
DelayedOpenSeconds: config.GetDelayedOpenSeconds(),
ExpireSeconds: config.GetExpireSeconds(),
DailySendLimit: config.GetDailySendLimit(),
UpdatedByAdminID: config.GetUpdatedByAdminId(),
CreatedAtMS: config.GetCreatedAtMs(),
UpdatedAtMS: config.GetUpdatedAtMs(),
}
}
func packetFromProto(packet *walletv1.RedPacket) packetDTO {
if packet == nil {
return packetDTO{}
}
claims := make([]claimDTO, 0, len(packet.GetClaims()))
for _, claim := range packet.GetClaims() {
claims = append(claims, claimFromProto(claim))
}
return packetDTO{
AppCode: packet.GetAppCode(),
PacketID: packet.GetPacketId(),
CommandID: packet.GetCommandId(),
SenderUserID: packet.GetSenderUserId(),
RoomID: packet.GetRoomId(),
RegionID: packet.GetRegionId(),
PacketType: packet.GetPacketType(),
TotalAmount: packet.GetTotalAmount(),
PacketCount: packet.GetPacketCount(),
RemainingAmount: packet.GetRemainingAmount(),
RemainingCount: packet.GetRemainingCount(),
Status: packet.GetStatus(),
OpenAtMS: packet.GetOpenAtMs(),
ExpiresAtMS: packet.GetExpiresAtMs(),
CreatedAtMS: packet.GetCreatedAtMs(),
UpdatedAtMS: packet.GetUpdatedAtMs(),
Claims: claims,
}
}
func claimFromProto(claim *walletv1.RedPacketClaim) claimDTO {
if claim == nil {
return claimDTO{}
}
return claimDTO{
AppCode: claim.GetAppCode(),
ClaimID: claim.GetClaimId(),
CommandID: claim.GetCommandId(),
PacketID: claim.GetPacketId(),
UserID: claim.GetUserId(),
Amount: claim.GetAmount(),
WalletTransactionID: claim.GetWalletTransactionId(),
Status: claim.GetStatus(),
FailureReason: claim.GetFailureReason(),
CreatedAtMS: claim.GetCreatedAtMs(),
UpdatedAtMS: claim.GetUpdatedAtMs(),
}
}
func parseInt64Query(c *gin.Context, key string) int64 {
value := strings.TrimSpace(c.Query(key))
if value == "" {
return 0
}
parsed, _ := strconv.ParseInt(value, 10, 64)
if parsed < 0 {
return 0
}
return parsed
}

View File

@ -0,0 +1,17 @@
package redpacket
import (
"hyapp-admin-server/internal/middleware"
"github.com/gin-gonic/gin"
)
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
if h == nil {
return
}
protected.GET("/admin/activity/red-packets/config", middleware.RequirePermission("red-packet:view"), h.GetConfig)
protected.PUT("/admin/activity/red-packets/config", middleware.RequirePermission("red-packet:update"), h.UpdateConfig)
protected.GET("/admin/activity/red-packets", middleware.RequirePermission("red-packet:view"), h.ListPackets)
protected.GET("/admin/activity/red-packets/:packet_id", middleware.RequirePermission("red-packet:view"), h.GetPacket)
}

View File

@ -14,6 +14,10 @@ type resourceDTO struct {
GrantStrategy string `json:"grantStrategy"`
WalletAssetType string `json:"walletAssetType"`
WalletAssetAmount int64 `json:"walletAssetAmount"`
PriceType string `json:"priceType"`
CoinPrice int64 `json:"coinPrice"`
GiftPointAmount int64 `json:"giftPointAmount"`
BadgeForm string `json:"badgeForm,omitempty"`
UsageScopes []string `json:"usageScopes"`
AssetURL string `json:"assetUrl"`
PreviewURL string `json:"previewUrl"`
@ -172,6 +176,10 @@ func resourceFromProto(item *walletv1.Resource) resourceDTO {
GrantStrategy: item.GetGrantStrategy(),
WalletAssetType: item.GetWalletAssetType(),
WalletAssetAmount: item.GetWalletAssetAmount(),
PriceType: item.GetPriceType(),
CoinPrice: item.GetCoinPrice(),
GiftPointAmount: item.GetGiftPointAmount(),
BadgeForm: badgeFormForResource(item.GetResourceType(), item.GetMetadataJson()),
UsageScopes: item.GetUsageScopes(),
AssetURL: item.GetAssetUrl(),
PreviewURL: item.GetPreviewUrl(),

View File

@ -74,6 +74,14 @@ func (h *Handler) CreateResource(c *gin.Context) {
response.BadRequest(c, "资源参数不正确")
return
}
if err := validateResourcePricing(req); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := validateResourceBadgeForm(req); err != nil {
response.BadRequest(c, err.Error())
return
}
resp, err := h.wallet.CreateResource(c.Request.Context(), req.createProto(c))
if err != nil {
response.BadRequest(c, err.Error())
@ -170,6 +178,14 @@ func (h *Handler) UpdateResource(c *gin.Context) {
response.BadRequest(c, "资源参数不正确")
return
}
if err := validateResourcePricing(req); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := validateResourceBadgeForm(req); err != nil {
response.BadRequest(c, err.Error())
return
}
protoReq := req.updateProto(c, resourceID)
resp, err := h.wallet.UpdateResource(c.Request.Context(), protoReq)
if err != nil {

View File

@ -70,3 +70,29 @@ func TestEmojiPackMetadataKeepsLimitedRegions(t *testing.T) {
t.Fatalf("unexpected region ids: %+v", payload.RegionIDs)
}
}
func TestBadgeResourceMetadataUsesSelectedForm(t *testing.T) {
metadata := resourceMetadataJSON(resourceTypeBadge, "long")
payload := badgeMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("badge metadata json mismatch: %v", err)
}
if payload.BadgeForm != badgeFormStrip || payload.DefaultSlot != "profile_strip" {
t.Fatalf("long badge metadata mismatch: %+v", payload)
}
if got := badgeFormFromMetadata(resourceMetadataJSON(resourceTypeBadge, "short")); got != badgeFormTile {
t.Fatalf("short badge form mismatch: %s", got)
}
}
func TestValidateBadgeResourceRequiresBadgeForm(t *testing.T) {
if err := validateResourceBadgeForm(resourceRequest{ResourceType: resourceTypeBadge}); err == nil {
t.Fatalf("badge form should be required")
}
if err := validateResourceBadgeForm(resourceRequest{ResourceType: resourceTypeBadge, BadgeForm: badgeFormTile}); err != nil {
t.Fatalf("valid badge form rejected: %v", err)
}
if err := validateResourceBadgeForm(resourceRequest{ResourceType: "gift", BadgeForm: ""}); err != nil {
t.Fatalf("non-badge resource should not require badge form: %v", err)
}
}

View File

@ -18,9 +18,20 @@ const dayMillis int64 = 24 * 60 * 60 * 1000
const (
resourceTypeCoin = "coin"
resourceTypeBadge = "badge"
resourceTypeEmojiPack = "emoji_pack"
)
const (
resourcePriceTypeCoin = "coin"
resourcePriceTypeFree = "free"
)
const (
badgeFormStrip = "strip"
badgeFormTile = "tile"
)
const (
defaultEmojiPackCategory = "默认"
emojiPackPricingFree = "free"
@ -33,6 +44,10 @@ type resourceRequest struct {
Name string `json:"name"`
Status string `json:"status"`
Amount int64 `json:"amount"`
PriceType string `json:"priceType"`
CoinPrice int64 `json:"coinPrice"`
GiftPointAmount int64 `json:"giftPointAmount"`
BadgeForm string `json:"badgeForm"`
ManagerGrantEnabled *bool `json:"managerGrantEnabled"`
AssetURL string `json:"assetUrl"`
PreviewURL string `json:"previewUrl"`
@ -57,6 +72,11 @@ type emojiPackMetadataPayload struct {
RegionScope string `json:"region_scope"`
}
type badgeMetadataPayload struct {
BadgeForm string `json:"badge_form"`
DefaultSlot string `json:"default_slot,omitempty"`
}
type resourceGroupRequest struct {
GroupCode string `json:"groupCode"`
Name string `json:"name"`
@ -121,6 +141,8 @@ type grantGroupRequest struct {
func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceRequest {
resourceType := normalizeResourceType(r.ResourceType)
walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount)
priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice)
metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm)
return &walletv1.CreateResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
@ -133,11 +155,14 @@ func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceReq
GrantStrategy: resourceGrantStrategy(resourceType),
WalletAssetType: walletAssetType,
WalletAssetAmount: walletAssetAmount,
PriceType: priceType,
CoinPrice: coinPrice,
GiftPointAmount: giftPointAmount,
UsageScopes: []string{resourceType},
AssetUrl: strings.TrimSpace(r.AssetURL),
PreviewUrl: strings.TrimSpace(r.PreviewURL),
AnimationUrl: strings.TrimSpace(r.AnimationURL),
MetadataJson: "{}",
MetadataJson: metadataJSON,
SortOrder: r.SortOrder,
OperatorUserId: actorID(c),
}
@ -146,6 +171,8 @@ func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceReq
func (r resourceRequest) updateProto(c *gin.Context, resourceID int64) *walletv1.UpdateResourceRequest {
resourceType := normalizeResourceType(r.ResourceType)
walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount)
priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice)
metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm)
return &walletv1.UpdateResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
@ -159,11 +186,14 @@ func (r resourceRequest) updateProto(c *gin.Context, resourceID int64) *walletv1
GrantStrategy: resourceGrantStrategy(resourceType),
WalletAssetType: walletAssetType,
WalletAssetAmount: walletAssetAmount,
PriceType: priceType,
CoinPrice: coinPrice,
GiftPointAmount: giftPointAmount,
UsageScopes: []string{resourceType},
AssetUrl: strings.TrimSpace(r.AssetURL),
PreviewUrl: strings.TrimSpace(r.PreviewURL),
AnimationUrl: strings.TrimSpace(r.AnimationURL),
MetadataJson: "{}",
MetadataJson: metadataJSON,
SortOrder: r.SortOrder,
OperatorUserId: actorID(c),
}
@ -340,6 +370,39 @@ func resourceWalletAsset(resourceType string, amount int64) (string, int64) {
return "", 0
}
func resourcePricing(priceType string, coinPrice int64) (string, int64, int64) {
priceType = strings.ToLower(strings.TrimSpace(priceType))
if priceType == resourcePriceTypeFree {
return resourcePriceTypeFree, 0, 0
}
return resourcePriceTypeCoin, coinPrice, coinPrice
}
func validateResourcePricing(req resourceRequest) error {
priceType := strings.ToLower(strings.TrimSpace(req.PriceType))
switch priceType {
case resourcePriceTypeCoin:
if req.CoinPrice <= 0 {
return fmt.Errorf("请输入资源价格")
}
case resourcePriceTypeFree:
return nil
default:
return fmt.Errorf("请选择资源价格类型")
}
return nil
}
func validateResourceBadgeForm(req resourceRequest) error {
if normalizeResourceType(req.ResourceType) != resourceTypeBadge {
return nil
}
if normalizeBadgeForm(req.BadgeForm) == "" {
return fmt.Errorf("请选择徽章属性")
}
return nil
}
func resourceGrantStrategy(resourceType string) string {
switch resourceType {
case resourceTypeCoin:
@ -353,6 +416,52 @@ func resourceGrantStrategy(resourceType string) string {
}
}
func resourceMetadataJSON(resourceType string, badgeForm string) string {
if resourceType != resourceTypeBadge {
return "{}"
}
payload := badgeMetadataPayload{BadgeForm: normalizeBadgeForm(badgeForm)}
switch payload.BadgeForm {
case badgeFormStrip:
payload.DefaultSlot = "profile_strip"
case badgeFormTile:
payload.DefaultSlot = "honor_wall"
default:
return "{}"
}
body, err := json.Marshal(payload)
if err != nil {
return "{}"
}
return string(body)
}
func normalizeBadgeForm(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case badgeFormStrip, "long":
return badgeFormStrip
case badgeFormTile, "short":
return badgeFormTile
default:
return ""
}
}
func badgeFormFromMetadata(metadataJSON string) string {
payload := badgeMetadataPayload{}
if err := json.Unmarshal([]byte(strings.TrimSpace(metadataJSON)), &payload); err != nil {
return ""
}
return normalizeBadgeForm(payload.BadgeForm)
}
func badgeFormForResource(resourceType string, metadataJSON string) string {
if normalizeResourceType(resourceType) != resourceTypeBadge {
return ""
}
return badgeFormFromMetadata(metadataJSON)
}
func emojiPackMetadataJSON(regionIDs []int64, category string, pricingType string) (string, error) {
normalizedRegionIDs := normalizeRegionIDs(regionIDs)
metadata := emojiPackMetadataPayload{

View File

@ -2,13 +2,10 @@ package roomadmin
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"slices"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/roomclient"
)
var roomSeatCountCandidates = []int32{10, 15, 20, 25, 30}
@ -22,58 +19,36 @@ type RoomConfig struct {
// GetRoomConfig 读取房间座位数配置;缺行时返回产品默认值,不阻塞首次打开配置页。
func (s *Service) GetRoomConfig(ctx context.Context) (RoomConfig, error) {
if s.db == nil {
return RoomConfig{}, fmt.Errorf("room mysql is not configured")
if s.roomClient == nil {
return RoomConfig{}, fmt.Errorf("room service client is not configured")
}
row := s.db.QueryRowContext(ctx, `
SELECT allowed_seat_counts, default_seat_count, updated_at_ms
FROM room_seat_configs
WHERE app_code = ?
`, appctx.FromContext(ctx))
var rawAllowed string
config := defaultRoomConfig()
if err := row.Scan(&rawAllowed, &config.DefaultSeatCount, &config.UpdatedAtMS); err != nil {
if err == sql.ErrNoRows {
return config, nil
}
return RoomConfig{}, err
config, err := s.roomClient.GetRoomSeatConfig(ctx)
if err != nil {
return RoomConfig{}, mapRoomClientError(err)
}
if err := json.Unmarshal([]byte(rawAllowed), &config.AllowedSeatCounts); err != nil {
return RoomConfig{}, err
}
return normalizeRoomConfig(config)
return roomConfigFromClient(config)
}
// UpdateRoomConfig 保存后台房间配置;候选集合固定,后台只控制启用项和默认值。
func (s *Service) UpdateRoomConfig(ctx context.Context, req updateRoomConfigRequest) (RoomConfig, error) {
if s.db == nil {
return RoomConfig{}, fmt.Errorf("room mysql is not configured")
if s.roomClient == nil {
return RoomConfig{}, fmt.Errorf("room service client is not configured")
}
config, err := normalizeRoomConfig(RoomConfig{
AllowedSeatCounts: req.AllowedSeatCounts,
DefaultSeatCount: req.DefaultSeatCount,
UpdatedAtMS: time.Now().UTC().UnixMilli(),
})
if err != nil {
return RoomConfig{}, err
}
rawAllowed, err := json.Marshal(config.AllowedSeatCounts)
saved, err := s.roomClient.UpdateRoomSeatConfig(ctx, roomclient.UpdateRoomSeatConfigRequest{
AllowedSeatCounts: config.AllowedSeatCounts,
DefaultSeatCount: config.DefaultSeatCount,
})
if err != nil {
return RoomConfig{}, err
return RoomConfig{}, mapRoomClientError(err)
}
now := config.UpdatedAtMS
if _, err := s.db.ExecContext(ctx, `
INSERT INTO room_seat_configs (app_code, allowed_seat_counts, default_seat_count, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
allowed_seat_counts = VALUES(allowed_seat_counts),
default_seat_count = VALUES(default_seat_count),
updated_at_ms = VALUES(updated_at_ms)
`, appctx.FromContext(ctx), string(rawAllowed), config.DefaultSeatCount, now, now); err != nil {
return RoomConfig{}, err
}
return config, nil
return roomConfigFromClient(saved)
}
func defaultRoomConfig() RoomConfig {
@ -112,3 +87,16 @@ func normalizeRoomConfig(config RoomConfig) (RoomConfig, error) {
config.AllowedSeatCounts = allowed
return config, nil
}
func roomConfigFromClient(input roomclient.RoomSeatConfig) (RoomConfig, error) {
config := RoomConfig{
CandidateSeatCounts: input.CandidateSeatCounts,
AllowedSeatCounts: input.AllowedSeatCounts,
DefaultSeatCount: input.DefaultSeatCount,
UpdatedAtMS: input.UpdatedAtMS,
}
if len(config.CandidateSeatCounts) == 0 {
config.CandidateSeatCounts = append([]int32(nil), roomSeatCountCandidates...)
}
return normalizeRoomConfig(config)
}

View File

@ -19,8 +19,8 @@ type Handler struct {
audit shared.OperationLogger
}
func New(roomDB *sql.DB, userDB *sql.DB, roomClient roomclient.Client, audit shared.OperationLogger) *Handler {
return &Handler{service: NewService(roomDB, userDB, roomClient), audit: audit}
func New(userDB *sql.DB, roomClient roomclient.Client, audit shared.OperationLogger) *Handler {
return &Handler{service: NewService(userDB, roomClient), audit: audit}
}
func (h *Handler) ListRooms(c *gin.Context) {
@ -126,7 +126,7 @@ func (h *Handler) DeleteRoom(c *gin.Context) {
if !ok {
return
}
if err := h.service.DeleteRoom(c.Request.Context(), roomID); err != nil {
if err := h.service.DeleteRoom(c.Request.Context(), roomID, shared.ActorFromContext(c), middleware.CurrentRequestID(c)); err != nil {
writeMutationError(c, err, "删除房间失败")
return
}

View File

@ -2,14 +2,12 @@ package roomadmin
import (
"context"
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/roomclient"
)
const (
@ -49,204 +47,45 @@ type RoomPin struct {
}
func (s *Service) ListRoomPins(ctx context.Context, query roomPinListQuery) ([]RoomPin, int64, error) {
if s.db == nil {
return nil, 0, fmt.Errorf("room mysql is not configured")
if s.roomClient == nil {
return nil, 0, fmt.Errorf("room service client is not configured")
}
query = normalizeRoomPinListQuery(query)
whereSQL, args := roomPinListWhere(ctx, query, time.Now().UTC().UnixMilli())
total, err := countRows(ctx, s.db, whereSQL, args...)
result, err := s.roomClient.ListRoomPins(ctx, roomclient.ListRoomPinsRequest{
Page: query.Page,
PageSize: query.PageSize,
Keyword: query.Keyword,
Status: query.Status,
RegionID: query.RegionID,
})
if err != nil {
return nil, 0, err
}
rows, err := s.db.QueryContext(ctx, `
SELECT p.id,
p.visible_region_id,
p.room_id,
p.weight,
p.status,
p.pinned_at_ms,
p.expires_at_ms,
p.cancelled_at_ms,
p.created_by_admin_id,
p.cancelled_by_admin_id,
p.created_at_ms,
p.updated_at_ms,
r.room_short_id,
r.title,
r.cover_url,
r.owner_user_id,
r.status
`+whereSQL+`
ORDER BY p.weight DESC, p.expires_at_ms DESC, p.id DESC
LIMIT ? OFFSET ?
`, append(args, query.PageSize, offset(query.Page, query.PageSize))...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]RoomPin, 0, query.PageSize)
for rows.Next() {
item, err := scanRoomPin(rows)
if err != nil {
return nil, 0, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
items := roomPinsFromClient(result.Pins)
if err := s.fillRoomPinDetails(ctx, items); err != nil {
return nil, 0, err
}
return items, total, nil
return items, result.Total, nil
}
func (s *Service) CreateRoomPin(ctx context.Context, req createRoomPinRequest, actorID uint) (RoomPin, error) {
if s.db == nil {
return RoomPin{}, fmt.Errorf("room mysql is not configured")
if s.roomClient == nil {
return RoomPin{}, fmt.Errorf("room service client is not configured")
}
input, err := normalizeCreateRoomPinRequest(req)
if err != nil {
return RoomPin{}, err
}
room, _, err := s.getRoomForPin(ctx, input.RoomID)
pin, err := s.roomClient.CreateRoomPin(ctx, roomclient.CreateRoomPinRequest{
RoomID: input.RoomID,
Weight: input.Weight,
DurationDays: input.DurationDays,
AdminID: uint64(actorID),
})
if err != nil {
return RoomPin{}, err
}
if room.Status != "active" {
return RoomPin{}, fmt.Errorf("%w: 只能置顶正常状态房间", ErrInvalidArgument)
}
nowMS := time.Now().UTC().UnixMilli()
expiresAtMS := nowMS + input.DurationDays*roomPinDayMillis
appCode := appctx.FromContext(ctx)
if _, err := s.db.ExecContext(ctx, `
INSERT INTO room_region_pins (
app_code, visible_region_id, room_id, weight, status, pinned_at_ms, expires_at_ms,
cancelled_at_ms, created_by_admin_id, cancelled_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, 0, ?, ?)
ON DUPLICATE KEY UPDATE
weight = VALUES(weight),
status = VALUES(status),
pinned_at_ms = VALUES(pinned_at_ms),
expires_at_ms = VALUES(expires_at_ms),
cancelled_at_ms = 0,
created_by_admin_id = VALUES(created_by_admin_id),
cancelled_by_admin_id = 0,
created_at_ms = VALUES(created_at_ms),
updated_at_ms = VALUES(updated_at_ms)
`, appCode, room.VisibleRegionID, room.RoomID, input.Weight, roomPinStatusActive, nowMS, expiresAtMS, actorID, nowMS, nowMS); err != nil {
return RoomPin{}, err
}
pin, err := s.getRoomPinByRoom(ctx, room.VisibleRegionID, room.RoomID)
if err != nil {
return RoomPin{}, err
}
return pin, nil
}
func (s *Service) CancelRoomPin(ctx context.Context, pinID int64, actorID uint) (RoomPin, error) {
if s.db == nil {
return RoomPin{}, fmt.Errorf("room mysql is not configured")
}
if pinID <= 0 {
return RoomPin{}, fmt.Errorf("%w: 置顶 ID 不正确", ErrInvalidArgument)
}
nowMS := time.Now().UTC().UnixMilli()
appCode := appctx.FromContext(ctx)
result, err := s.db.ExecContext(ctx, `
UPDATE room_region_pins
SET status = ?, cancelled_at_ms = ?, cancelled_by_admin_id = ?, updated_at_ms = ?
WHERE app_code = ? AND id = ? AND status <> ?
`, roomPinStatusCancelled, nowMS, actorID, nowMS, appCode, pinID, roomPinStatusCancelled)
if err != nil {
return RoomPin{}, err
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
if _, err := s.getRoomPinByID(ctx, pinID); errors.Is(err, ErrNotFound) {
return RoomPin{}, ErrNotFound
}
}
return s.getRoomPinByID(ctx, pinID)
}
func (s *Service) getRoomForPin(ctx context.Context, roomID string) (RoomPinRoom, int64, error) {
appCode := appctx.FromContext(ctx)
row := s.db.QueryRowContext(ctx, `
SELECT room_id,
room_short_id,
visible_region_id,
owner_user_id,
title,
cover_url,
status
FROM room_list_entries
WHERE app_code = ? AND (room_id = ? OR room_short_id = ?)
LIMIT 1
`, appCode, roomID, roomID)
var room RoomPinRoom
var ownerUserID int64
if err := row.Scan(&room.RoomID, &room.RoomShortID, &room.VisibleRegionID, &ownerUserID, &room.Title, &room.CoverURL, &room.Status); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return RoomPinRoom{}, 0, ErrNotFound
}
return RoomPinRoom{}, 0, err
}
if room.Status != "active" {
room.Status = "closed"
}
return room, ownerUserID, nil
}
func (s *Service) getRoomPinByRoom(ctx context.Context, regionID int64, roomID string) (RoomPin, error) {
return s.getRoomPin(ctx, `
WHERE p.app_code = ? AND p.visible_region_id = ? AND p.room_id = ?
`, appctx.FromContext(ctx), regionID, roomID)
}
func (s *Service) getRoomPinByID(ctx context.Context, pinID int64) (RoomPin, error) {
return s.getRoomPin(ctx, `
WHERE p.app_code = ? AND p.id = ?
`, appctx.FromContext(ctx), pinID)
}
func (s *Service) getRoomPin(ctx context.Context, whereSQL string, args ...any) (RoomPin, error) {
row := s.db.QueryRowContext(ctx, `
SELECT p.id,
p.visible_region_id,
p.room_id,
p.weight,
p.status,
p.pinned_at_ms,
p.expires_at_ms,
p.cancelled_at_ms,
p.created_by_admin_id,
p.cancelled_by_admin_id,
p.created_at_ms,
p.updated_at_ms,
r.room_short_id,
r.title,
r.cover_url,
r.owner_user_id,
r.status
FROM room_region_pins p
JOIN room_list_entries r ON r.app_code = p.app_code AND r.room_id = p.room_id
`+whereSQL+`
LIMIT 1
`, args...)
item, err := scanRoomPin(row)
if errors.Is(err, sql.ErrNoRows) {
return RoomPin{}, ErrNotFound
}
if err != nil {
return RoomPin{}, err
return RoomPin{}, mapRoomClientError(err)
}
item := roomPinFromClient(pin)
items := []RoomPin{item}
if err := s.fillRoomPinDetails(ctx, items); err != nil {
return RoomPin{}, err
@ -254,33 +93,23 @@ func (s *Service) getRoomPin(ctx context.Context, whereSQL string, args ...any)
return items[0], nil
}
func roomPinListWhere(ctx context.Context, query roomPinListQuery, nowMS int64) (string, []any) {
where := []string{"p.app_code = ?"}
args := []any{appctx.FromContext(ctx)}
switch query.Status {
case roomPinStatusActive:
where = append(where, "p.status = ?", "p.expires_at_ms > ?")
args = append(args, roomPinStatusActive, nowMS)
case roomPinStatusExpired:
where = append(where, "p.status = ?", "p.expires_at_ms <= ?")
args = append(args, roomPinStatusActive, nowMS)
case roomPinStatusCancelled:
where = append(where, "p.status = ?")
args = append(args, roomPinStatusCancelled)
func (s *Service) CancelRoomPin(ctx context.Context, pinID int64, actorID uint) (RoomPin, error) {
if s.roomClient == nil {
return RoomPin{}, fmt.Errorf("room service client is not configured")
}
if query.RegionID > 0 {
where = append(where, "p.visible_region_id = ?")
args = append(args, query.RegionID)
if pinID <= 0 {
return RoomPin{}, fmt.Errorf("%w: 置顶 ID 不正确", ErrInvalidArgument)
}
if query.Keyword != "" {
like := "%" + query.Keyword + "%"
where = append(where, "(p.room_id LIKE ? OR r.room_short_id LIKE ? OR r.title LIKE ? OR CAST(r.owner_user_id AS CHAR) LIKE ?)")
args = append(args, like, like, like, like)
pin, err := s.roomClient.CancelRoomPin(ctx, roomclient.CancelRoomPinRequest{PinID: pinID, AdminID: uint64(actorID)})
if err != nil {
return RoomPin{}, mapRoomClientError(err)
}
return `
FROM room_region_pins p
JOIN room_list_entries r ON r.app_code = p.app_code AND r.room_id = p.room_id
WHERE ` + strings.Join(where, " AND "), args
item := roomPinFromClient(pin)
items := []RoomPin{item}
if err := s.fillRoomPinDetails(ctx, items); err != nil {
return RoomPin{}, err
}
return items[0], nil
}
func normalizeRoomPinListQuery(query roomPinListQuery) roomPinListQuery {
@ -333,6 +162,48 @@ func normalizeCreateRoomPinRequest(req createRoomPinRequest) (createRoomPinReque
return req, nil
}
func roomPinsFromClient(input []roomclient.RoomPin) []RoomPin {
out := make([]RoomPin, 0, len(input))
for _, pin := range input {
out = append(out, roomPinFromClient(pin))
}
return out
}
func roomPinFromClient(input roomclient.RoomPin) RoomPin {
status := input.Room.Status
if status != "active" {
status = "closed"
}
ownerUserID := strconv.FormatInt(input.Room.OwnerUserID, 10)
remainingMS := input.ExpiresAtMS - time.Now().UTC().UnixMilli()
if remainingMS < 0 {
remainingMS = 0
}
return RoomPin{
ID: input.ID,
Weight: input.Weight,
Status: input.Status,
PinnedAtMS: input.PinnedAtMS,
ExpiresAtMS: input.ExpiresAtMS,
CancelledAtMS: input.CancelledAtMS,
CreatedByAdminID: uint(input.CreatedByAdminID),
CancelledByAdminID: uint(input.CancelledByAdminID),
CreatedAtMS: input.CreatedAtMS,
UpdatedAtMS: input.UpdatedAtMS,
RemainingMS: remainingMS,
User: RoomOwner{UserID: ownerUserID},
Room: RoomPinRoom{
RoomID: input.Room.RoomID,
RoomShortID: input.Room.RoomShortID,
VisibleRegionID: input.Room.VisibleRegionID,
Title: input.Room.Title,
CoverURL: input.Room.CoverURL,
Status: status,
},
}
}
type roomPinScanner interface {
Scan(dest ...any) error
}

View File

@ -7,15 +7,12 @@ import (
"fmt"
"strconv"
"strings"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/roomclient"
"hyapp-admin-server/internal/modules/shared"
roomv1 "hyapp.local/api/proto/room/v1"
)
var (
@ -24,7 +21,6 @@ var (
)
type Service struct {
db *sql.DB
userDB *sql.DB
roomClient roomclient.Client
}
@ -61,120 +57,40 @@ type Room struct {
VisibleRegionID int64 `json:"visibleRegionId"`
}
func NewService(db *sql.DB, userDB *sql.DB, roomClient roomclient.Client) *Service {
return &Service{db: db, userDB: userDB, roomClient: roomClient}
func NewService(userDB *sql.DB, roomClient roomclient.Client) *Service {
return &Service{userDB: userDB, roomClient: roomClient}
}
func (s *Service) ListRooms(ctx context.Context, query listQuery) ([]Room, int64, error) {
if s.db == nil {
return nil, 0, fmt.Errorf("room mysql is not configured")
if s.roomClient == nil {
return nil, 0, fmt.Errorf("room service client is not configured")
}
query = normalizeListQuery(query)
appCode := appctx.FromContext(ctx)
whereSQL := "FROM room_list_entries rle WHERE rle.app_code = ?"
args := []any{appCode}
if query.Status == "closed" {
whereSQL += " AND rle.status <> ?"
args = append(args, "active")
} else if query.Status != "" {
whereSQL += " AND rle.status = ?"
args = append(args, query.Status)
}
if query.RegionID > 0 {
whereSQL += " AND rle.visible_region_id = ?"
args = append(args, query.RegionID)
}
if query.Keyword != "" {
like := "%" + query.Keyword + "%"
whereSQL += " AND (rle.room_id LIKE ? OR rle.room_short_id LIKE ? OR rle.title LIKE ? OR CAST(rle.owner_user_id AS CHAR) LIKE ?)"
args = append(args, like, like, like, like)
}
total, err := countRows(ctx, s.db, whereSQL, args...)
result, err := s.roomClient.ListRooms(ctx, roomclient.ListRoomsRequest{
Page: query.Page,
PageSize: query.PageSize,
Keyword: query.Keyword,
Status: query.Status,
RegionID: query.RegionID,
SortBy: query.SortBy,
SortDirection: query.SortDirection,
})
if err != nil {
return nil, 0, err
}
rows, err := s.db.QueryContext(ctx, fmt.Sprintf(`
SELECT rle.room_id,
rle.room_short_id,
rle.visible_region_id,
rle.owner_user_id,
rle.title,
rle.cover_url,
rle.mode,
rle.status,
rle.close_reason,
rle.closed_by_admin_id,
rle.closed_by_admin_name,
rle.closed_at_ms,
rle.heat,
rle.online_count,
rle.seat_count,
rle.occupied_seat_count,
rle.sort_score,
rle.created_at_ms,
rle.updated_at_ms
%s
ORDER BY %s
LIMIT ? OFFSET ?
`, whereSQL, roomListOrderBy(query)), append(args, query.PageSize, offset(query.Page, query.PageSize))...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]Room, 0, query.PageSize)
for rows.Next() {
item, err := scanRoom(rows)
if err != nil {
return nil, 0, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
items := roomsFromClient(result.Rooms)
if err := s.fillRoomDetails(ctx, items); err != nil {
return nil, 0, err
}
return items, total, nil
return items, result.Total, nil
}
func (s *Service) GetRoom(ctx context.Context, roomID string) (Room, error) {
if s.db == nil {
return Room{}, fmt.Errorf("room mysql is not configured")
}
appCode := appctx.FromContext(ctx)
row := s.db.QueryRowContext(ctx, `
SELECT room_id,
room_short_id,
visible_region_id,
owner_user_id,
title,
cover_url,
mode,
status,
close_reason,
closed_by_admin_id,
closed_by_admin_name,
closed_at_ms,
heat,
online_count,
seat_count,
occupied_seat_count,
sort_score,
created_at_ms,
updated_at_ms
FROM room_list_entries
WHERE app_code = ? AND room_id = ?
`, appCode, roomID)
item, err := scanRoom(row)
if errors.Is(err, sql.ErrNoRows) {
return Room{}, ErrNotFound
}
item, err := s.roomClient.GetRoom(ctx, roomclient.GetRoomRequest{RoomID: roomID})
if err != nil {
return Room{}, err
return Room{}, mapRoomClientError(err)
}
items := []Room{item}
items := []Room{roomFromClient(*item)}
if err := s.fillRoomDetails(ctx, items); err != nil {
return Room{}, err
}
@ -182,110 +98,93 @@ func (s *Service) GetRoom(ctx context.Context, roomID string) (Room, error) {
}
func (s *Service) UpdateRoom(ctx context.Context, roomID string, req updateRoomRequest, actor shared.Actor, requestID string) (Room, error) {
if s.db == nil {
return Room{}, fmt.Errorf("room mysql is not configured")
if s.roomClient == nil {
return Room{}, fmt.Errorf("room service client is not configured")
}
updates, snapshotPatch, err := buildRoomUpdate(req)
if err != nil {
if _, _, err := buildRoomUpdate(req); err != nil {
return Room{}, err
}
if len(updates) == 0 {
if !roomUpdateHasChange(req) {
return s.GetRoom(ctx, roomID)
}
if snapshotPatch.statusSet && snapshotPatch.status == "closed" {
if err := s.closeRoomLifecycle(ctx, roomID, actor, requestID); err != nil {
return Room{}, err
}
} else if snapshotPatch.statusSet && snapshotPatch.status == "active" {
if err := s.reopenRoomLifecycle(ctx, roomID, actor, requestID); err != nil {
return Room{}, err
}
}
tx, err := s.db.BeginTx(ctx, nil)
_, err := s.roomClient.UpdateRoom(ctx, roomclient.UpdateRoomRequest{
RequestID: requestID,
RoomID: roomID,
ActorUserID: int64(actor.UserID),
Title: req.Title,
CoverURL: req.CoverURL,
Description: req.Description,
Mode: req.Mode,
Status: req.Status,
VisibleRegionID: req.VisibleRegionID,
CloseReason: valueOrEmpty(req.CloseReason),
AdminID: uint64(actor.UserID),
AdminName: actor.Username,
})
if err != nil {
return Room{}, err
}
defer rollbackIfOpen(tx)
var currentRoomID string
appCode := appctx.FromContext(ctx)
if err := tx.QueryRowContext(ctx, `SELECT room_id FROM room_list_entries WHERE app_code = ? AND room_id = ? FOR UPDATE`, appCode, roomID).Scan(&currentRoomID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Room{}, ErrNotFound
}
return Room{}, err
}
now := time.Now().UnixMilli()
listSets := make([]string, 0, len(updates)+5)
listArgs := make([]any, 0, len(updates)+6)
roomSets := make([]string, 0, len(updates)+4)
roomArgs := make([]any, 0, len(updates)+6)
for _, update := range updates {
if update.listColumn != "" {
listSets = append(listSets, update.listColumn+" = ?")
listArgs = append(listArgs, update.value)
}
if update.roomColumn != "" {
roomSets = append(roomSets, update.roomColumn+" = ?")
roomArgs = append(roomArgs, update.value)
}
}
if snapshotPatch.statusSet {
if snapshotPatch.status == "closed" {
reason := strings.TrimSpace(valueOrEmpty(req.CloseReason))
operatorName := strings.TrimSpace(actor.Username)
closeUpdates := []roomUpdate{
{listColumn: "close_reason", roomColumn: "close_reason", value: reason},
{listColumn: "closed_by_admin_id", roomColumn: "closed_by_admin_id", value: actor.UserID},
{listColumn: "closed_by_admin_name", roomColumn: "closed_by_admin_name", value: operatorName},
{listColumn: "closed_at_ms", roomColumn: "closed_at_ms", value: now},
}
appendRoomUpdates(closeUpdates, &listSets, &listArgs, &roomSets, &roomArgs)
} else {
clearUpdates := []roomUpdate{
{listColumn: "close_reason", roomColumn: "close_reason", value: ""},
{listColumn: "closed_by_admin_id", roomColumn: "closed_by_admin_id", value: 0},
{listColumn: "closed_by_admin_name", roomColumn: "closed_by_admin_name", value: ""},
{listColumn: "closed_at_ms", roomColumn: "closed_at_ms", value: 0},
}
appendRoomUpdates(clearUpdates, &listSets, &listArgs, &roomSets, &roomArgs)
}
}
listSets = append(listSets, "updated_at_ms = ?")
listArgs = append(listArgs, now, appCode, roomID)
if _, err := tx.ExecContext(ctx, "UPDATE room_list_entries SET "+strings.Join(listSets, ", ")+" WHERE app_code = ? AND room_id = ?", listArgs...); err != nil {
return Room{}, err
}
if len(roomSets) > 0 {
roomArgs = append(roomArgs, appCode, roomID)
if _, err := tx.ExecContext(ctx, "UPDATE rooms SET "+strings.Join(roomSets, ", ")+" WHERE app_code = ? AND room_id = ?", roomArgs...); err != nil {
return Room{}, err
}
}
if err := updateSnapshot(ctx, tx, roomID, snapshotPatch); err != nil {
return Room{}, err
}
if err := tx.Commit(); err != nil {
return Room{}, err
return Room{}, mapRoomClientError(err)
}
return s.GetRoom(ctx, roomID)
}
func (s *Service) closeRoomLifecycle(ctx context.Context, roomID string, actor shared.Actor, requestID string) error {
func (s *Service) DeleteRoom(ctx context.Context, roomID string, actor shared.Actor, requestID string) error {
if s.roomClient == nil {
return fmt.Errorf("room service client is not configured")
}
if actor.UserID == 0 {
return fmt.Errorf("%w: 操作人不正确", ErrInvalidArgument)
}
_, err := s.roomClient.CloseRoom(ctx, roomclient.CloseRoomRequest{
_, err := s.roomClient.DeleteRoom(ctx, roomclient.DeleteRoomRequest{
RequestID: requestID,
RoomID: roomID,
ActorUserID: int64(actor.UserID),
Reason: "admin_closed",
AdminID: uint64(actor.UserID),
AdminName: actor.Username,
})
return mapRoomClientError(err)
}
func roomUpdateHasChange(req updateRoomRequest) bool {
return req.Title != nil || req.CoverURL != nil || req.Description != nil || req.Mode != nil || req.Status != nil || req.VisibleRegionID != nil
}
func roomsFromClient(items []roomclient.Room) []Room {
rooms := make([]Room, 0, len(items))
for _, item := range items {
rooms = append(rooms, roomFromClient(item))
}
return rooms
}
func roomFromClient(item roomclient.Room) Room {
status := item.Status
if status != "active" {
status = "closed"
}
ownerUserID := strconv.FormatInt(item.OwnerUserID, 10)
return Room{
RoomID: item.RoomID,
RoomShortID: item.RoomShortID,
VisibleRegionID: item.VisibleRegionID,
OwnerUserID: ownerUserID,
Owner: RoomOwner{UserID: ownerUserID},
Title: item.Title,
CoverURL: item.CoverURL,
Mode: item.Mode,
Status: status,
CloseReason: item.CloseReason,
ClosedByAdminID: uint(item.ClosedByAdminID),
ClosedByAdminName: item.ClosedByAdminName,
ClosedAtMs: item.ClosedAtMS,
Heat: item.Heat,
RoomContribution: item.Heat,
OnlineCount: item.OnlineCount,
SeatCount: item.SeatCount,
OccupiedSeatCount: item.OccupiedSeatCount,
SortScore: item.SortScore,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
}
}
func mapRoomClientError(err error) error {
if err == nil {
return nil
}
@ -300,76 +199,6 @@ func (s *Service) closeRoomLifecycle(ctx context.Context, roomID string, actor s
return err
}
func (s *Service) reopenRoomLifecycle(ctx context.Context, roomID string, actor shared.Actor, requestID string) error {
if s.roomClient == nil {
return fmt.Errorf("room service client is not configured")
}
if actor.UserID == 0 {
return fmt.Errorf("%w: 操作人不正确", ErrInvalidArgument)
}
_, err := s.roomClient.ReopenRoom(ctx, roomclient.ReopenRoomRequest{
RequestID: requestID,
RoomID: roomID,
ActorUserID: int64(actor.UserID),
})
if err == nil {
return nil
}
if st, ok := status.FromError(err); ok {
switch st.Code() {
case codes.NotFound:
return ErrNotFound
case codes.InvalidArgument, codes.FailedPrecondition:
return fmt.Errorf("%w: %s", ErrInvalidArgument, st.Message())
}
}
return err
}
func appendRoomUpdates(updates []roomUpdate, listSets *[]string, listArgs *[]any, roomSets *[]string, roomArgs *[]any) {
for _, update := range updates {
if update.listColumn != "" {
*listSets = append(*listSets, update.listColumn+" = ?")
*listArgs = append(*listArgs, update.value)
}
if update.roomColumn != "" {
*roomSets = append(*roomSets, update.roomColumn+" = ?")
*roomArgs = append(*roomArgs, update.value)
}
}
}
func (s *Service) DeleteRoom(ctx context.Context, roomID string) error {
if s.db == nil {
return fmt.Errorf("room mysql is not configured")
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer rollbackIfOpen(tx)
appCode := appctx.FromContext(ctx)
result, err := tx.ExecContext(ctx, `DELETE FROM room_list_entries WHERE app_code = ? AND room_id = ?`, appCode, roomID)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return ErrNotFound
}
if _, err := tx.ExecContext(ctx, `DELETE FROM room_snapshots WHERE app_code = ? AND room_id = ?`, appCode, roomID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM rooms WHERE app_code = ? AND room_id = ?`, appCode, roomID); err != nil {
return err
}
return tx.Commit()
}
type roomUpdate struct {
listColumn string
roomColumn string
@ -465,85 +294,6 @@ func buildRoomUpdate(req updateRoomRequest) ([]roomUpdate, snapshotPatch, error)
return updates, patch, nil
}
func updateSnapshot(ctx context.Context, tx *sql.Tx, roomID string, patch snapshotPatch) error {
var payload []byte
appCode := appctx.FromContext(ctx)
err := tx.QueryRowContext(ctx, `SELECT payload FROM room_snapshots WHERE app_code = ? AND room_id = ? FOR UPDATE`, appCode, roomID).Scan(&payload)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
return err
}
snapshot := &roomv1.RoomSnapshot{}
if err := proto.Unmarshal(payload, snapshot); err != nil {
return err
}
if snapshot.RoomExt == nil {
snapshot.RoomExt = map[string]string{}
}
if patch.titleSet {
snapshot.RoomExt["title"] = patch.title
}
if patch.coverURLSet {
snapshot.RoomExt["cover_url"] = patch.coverURL
}
if patch.descriptionSet {
snapshot.RoomExt["description"] = patch.description
}
if patch.modeSet {
snapshot.Mode = patch.mode
}
if patch.statusSet {
snapshot.Status = patch.status
}
nextPayload, err := proto.Marshal(snapshot)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `UPDATE room_snapshots SET payload = ? WHERE app_code = ? AND room_id = ?`, nextPayload, appCode, roomID)
return err
}
type roomScanner interface {
Scan(dest ...any) error
}
func scanRoom(row roomScanner) (Room, error) {
var item Room
var ownerUserID int64
var closedByAdminID uint64
err := row.Scan(
&item.RoomID,
&item.RoomShortID,
&item.VisibleRegionID,
&ownerUserID,
&item.Title,
&item.CoverURL,
&item.Mode,
&item.Status,
&item.CloseReason,
&closedByAdminID,
&item.ClosedByAdminName,
&item.ClosedAtMs,
&item.Heat,
&item.OnlineCount,
&item.SeatCount,
&item.OccupiedSeatCount,
&item.SortScore,
&item.CreatedAtMs,
&item.UpdatedAtMs,
)
item.ClosedByAdminID = uint(closedByAdminID)
item.OwnerUserID = strconv.FormatInt(ownerUserID, 10)
item.Owner = RoomOwner{UserID: item.OwnerUserID}
item.RoomContribution = item.Heat
if item.Status != "active" {
item.Status = "closed"
}
return item, err
}
func normalizeListQuery(query listQuery) listQuery {
if query.Page < 1 {
query.Page = 1
@ -743,16 +493,6 @@ func int64Args(values []int64) []any {
return args
}
func countRows(ctx context.Context, db *sql.DB, whereSQL string, args ...any) (int64, error) {
var total int64
err := db.QueryRowContext(ctx, "SELECT COUNT(*) "+whereSQL, args...).Scan(&total)
return total, err
}
func offset(page int, pageSize int) int {
return (page - 1) * pageSize
}
func rollbackIfOpen(tx *sql.Tx) {
_ = tx.Rollback()
}

View File

@ -1,9 +1,9 @@
package roomtreasure
import (
"database/sql"
"errors"
"hyapp-admin-server/internal/integration/roomclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
@ -16,8 +16,8 @@ type Handler struct {
audit shared.OperationLogger
}
func New(roomDB *sql.DB, audit shared.OperationLogger) *Handler {
return &Handler{service: NewService(roomDB), audit: audit}
func New(roomClient roomclient.Client, audit shared.OperationLogger) *Handler {
return &Handler{service: NewService(roomClient), audit: audit}
}
func (h *Handler) GetRoomTreasureConfig(c *gin.Context) {

View File

@ -2,15 +2,12 @@ package roomtreasure
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/roomclient"
)
const (
@ -29,7 +26,7 @@ var (
)
type Service struct {
db *sql.DB
roomClient roomclient.Client
}
type RoomTreasureConfig struct {
@ -90,90 +87,29 @@ type updateRoomTreasureConfigRequest struct {
GiftEnergyRules []GiftEnergyRuleConfig `json:"giftEnergyRules"`
}
func NewService(db *sql.DB) *Service {
return &Service{db: db}
func NewService(roomClient roomclient.Client) *Service {
return &Service{roomClient: roomClient}
}
// GetConfig 读取当前 App 的语音房宝箱配置;缺行时返回关闭态默认配置,便于首次进入后台页面。
func (s *Service) GetConfig(ctx context.Context) (RoomTreasureConfig, error) {
if s.db == nil {
return RoomTreasureConfig{}, fmt.Errorf("room mysql is not configured")
if s.roomClient == nil {
return RoomTreasureConfig{}, fmt.Errorf("room service client is not configured")
}
appCode := appctx.FromContext(ctx)
row := s.db.QueryRowContext(ctx, `
SELECT app_code,
enabled,
config_version,
energy_source,
open_delay_ms,
broadcast_enabled,
broadcast_scope,
broadcast_delay_ms,
reward_stack_policy,
levels_json,
gift_energy_rules_json,
updated_by_admin_id,
created_at_ms,
updated_at_ms
FROM room_treasure_configs
WHERE app_code = ?
`, appCode)
var enabled int
var broadcastEnabled int
var rawLevels string
var rawGiftEnergyRules string
config := defaultConfig(appCode)
if err := row.Scan(
&config.AppCode,
&enabled,
&config.ConfigVersion,
&config.EnergySource,
&config.OpenDelayMS,
&broadcastEnabled,
&config.BroadcastScope,
&config.BroadcastDelayMS,
&config.RewardStackPolicy,
&rawLevels,
&rawGiftEnergyRules,
&config.UpdatedByAdminID,
&config.CreatedAtMS,
&config.UpdatedAtMS,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return config, nil
}
config, err := s.roomClient.GetRoomTreasureConfig(ctx)
if err != nil {
return RoomTreasureConfig{}, err
}
config.Enabled = enabled == 1
config.BroadcastEnabled = broadcastEnabled == 1
if err := json.Unmarshal([]byte(rawLevels), &config.Levels); err != nil {
return RoomTreasureConfig{}, err
}
if err := json.Unmarshal([]byte(rawGiftEnergyRules), &config.GiftEnergyRules); err != nil {
return RoomTreasureConfig{}, err
}
return normalizeConfig(config)
return configFromClient(config)
}
// UpdateConfig 保存房间宝箱低频配置;版本号由后台递增,运行时只使用已提交版本。
func (s *Service) UpdateConfig(ctx context.Context, req updateRoomTreasureConfigRequest, operatorAdminID int64) (RoomTreasureConfig, error) {
if s.db == nil {
return RoomTreasureConfig{}, fmt.Errorf("room mysql is not configured")
}
current, err := s.GetConfig(ctx)
if err != nil {
return RoomTreasureConfig{}, err
}
nowMS := time.Now().UTC().UnixMilli()
createdAtMS := current.CreatedAtMS
if createdAtMS <= 0 {
createdAtMS = nowMS
if s.roomClient == nil {
return RoomTreasureConfig{}, fmt.Errorf("room service client is not configured")
}
config, err := normalizeConfig(RoomTreasureConfig{
AppCode: appctx.FromContext(ctx),
Enabled: req.Enabled,
ConfigVersion: current.ConfigVersion + 1,
EnergySource: req.EnergySource,
OpenDelayMS: req.OpenDelayMS,
BroadcastEnabled: req.BroadcastEnabled,
@ -182,70 +118,18 @@ func (s *Service) UpdateConfig(ctx context.Context, req updateRoomTreasureConfig
RewardStackPolicy: req.RewardStackPolicy,
Levels: req.Levels,
GiftEnergyRules: req.GiftEnergyRules,
UpdatedByAdminID: operatorAdminID,
CreatedAtMS: createdAtMS,
UpdatedAtMS: nowMS,
})
if err != nil {
return RoomTreasureConfig{}, err
}
rawLevels, err := json.Marshal(config.Levels)
saved, err := s.roomClient.UpdateRoomTreasureConfig(ctx, roomclient.UpdateRoomTreasureConfigRequest{
Config: configToClient(config),
AdminID: operatorAdminID,
})
if err != nil {
return RoomTreasureConfig{}, err
}
rawGiftEnergyRules, err := json.Marshal(config.GiftEnergyRules)
if err != nil {
return RoomTreasureConfig{}, err
}
if _, err := s.db.ExecContext(ctx, `
INSERT INTO room_treasure_configs (
app_code,
enabled,
config_version,
energy_source,
open_delay_ms,
broadcast_enabled,
broadcast_scope,
broadcast_delay_ms,
reward_stack_policy,
levels_json,
gift_energy_rules_json,
updated_by_admin_id,
created_at_ms,
updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled),
config_version = VALUES(config_version),
energy_source = VALUES(energy_source),
open_delay_ms = VALUES(open_delay_ms),
broadcast_enabled = VALUES(broadcast_enabled),
broadcast_scope = VALUES(broadcast_scope),
broadcast_delay_ms = VALUES(broadcast_delay_ms),
reward_stack_policy = VALUES(reward_stack_policy),
levels_json = VALUES(levels_json),
gift_energy_rules_json = VALUES(gift_energy_rules_json),
updated_by_admin_id = VALUES(updated_by_admin_id),
updated_at_ms = VALUES(updated_at_ms)
`,
config.AppCode,
boolInt(config.Enabled),
config.ConfigVersion,
config.EnergySource,
config.OpenDelayMS,
boolInt(config.BroadcastEnabled),
config.BroadcastScope,
config.BroadcastDelayMS,
config.RewardStackPolicy,
string(rawLevels),
string(rawGiftEnergyRules),
config.UpdatedByAdminID,
config.CreatedAtMS,
config.UpdatedAtMS,
); err != nil {
return RoomTreasureConfig{}, err
}
return config, nil
return configFromClient(saved)
}
func defaultConfig(appCode string) RoomTreasureConfig {
@ -398,13 +282,6 @@ func normalizeGiftEnergyRules(rules []GiftEnergyRuleConfig) ([]GiftEnergyRuleCon
return normalized, nil
}
func boolInt(value bool) int {
if value {
return 1
}
return 0
}
func defaultString(value string, fallback string) string {
if value == "" {
return fallback
@ -420,3 +297,131 @@ func stringIn(value string, candidates ...string) bool {
}
return false
}
func configFromClient(input roomclient.RoomTreasureConfig) (RoomTreasureConfig, error) {
config := RoomTreasureConfig{
AppCode: input.AppCode,
Enabled: input.Enabled,
ConfigVersion: input.ConfigVersion,
EnergySource: input.EnergySource,
OpenDelayMS: input.OpenDelayMS,
BroadcastEnabled: input.BroadcastEnabled,
BroadcastScope: input.BroadcastScope,
BroadcastDelayMS: input.BroadcastDelayMS,
RewardStackPolicy: input.RewardStackPolicy,
Levels: levelsFromClient(input.Levels),
GiftEnergyRules: energyRulesFromClient(input.GiftEnergyRules),
UpdatedByAdminID: input.UpdatedByAdminID,
CreatedAtMS: input.CreatedAtMS,
UpdatedAtMS: input.UpdatedAtMS,
}
return normalizeConfig(config)
}
func configToClient(input RoomTreasureConfig) roomclient.RoomTreasureConfig {
return roomclient.RoomTreasureConfig{
Enabled: input.Enabled,
EnergySource: input.EnergySource,
OpenDelayMS: input.OpenDelayMS,
BroadcastEnabled: input.BroadcastEnabled,
BroadcastScope: input.BroadcastScope,
BroadcastDelayMS: input.BroadcastDelayMS,
RewardStackPolicy: input.RewardStackPolicy,
Levels: levelsToClient(input.Levels),
GiftEnergyRules: energyRulesToClient(input.GiftEnergyRules),
}
}
func levelsFromClient(input []roomclient.RoomTreasureLevelConfig) []RoomTreasureLevelConfig {
out := make([]RoomTreasureLevelConfig, 0, len(input))
for _, level := range input {
out = append(out, RoomTreasureLevelConfig{
Level: level.Level,
EnergyThreshold: level.EnergyThreshold,
CoverURL: level.CoverURL,
AnimationURL: level.AnimationURL,
OpeningAnimationURL: level.OpeningAnimationURL,
OpenedImageURL: level.OpenedImageURL,
InRoomRewards: rewardsFromClient(level.InRoomRewards),
Top1Rewards: rewardsFromClient(level.Top1Rewards),
IgniterRewards: rewardsFromClient(level.IgniterRewards),
})
}
return out
}
func levelsToClient(input []RoomTreasureLevelConfig) []roomclient.RoomTreasureLevelConfig {
out := make([]roomclient.RoomTreasureLevelConfig, 0, len(input))
for _, level := range input {
out = append(out, roomclient.RoomTreasureLevelConfig{
Level: level.Level,
EnergyThreshold: level.EnergyThreshold,
CoverURL: level.CoverURL,
AnimationURL: level.AnimationURL,
OpeningAnimationURL: level.OpeningAnimationURL,
OpenedImageURL: level.OpenedImageURL,
InRoomRewards: rewardsToClient(level.InRoomRewards),
Top1Rewards: rewardsToClient(level.Top1Rewards),
IgniterRewards: rewardsToClient(level.IgniterRewards),
})
}
return out
}
func rewardsFromClient(input []roomclient.RoomTreasureRewardItem) []RoomTreasureRewardItem {
out := make([]RoomTreasureRewardItem, 0, len(input))
for _, reward := range input {
out = append(out, RoomTreasureRewardItem{
RewardItemID: reward.RewardItemID,
ResourceGroupID: reward.ResourceGroupID,
Weight: reward.Weight,
DisplayName: reward.DisplayName,
IconURL: reward.IconURL,
})
}
return out
}
func rewardsToClient(input []RoomTreasureRewardItem) []roomclient.RoomTreasureRewardItem {
out := make([]roomclient.RoomTreasureRewardItem, 0, len(input))
for _, reward := range input {
out = append(out, roomclient.RoomTreasureRewardItem{
RewardItemID: reward.RewardItemID,
ResourceGroupID: reward.ResourceGroupID,
Weight: reward.Weight,
DisplayName: reward.DisplayName,
IconURL: reward.IconURL,
})
}
return out
}
func energyRulesFromClient(input []roomclient.GiftEnergyRuleConfig) []GiftEnergyRuleConfig {
out := make([]GiftEnergyRuleConfig, 0, len(input))
for _, rule := range input {
out = append(out, GiftEnergyRuleConfig{
RuleID: rule.RuleID,
GiftID: rule.GiftID,
GiftTypeCode: rule.GiftTypeCode,
MultiplierPPM: rule.MultiplierPPM,
FixedEnergy: rule.FixedEnergy,
Excluded: rule.Excluded,
})
}
return out
}
func energyRulesToClient(input []GiftEnergyRuleConfig) []roomclient.GiftEnergyRuleConfig {
out := make([]roomclient.GiftEnergyRuleConfig, 0, len(input))
for _, rule := range input {
out = append(out, roomclient.GiftEnergyRuleConfig{
RuleID: rule.RuleID,
GiftID: rule.GiftID,
GiftTypeCode: rule.GiftTypeCode,
MultiplierPPM: rule.MultiplierPPM,
FixedEnergy: rule.FixedEnergy,
Excluded: rule.Excluded,
})
}
return out
}

View File

@ -0,0 +1,88 @@
package userleaderboard
import (
"database/sql"
"errors"
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/roomclient"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
}
func New(walletDB *sql.DB, userDB *sql.DB, room roomclient.Client) *Handler {
return &Handler{service: NewService(walletDB, userDB, room)}
}
func (h *Handler) List(c *gin.Context) {
query, ok := parseQuery(c)
if !ok {
return
}
result, err := h.service.List(c.Request.Context(), appctx.FromContext(c.Request.Context()), query)
if err != nil {
if errors.Is(err, errInvalidArgument) {
response.BadRequest(c, "榜单参数不正确")
return
}
response.ServerError(c, "获取用户榜单失败")
return
}
response.OK(c, result)
}
func parseQuery(c *gin.Context) (Query, bool) {
options := shared.ListOptions(c)
boardType := NormalizeBoardType(firstQuery(c, "board_type", "boardType", "type"))
if boardType == "" {
response.BadRequest(c, "榜单类型不正确")
return Query{}, false
}
period := NormalizePeriod(firstQuery(c, "period", "time_dimension", "timeDimension"))
if period == "" {
response.BadRequest(c, "时间维度不正确")
return Query{}, false
}
viewerUserID, ok := optionalInt64(firstQuery(c, "viewer_user_id", "viewerUserId", "user_id", "userId"))
if !ok {
response.BadRequest(c, "用户 ID 不正确")
return Query{}, false
}
return NormalizeQuery(Query{
BoardType: boardType,
Period: period,
Page: options.Page,
PageSize: options.PageSize,
ViewerUserID: viewerUserID,
}), true
}
func optionalInt64(raw string) (int64, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, true
}
value, err := strconv.ParseInt(raw, 10, 64)
return value, err == nil && value >= 0
}
func firstQuery(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(c.Query(key)); value != "" {
return value
}
}
return ""
}

View File

@ -0,0 +1,15 @@
package userleaderboard
import (
"hyapp-admin-server/internal/middleware"
"github.com/gin-gonic/gin"
)
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
if h == nil {
return
}
protected.GET("/admin/activity/user-leaderboards", middleware.RequirePermission("user-leaderboard:view"), h.List)
}

View File

@ -0,0 +1,573 @@
package userleaderboard
import (
"context"
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/integration/roomclient"
)
const (
BoardTypeSent = "sent"
BoardTypeReceived = "received"
BoardTypeRoom = "room"
PeriodToday = "today"
PeriodWeek = "week"
PeriodMonth = "month"
maxPageSize = 100
)
var errInvalidArgument = errors.New("invalid argument")
type Service struct {
walletDB *sql.DB
userDB *sql.DB
room roomclient.Client
}
type Query struct {
BoardType string
Period string
Page int
PageSize int
ViewerUserID int64
}
type Response struct {
Items []Item `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
BoardType string `json:"board_type"`
Period string `json:"period"`
StartAtMS int64 `json:"start_at_ms"`
EndAtMS int64 `json:"end_at_ms"`
ServerTimeMS int64 `json:"server_time_ms"`
MyRank *Item `json:"my_rank,omitempty"`
}
type Item struct {
Rank int64 `json:"rank"`
UserID string `json:"user_id,omitempty"`
RoomID string `json:"room_id,omitempty"`
GiftValue int64 `json:"gift_value"`
GiftCount int64 `json:"gift_count"`
TransactionCount int64 `json:"transaction_count"`
LastGiftAtMS int64 `json:"last_gift_at_ms"`
User *User `json:"user,omitempty"`
Room *Room `json:"room,omitempty"`
}
type User struct {
UserID string `json:"user_id"`
DisplayUserID string `json:"display_user_id,omitempty"`
Username string `json:"username,omitempty"`
Avatar string `json:"avatar,omitempty"`
}
type Room struct {
RoomID string `json:"room_id"`
RoomShortID string `json:"room_short_id,omitempty"`
Title string `json:"title,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
OwnerUserID string `json:"owner_user_id,omitempty"`
Status string `json:"status,omitempty"`
}
type userProfile struct {
UserID int64
DisplayUserID string
Username string
Avatar string
}
type roomProfile struct {
RoomID string
RoomShortID string
Title string
CoverURL string
OwnerUserID int64
Status string
}
func NewService(walletDB *sql.DB, userDB *sql.DB, room roomclient.Client) *Service {
return &Service{walletDB: walletDB, userDB: userDB, room: room}
}
func (s *Service) List(ctx context.Context, appCode string, query Query) (Response, error) {
query = NormalizeQuery(query)
if query.BoardType == "" || query.Period == "" {
return Response{}, errInvalidArgument
}
if s == nil || s.walletDB == nil {
return Response{}, fmt.Errorf("wallet mysql is not configured")
}
now := time.Now().UTC()
start, end := periodWindow(query.Period, now)
startMS := start.UnixMilli()
endMS := end.UnixMilli()
total, err := s.count(ctx, appCode, query.BoardType, startMS, endMS)
if err != nil {
return Response{}, err
}
items, err := s.listItems(ctx, appCode, query.BoardType, startMS, endMS, query.PageSize, offset(query.Page, query.PageSize))
if err != nil {
return Response{}, err
}
var myRank *Item
if query.ViewerUserID > 0 && query.BoardType != BoardTypeRoom {
item, found, err := s.rankForUser(ctx, appCode, query.BoardType, startMS, endMS, query.ViewerUserID)
if err != nil {
return Response{}, err
}
if found {
myRank = &item
}
}
if err := s.enrich(ctx, appCode, query.BoardType, items, myRank); err != nil {
return Response{}, err
}
return Response{
Items: items,
Total: total,
Page: query.Page,
PageSize: query.PageSize,
BoardType: query.BoardType,
Period: query.Period,
StartAtMS: startMS,
EndAtMS: endMS,
ServerTimeMS: now.UnixMilli(),
MyRank: myRank,
}, nil
}
func NormalizeBoardType(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", "sent", "send", "sender", "gift_sent", "user_sent":
return BoardTypeSent
case "received", "receive", "receiver", "gift_received", "user_received":
return BoardTypeReceived
case "room", "rooms", "room_gift", "room_gifts":
return BoardTypeRoom
default:
return ""
}
}
func NormalizePeriod(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", "today", "day", "daily":
return PeriodToday
case "week", "weekly":
return PeriodWeek
case "month", "monthly":
return PeriodMonth
default:
return ""
}
}
func NormalizeQuery(query Query) Query {
query.BoardType = NormalizeBoardType(query.BoardType)
query.Period = NormalizePeriod(query.Period)
if query.Page <= 0 {
query.Page = 1
}
if query.PageSize <= 0 {
query.PageSize = 20
}
if query.PageSize > maxPageSize {
query.PageSize = maxPageSize
}
return query
}
func periodWindow(period string, now time.Time) (time.Time, time.Time) {
utcNow := now.UTC()
dayStart := time.Date(utcNow.Year(), utcNow.Month(), utcNow.Day(), 0, 0, 0, 0, time.UTC)
switch period {
case PeriodWeek:
weekday := int(dayStart.Weekday())
if weekday == 0 {
weekday = 7
}
return dayStart.AddDate(0, 0, 1-weekday), utcNow
case PeriodMonth:
return time.Date(utcNow.Year(), utcNow.Month(), 1, 0, 0, 0, 0, time.UTC), utcNow
default:
return dayStart, utcNow
}
}
func (s *Service) count(ctx context.Context, appCode string, boardType string, startMS int64, endMS int64) (int64, error) {
subject, predicate := leaderboardDimension(boardType)
query := fmt.Sprintf(`
WITH gift_facts AS (
SELECT
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.sender_user_id')) AS SIGNED) AS sender_user_id,
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.target_user_id')) AS SIGNED) AS target_user_id,
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.room_id')), '') AS room_id,
COALESCE(
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_point_added')) AS SIGNED),
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_point_amount')) AS SIGNED),
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.charge_amount')) AS SIGNED),
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.coin_spent')) AS SIGNED),
0
) AS gift_value,
COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_count')) AS SIGNED), 0) AS gift_count,
created_at_ms
FROM wallet_transactions
WHERE app_code = ? AND biz_type = 'gift_debit' AND status = 'succeeded'
AND created_at_ms >= ? AND created_at_ms < ?
),
aggregated AS (
SELECT %s AS subject_id
FROM gift_facts
WHERE %s
GROUP BY %s
)
SELECT COUNT(*) FROM aggregated`, subject, predicate, subject)
var total int64
err := s.walletDB.QueryRowContext(ctx, query, appCode, startMS, endMS).Scan(&total)
return total, err
}
func (s *Service) listItems(ctx context.Context, appCode string, boardType string, startMS int64, endMS int64, limit int, offset int) ([]Item, error) {
subject, predicate := leaderboardDimension(boardType)
query := fmt.Sprintf(`
WITH gift_facts AS (
SELECT
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.sender_user_id')) AS SIGNED) AS sender_user_id,
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.target_user_id')) AS SIGNED) AS target_user_id,
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.room_id')), '') AS room_id,
COALESCE(
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_point_added')) AS SIGNED),
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_point_amount')) AS SIGNED),
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.charge_amount')) AS SIGNED),
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.coin_spent')) AS SIGNED),
0
) AS gift_value,
COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_count')) AS SIGNED), 0) AS gift_count,
created_at_ms
FROM wallet_transactions
WHERE app_code = ? AND biz_type = 'gift_debit' AND status = 'succeeded'
AND created_at_ms >= ? AND created_at_ms < ?
),
aggregated AS (
SELECT %s AS subject_id,
SUM(gift_value) AS gift_value,
SUM(gift_count) AS gift_count,
COUNT(*) AS transaction_count,
MAX(created_at_ms) AS last_gift_at_ms
FROM gift_facts
WHERE %s
GROUP BY %s
),
ranked AS (
SELECT ROW_NUMBER() OVER (ORDER BY gift_value DESC, last_gift_at_ms DESC, subject_id ASC) AS rank_no,
subject_id, gift_value, gift_count, transaction_count, last_gift_at_ms
FROM aggregated
)
SELECT rank_no, CAST(subject_id AS CHAR), gift_value, gift_count, transaction_count, last_gift_at_ms
FROM ranked
ORDER BY rank_no ASC
LIMIT ? OFFSET ?`, subject, predicate, subject)
rows, err := s.walletDB.QueryContext(ctx, query, appCode, startMS, endMS, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]Item, 0, limit)
for rows.Next() {
item, err := scanItem(rows, boardType)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Service) rankForUser(ctx context.Context, appCode string, boardType string, startMS int64, endMS int64, userID int64) (Item, bool, error) {
subject, predicate := leaderboardDimension(boardType)
query := fmt.Sprintf(`
WITH gift_facts AS (
SELECT
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.sender_user_id')) AS SIGNED) AS sender_user_id,
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.target_user_id')) AS SIGNED) AS target_user_id,
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.room_id')), '') AS room_id,
COALESCE(
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_point_added')) AS SIGNED),
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_point_amount')) AS SIGNED),
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.charge_amount')) AS SIGNED),
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.coin_spent')) AS SIGNED),
0
) AS gift_value,
COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_count')) AS SIGNED), 0) AS gift_count,
created_at_ms
FROM wallet_transactions
WHERE app_code = ? AND biz_type = 'gift_debit' AND status = 'succeeded'
AND created_at_ms >= ? AND created_at_ms < ?
),
aggregated AS (
SELECT %s AS subject_id,
SUM(gift_value) AS gift_value,
SUM(gift_count) AS gift_count,
COUNT(*) AS transaction_count,
MAX(created_at_ms) AS last_gift_at_ms
FROM gift_facts
WHERE %s
GROUP BY %s
),
ranked AS (
SELECT ROW_NUMBER() OVER (ORDER BY gift_value DESC, last_gift_at_ms DESC, subject_id ASC) AS rank_no,
subject_id, gift_value, gift_count, transaction_count, last_gift_at_ms
FROM aggregated
)
SELECT rank_no, CAST(subject_id AS CHAR), gift_value, gift_count, transaction_count, last_gift_at_ms
FROM ranked
WHERE subject_id = ?
LIMIT 1`, subject, predicate, subject)
row := s.walletDB.QueryRowContext(ctx, query, appCode, startMS, endMS, userID)
item, err := scanItem(row, boardType)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Item{}, false, nil
}
return Item{}, false, err
}
return item, true, nil
}
type itemScanner interface {
Scan(dest ...any) error
}
func scanItem(scanner itemScanner, boardType string) (Item, error) {
var item Item
var subject string
if err := scanner.Scan(&item.Rank, &subject, &item.GiftValue, &item.GiftCount, &item.TransactionCount, &item.LastGiftAtMS); err != nil {
return Item{}, err
}
if boardType == BoardTypeRoom {
item.RoomID = subject
item.Room = &Room{RoomID: subject}
return item, nil
}
item.UserID = subject
item.User = &User{UserID: subject}
return item, nil
}
func leaderboardDimension(boardType string) (string, string) {
switch boardType {
case BoardTypeReceived:
return "target_user_id", "target_user_id > 0"
case BoardTypeRoom:
return "room_id", "room_id <> ''"
default:
return "sender_user_id", "sender_user_id > 0"
}
}
func (s *Service) enrich(ctx context.Context, appCode string, boardType string, items []Item, myRank *Item) error {
if boardType == BoardTypeRoom {
return s.enrichRooms(ctx, appCode, items)
}
userIDs := make([]int64, 0, len(items)+1)
for _, item := range items {
if id, err := strconv.ParseInt(item.UserID, 10, 64); err == nil && id > 0 {
userIDs = append(userIDs, id)
}
}
if myRank != nil {
if id, err := strconv.ParseInt(myRank.UserID, 10, 64); err == nil && id > 0 {
userIDs = append(userIDs, id)
}
}
profiles, err := s.userProfiles(ctx, appCode, userIDs)
if err != nil {
return err
}
applyUserProfiles(items, profiles)
if myRank != nil {
applyUserProfile(myRank, profiles)
}
return nil
}
func (s *Service) userProfiles(ctx context.Context, appCode string, userIDs []int64) (map[int64]userProfile, error) {
result := make(map[int64]userProfile, len(userIDs))
if s == nil || s.userDB == nil || len(userIDs) == 0 {
return result, nil
}
userIDs = uniqueInt64s(userIDs)
args := make([]any, 0, len(userIDs)+1)
args = append(args, appCode)
for _, id := range userIDs {
args = append(args, id)
}
rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(`
SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '')
FROM users
WHERE app_code = ? AND user_id IN (%s)
`, placeholders(len(userIDs))), args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var profile userProfile
if err := rows.Scan(&profile.UserID, &profile.DisplayUserID, &profile.Username, &profile.Avatar); err != nil {
return nil, err
}
result[profile.UserID] = profile
}
return result, rows.Err()
}
func applyUserProfiles(items []Item, profiles map[int64]userProfile) {
for i := range items {
applyUserProfile(&items[i], profiles)
}
}
func applyUserProfile(item *Item, profiles map[int64]userProfile) {
if item == nil {
return
}
id, err := strconv.ParseInt(item.UserID, 10, 64)
if err != nil || id <= 0 {
return
}
if profile, ok := profiles[id]; ok {
item.User = &User{
UserID: strconv.FormatInt(profile.UserID, 10),
DisplayUserID: profile.DisplayUserID,
Username: profile.Username,
Avatar: profile.Avatar,
}
}
}
func (s *Service) enrichRooms(ctx context.Context, appCode string, items []Item) error {
roomIDs := make([]string, 0, len(items))
for _, item := range items {
if item.RoomID != "" {
roomIDs = append(roomIDs, item.RoomID)
}
}
rooms, err := s.roomProfiles(ctx, appCode, roomIDs)
if err != nil {
return err
}
for i := range items {
if room, ok := rooms[items[i].RoomID]; ok {
items[i].Room = &Room{
RoomID: room.RoomID,
RoomShortID: room.RoomShortID,
Title: room.Title,
CoverURL: room.CoverURL,
OwnerUserID: formatOptionalID(room.OwnerUserID),
Status: room.Status,
}
}
}
return nil
}
func (s *Service) roomProfiles(ctx context.Context, appCode string, roomIDs []string) (map[string]roomProfile, error) {
result := make(map[string]roomProfile, len(roomIDs))
if s == nil || s.room == nil || len(roomIDs) == 0 {
return result, nil
}
roomIDs = uniqueStrings(roomIDs)
_ = appCode
for _, id := range roomIDs {
room, err := s.room.GetRoom(ctx, roomclient.GetRoomRequest{RoomID: id})
if err != nil {
return nil, err
}
result[room.RoomID] = roomProfile{
RoomID: room.RoomID,
RoomShortID: room.RoomShortID,
Title: room.Title,
CoverURL: room.CoverURL,
OwnerUserID: room.OwnerUserID,
Status: room.Status,
}
}
return result, nil
}
func offset(page int, pageSize int) int {
if page <= 1 {
return 0
}
return (page - 1) * pageSize
}
func placeholders(n int) string {
if n <= 0 {
return ""
}
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
}
func uniqueInt64s(values []int64) []int64 {
seen := make(map[int64]struct{}, len(values))
out := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func uniqueStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func formatOptionalID(id int64) string {
if id <= 0 {
return ""
}
return strconv.FormatInt(id, 10)
}

View File

@ -0,0 +1,38 @@
package userleaderboard
import (
"testing"
"time"
)
func TestPeriodWindowUsesUTCBoundaries(t *testing.T) {
now := time.Date(2026, 5, 20, 15, 30, 0, 0, time.FixedZone("CST", 8*60*60))
todayStart, todayEnd := periodWindow(PeriodToday, now)
if todayStart != time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC) {
t.Fatalf("today start = %s", todayStart)
}
if !todayEnd.Equal(now.UTC()) {
t.Fatalf("today end = %s", todayEnd)
}
weekStart, _ := periodWindow(PeriodWeek, now)
if weekStart != time.Date(2026, 5, 18, 0, 0, 0, 0, time.UTC) {
t.Fatalf("week start = %s", weekStart)
}
monthStart, _ := periodWindow(PeriodMonth, now)
if monthStart != time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) {
t.Fatalf("month start = %s", monthStart)
}
}
func TestNormalizeQueryDefaultsAndCapsPageSize(t *testing.T) {
query := NormalizeQuery(Query{BoardType: "receiver", Period: "monthly", PageSize: 500})
if query.BoardType != BoardTypeReceived || query.Period != PeriodMonth {
t.Fatalf("query = %+v", query)
}
if query.Page != 1 || query.PageSize != maxPageSize {
t.Fatalf("pagination = page %d pageSize %d", query.Page, query.PageSize)
}
}

View File

@ -0,0 +1,247 @@
package vipconfig
import (
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/gin-gonic/gin"
)
type Handler struct {
wallet walletclient.Client
audit shared.OperationLogger
}
func New(wallet walletclient.Client, audit shared.OperationLogger) *Handler {
return &Handler{wallet: wallet, audit: audit}
}
type configRequest struct {
Levels []vipLevelDTO `json:"levels"`
}
type grantVipRequest struct {
CommandID string `json:"commandId"`
TargetUserID any `json:"targetUserId"`
Level int32 `json:"level"`
Reason string `json:"reason"`
}
type configDTO struct {
Levels []vipLevelDTO `json:"levels"`
ServerTimeMS int64 `json:"serverTimeMs"`
}
type grantVipDTO struct {
TransactionID string `json:"transactionId"`
VIP userVipDTO `json:"vip"`
RewardItems []vipRewardItemDTO `json:"rewardItems"`
ServerTimeMS int64 `json:"serverTimeMs"`
}
type userVipDTO struct {
UserID string `json:"userId"`
Level int32 `json:"level"`
Name string `json:"name"`
Active bool `json:"active"`
StartedAtMS int64 `json:"startedAtMs"`
ExpiresAtMS int64 `json:"expiresAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type vipLevelDTO struct {
Level int32 `json:"level"`
Name string `json:"name"`
Status string `json:"status"`
PriceCoin int64 `json:"priceCoin"`
DurationMS int64 `json:"durationMs"`
RewardResourceGroupID int64 `json:"rewardResourceGroupId"`
SortOrder int32 `json:"sortOrder"`
RechargeGateRequired bool `json:"rechargeGateRequired"`
RequiredRechargeCoinAmount int64 `json:"requiredRechargeCoinAmount"`
UserRechargeCoinAmount int64 `json:"userRechargeCoinAmount"`
PurchaseLockedReason string `json:"purchaseLockedReason"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type vipRewardItemDTO struct {
ResourceID string `json:"resourceId"`
ResourceCode string `json:"resourceCode"`
ResourceType string `json:"resourceType"`
Name string `json:"name"`
Quantity int64 `json:"quantity"`
ExpiresAtMS int64 `json:"expiresAtMs"`
}
func (h *Handler) ListLevels(c *gin.Context) {
resp, err := h.wallet.ListAdminVipLevels(c.Request.Context(), &walletv1.ListAdminVipLevelsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
})
if err != nil {
response.ServerError(c, "获取 VIP 配置失败")
return
}
response.OK(c, configDTO{Levels: vipLevelsFromProto(resp.GetLevels()), ServerTimeMS: resp.GetServerTimeMs()})
}
func (h *Handler) GrantVIP(c *gin.Context) {
var req grantVipRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "VIP 赠送参数不正确")
return
}
targetUserID, ok := parseFlexibleUserID(req.TargetUserID)
if !ok || req.Level <= 0 {
response.BadRequest(c, "VIP 赠送参数不正确")
return
}
reason := strings.TrimSpace(req.Reason)
if reason == "" {
response.BadRequest(c, "赠送原因不能为空")
return
}
commandID := strings.TrimSpace(req.CommandID)
if commandID == "" {
commandID = "admin_vip_grant:" + middleware.CurrentRequestID(c)
}
resp, err := h.wallet.GrantVip(c.Request.Context(), &walletv1.GrantVipRequest{
CommandId: commandID,
AppCode: appctx.FromContext(c.Request.Context()),
TargetUserId: targetUserID,
Level: req.Level,
GrantSource: "admin_grant",
OperatorUserId: int64(middleware.CurrentUserID(c)),
Reason: reason,
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(c, h.audit, "grant-vip", "user_vip_memberships", strconv.FormatInt(targetUserID, 10), "success", "level="+strconv.Itoa(int(req.Level)))
response.Created(c, grantVipFromProto(resp))
}
func (h *Handler) UpdateLevels(c *gin.Context) {
var req configRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "VIP 配置参数不正确")
return
}
input := make([]*walletv1.AdminVipLevelInput, 0, len(req.Levels))
for _, level := range req.Levels {
input = append(input, &walletv1.AdminVipLevelInput{
Level: level.Level,
Name: strings.TrimSpace(level.Name),
Status: strings.TrimSpace(level.Status),
PriceCoin: level.PriceCoin,
DurationMs: level.DurationMS,
RewardResourceGroupId: level.RewardResourceGroupID,
RequiredRechargeCoinAmount: level.RequiredRechargeCoinAmount,
SortOrder: level.SortOrder,
})
}
resp, err := h.wallet.UpdateAdminVipLevels(c.Request.Context(), &walletv1.UpdateAdminVipLevelsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
Levels: input,
OperatorUserId: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(c, h.audit, "update-vip-config", "vip_levels", appctx.FromContext(c.Request.Context()), "success", strconv.Itoa(len(resp.GetLevels())))
response.OK(c, configDTO{Levels: vipLevelsFromProto(resp.GetLevels()), ServerTimeMS: resp.GetServerTimeMs()})
}
func vipLevelsFromProto(items []*walletv1.VipLevel) []vipLevelDTO {
levels := make([]vipLevelDTO, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
levels = append(levels, vipLevelDTO{
Level: item.GetLevel(),
Name: item.GetName(),
Status: item.GetStatus(),
PriceCoin: item.GetPriceCoin(),
DurationMS: item.GetDurationMs(),
RewardResourceGroupID: item.GetRewardResourceGroupId(),
SortOrder: item.GetSortOrder(),
RechargeGateRequired: item.GetRechargeGateRequired(),
RequiredRechargeCoinAmount: item.GetRequiredRechargeCoinAmount(),
UserRechargeCoinAmount: item.GetUserRechargeCoinAmount(),
PurchaseLockedReason: item.GetPurchaseLockedReason(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
})
}
return levels
}
func grantVipFromProto(resp *walletv1.GrantVipResponse) grantVipDTO {
if resp == nil {
return grantVipDTO{}
}
return grantVipDTO{
TransactionID: resp.GetTransactionId(),
VIP: userVipFromProto(resp.GetVip()),
RewardItems: vipRewardItemsFromProto(resp.GetRewardItems()),
ServerTimeMS: resp.GetServerTimeMs(),
}
}
func userVipFromProto(item *walletv1.UserVip) userVipDTO {
if item == nil {
return userVipDTO{}
}
return userVipDTO{
UserID: strconv.FormatInt(item.GetUserId(), 10),
Level: item.GetLevel(),
Name: item.GetName(),
Active: item.GetActive(),
StartedAtMS: item.GetStartedAtMs(),
ExpiresAtMS: item.GetExpiresAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func vipRewardItemsFromProto(items []*walletv1.VipRewardItem) []vipRewardItemDTO {
rewards := make([]vipRewardItemDTO, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
rewards = append(rewards, vipRewardItemDTO{
ResourceID: strconv.FormatInt(item.GetResourceId(), 10),
ResourceCode: item.GetResourceCode(),
ResourceType: item.GetResourceType(),
Name: item.GetName(),
Quantity: item.GetQuantity(),
ExpiresAtMS: item.GetExpiresAtMs(),
})
}
return rewards
}
func parseFlexibleUserID(value any) (int64, bool) {
switch typed := value.(type) {
case float64:
userID := int64(typed)
return userID, userID > 0 && float64(userID) == typed
case string:
userID, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
return userID, err == nil && userID > 0
default:
return 0, false
}
}

View File

@ -0,0 +1,17 @@
package vipconfig
import (
"hyapp-admin-server/internal/middleware"
"github.com/gin-gonic/gin"
)
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
if h == nil {
return
}
protected.GET("/admin/activity/vip-levels", middleware.RequirePermission("vip-config:view"), h.ListLevels)
protected.PUT("/admin/activity/vip-levels", middleware.RequirePermission("vip-config:update"), h.UpdateLevels)
protected.POST("/admin/activity/vip-grants", middleware.RequirePermission("vip-config:grant"), h.GrantVIP)
}

View File

@ -15,12 +15,36 @@ type AppVersionListOptions struct {
Keyword string
}
type AppExploreTabListOptions struct {
AppCode string
Keyword string
Enabled *bool
}
func (s *Store) ListAppConfigs(group string) ([]model.AppConfig, error) {
var items []model.AppConfig
err := s.db.Where("`group` = ?", strings.TrimSpace(group)).Order("`key` ASC").Find(&items).Error
return items, err
}
func (s *Store) GetAppConfig(group string, key string) (model.AppConfig, error) {
var item model.AppConfig
err := s.db.Where("`group` = ? AND `key` = ?", strings.TrimSpace(group), strings.TrimSpace(key)).First(&item).Error
return item, err
}
func (s *Store) CreateAppConfig(item *model.AppConfig) error {
return s.db.Create(item).Error
}
func (s *Store) UpdateAppConfig(item *model.AppConfig) error {
return s.db.Save(item).Error
}
func (s *Store) DeleteAppConfig(group string, key string) error {
return s.db.Where("`group` = ? AND `key` = ?", strings.TrimSpace(group), strings.TrimSpace(key)).Delete(&model.AppConfig{}).Error
}
func (s *Store) UpsertAppConfigs(items []model.AppConfig) error {
if len(items) == 0 {
return nil
@ -77,3 +101,36 @@ func (s *Store) UpdateAppVersion(item *model.AppVersion) error {
func (s *Store) DeleteAppVersion(appCode string, id uint) error {
return s.db.Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).Delete(&model.AppVersion{}).Error
}
func (s *Store) ListAppExploreTabs(options AppExploreTabListOptions) ([]model.AppExploreTab, error) {
db := s.db.Where("app_code = ?", strings.TrimSpace(options.AppCode))
if options.Enabled != nil {
db = db.Where("enabled = ?", *options.Enabled)
}
if keyword := strings.TrimSpace(options.Keyword); keyword != "" {
like := "%" + keyword + "%"
db = db.Where("tab LIKE ? OR h5_url LIKE ?", like, like)
}
var items []model.AppExploreTab
err := db.Order("sort_order ASC, id DESC").Find(&items).Error
return items, err
}
func (s *Store) GetAppExploreTab(appCode string, id uint) (model.AppExploreTab, error) {
var item model.AppExploreTab
err := s.db.Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).First(&item).Error
return item, err
}
func (s *Store) CreateAppExploreTab(item *model.AppExploreTab) error {
return s.db.Create(item).Error
}
func (s *Store) UpdateAppExploreTab(item *model.AppExploreTab) error {
return s.db.Save(item).Error
}
func (s *Store) DeleteAppExploreTab(appCode string, id uint) error {
return s.db.Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).Delete(&model.AppExploreTab{}).Error
}

View File

@ -65,6 +65,7 @@ func (s *Store) AutoMigrate() error {
&model.AppConfig{},
&model.AppBanner{},
&model.AppVersion{},
&model.AppExploreTab{},
&model.RefreshToken{},
&model.LoginLog{},
&model.OperationLog{},

Some files were not shown because too many files have changed in this diff Show More