完善接口

This commit is contained in:
zhx 2026-05-25 19:36:27 +08:00
parent 0bfe1cf57d
commit 1b3cf31a7d
57 changed files with 2310 additions and 293 deletions

View File

@ -21,10 +21,23 @@
- `notice-service` 负责从各 owner service 的 outbox/事件源读取事实并投递私有通知、腾讯云 IM 单聊、后续 push/inbox钱包、活动和房间不能直接耦合私有通知投递实现。 - `notice-service` 负责从各 owner service 的 outbox/事件源读取事实并投递私有通知、腾讯云 IM 单聊、后续 push/inbox钱包、活动和房间不能直接耦合私有通知投递实现。
- `user-service` 负责用户主数据,不应该成为房间高频流程的同步中心。 - `user-service` 负责用户主数据,不应该成为房间高频流程的同步中心。
- `cron-service` 只负责后台调度、任务运行记录和通过内部 gRPC 触发 owner 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 或明确的集成接口。 - `server/admin` 是后台管理服务独立 module负责管理员、RBAC、菜单、审计和后台 HTTP API访问 app 后端只能通过 `hyapp.local/api` 生成的 protobuf client 或明确的集成接口。
- `api` 是独立 Go modulemodule path 为 `hyapp.local/api`,只放 protobuf contract 和生成代码,不放服务实现或业务工具包。 - `api` 是独立 Go modulemodule path 为 `hyapp.local/api`,只放 protobuf contract 和生成代码,不放服务实现或业务工具包。
- `wallet_outbox``room_outbox` 只能由各自 owner service 直接读取并发布到 RocketMQ其他服务不能配置 `wallet_mysql_dsn``room_mysql_dsn` 或直连 owner 数据库扫描 outbox。 - `wallet_outbox``room_outbox``user_outbox``game_outbox` 只能由各自 owner service 直接读取并发布到 RocketMQ其他服务不能配置 owner 数据库 DSN 或直连 owner 数据库扫描 outbox。
- 下游服务消费 wallet/room outbox 必须使用自己的 RocketMQ consumer group 和本服务幂等表;消费位点属于消息系统和本地幂等事实,不能共享 owner service 的 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 ## Persistence Rules
@ -32,6 +45,7 @@
- `memory` repository/directory 只用于单元测试或明确的本地隔离测试。 - `memory` repository/directory 只用于单元测试或明确的本地隔离测试。
- MySQL 是房间恢复的持久来源:`rooms``room_snapshots``room_command_log``room_outbox` - MySQL 是房间恢复的持久来源:`rooms``room_snapshots``room_command_log``room_outbox`
- `wallet-service``wallet_outbox` 是账务事实的持久来源;它负责补偿发布到 RocketMQ下游不得把钱包库当作事件查询库。 - `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 等热状态,不能作为唯一恢复来源。
- Redis lease 更新必须保持原子性,不能改回非原子的 GET + SET 接管逻辑。 - Redis lease 更新必须保持原子性,不能改回非原子的 GET + SET 接管逻辑。
@ -68,6 +82,7 @@
- `13007`: cron gRPC - `13007`: cron gRPC
- `13008`: game gRPC - `13008`: game gRPC
- `13009`: notice gRPC - `13009`: notice gRPC
- `13010`: statistics query HTTP
- `23306`: local Docker MySQL host port - `23306`: local Docker MySQL host port
- `13379`: local Docker Redis host port - `13379`: local Docker Redis host port

View File

@ -15,6 +15,7 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
- `activity-service` 当前承接每日任务、活动/系统消息基础能力和房间事件 consumer可通过 room outbox direct gRPC 或 RocketMQ `hyapp_room_outbox` 独立 consumer group 消费房间事实。 - `activity-service` 当前承接每日任务、活动/系统消息基础能力和房间事件 consumer可通过 room outbox direct gRPC 或 RocketMQ `hyapp_room_outbox` 独立 consumer group 消费房间事实。
- `cron-service` 当前负责后台调度、任务运行记录,以及通过内部 gRPC 触发 owner service 批处理;它不直接拥有房间、账务、用户或活动业务状态。 - `cron-service` 当前负责后台调度、任务运行记录,以及通过内部 gRPC 触发 owner service 批处理;它不直接拥有房间、账务、用户或活动业务状态。
- `notice-service` 当前消费 `wallet_outbox` 的余额变更事件和 `room_outbox` 的踢人事件,异步投递腾讯云 IM 单聊私有通知;房间群消息和群成员控制仍由 `room-service` 的房间 IM bridge 处理MQ 模式下通过独立 consumer group 解耦。 - `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` - 腾讯云 IM 承担客户端长连接、群消息、公屏、单聊、离线/漫游和全球接入能力;本仓库不再部署 `im-service`
- 多 App 共用同一套后端服务,入口由 `gateway-service` 通过包名或 `app_code` 解析租户;所有业务库表按 `app_code` 隔离,设计细节见 `docs/多App租户架构.md` - 多 App 共用同一套后端服务,入口由 `gateway-service` 通过包名或 `app_code` 解析租户;所有业务库表按 `app_code` 隔离,设计细节见 `docs/多App租户架构.md`
@ -23,9 +24,10 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
- 客户端命令面:`gateway-service` 暴露 HTTP JSON。 - 客户端命令面:`gateway-service` 暴露 HTTP JSON。
- 客户端实时面:腾讯云 IM SDK客户端用 gateway 签发的 UserSig 登录。 - 客户端实时面:腾讯云 IM SDK客户端用 gateway 签发的 UserSig 登录。
- 服务内部调用gRPC + protobuf契约在独立 Go module `api`,业务代码通过 `hyapp.local/api/proto/...` 引用生成包。 - 服务内部调用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` 并发布 RocketMQ`wallet-service` 读取 `wallet_outbox` 并发布 RocketMQ其他服务不能通过 `room_mysql_dsn``wallet_mysql_dsn` 直连 owner 数据库扫描 outbox。 - outbox 读取权只属于 owner service。`room-service` 读取 `room_outbox``wallet-service` 读取 `wallet_outbox``user-service` 读取 `user_outbox``game-service` 读取 `game_outbox` 并发布 RocketMQ其他服务不能直连 owner 数据库扫描 outbox。
- 下游消费 wallet/room outbox 必须使用本服务独立 RocketMQ consumer group并在本服务库内用幂等表或唯一键记录已消费 `event_id`;消费位点不能写回或复用 owner service 的 outbox 状态。 - 下游消费 owner outbox 必须使用本服务独立 RocketMQ consumer group并在本服务库内用幂等表或唯一键记录已消费 `event_id`;消费位点不能写回或复用 owner service 的 outbox 状态。
- 后台统计查询面:`server/admin``statistics-service` 内部 HTTP API`gateway-service` 是 App 入口,除非有明确 App 产品需求,否则不暴露后台数据大屏统计。
## Time Model ## Time Model
@ -51,9 +53,40 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
| `cron-service` | `13007` | gRPC | | `cron-service` | `13007` | gRPC |
| `game-service` | `13008` | gRPC | | `game-service` | `13008` | gRPC |
| `notice-service` | `13009` | gRPC | | `notice-service` | `13009` | gRPC |
| `statistics-service` | `13010` | internal query HTTP |
| `statistics-service` | `13110` | health HTTP |
| MySQL | `23306 -> 3306` | local Docker only | | MySQL | `23306 -> 3306` | local Docker only |
| Redis | `13379 -> 6379` | 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 ## Run Locally
Use Docker for local and test environments: Use Docker for local and test environments:
@ -134,12 +167,12 @@ docs/flutter对接/Flutter App 通知与钱包余额对接.md
## RocketMQ ## RocketMQ
本地默认不依赖 RocketMQ`room-service` 可使用 `outbox_worker.publish_mode=direct` 直连腾讯云 IM publisher 和 activity gRPC publisher。线上推荐启用腾讯云 RocketMQ并把 room/wallet 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` 读取和发布。 - `room_outbox` 只由 `room-service` 读取和发布;`wallet_outbox` 只由 `wallet-service` 读取和发布`user_outbox` 只由 `user-service` 读取和发布;`game_outbox` 只由 `game-service` 读取和发布
- `user-service`、`activity-service``notice-service``cron-service` 等非 owner service 不能配置 `room_mysql_dsn``wallet_mysql_dsn` 或扫描其他服务 outbox 表。 - `statistics-service`、`activity-service``notice-service``cron-service` 等非 owner service 不能配置 owner 数据库 DSN 或扫描其他服务 outbox 表。
- 每个业务消费者使用独立 consumer group。即使消费同一个 topic不同业务也不能共用 group否则会互相抢消息。 - 每个业务消费者使用独立 consumer group。即使消费同一个 topic不同业务也不能共用 group否则会互相抢消息。
- 每个消费者在自己的库内维护幂等事实,例如按 `app_code + event_id` 或业务唯一键建唯一约束RocketMQ 的消费位点只解决投递进度,不替代业务幂等。 - 每个消费者在自己的库内维护幂等事实,例如按 `app_code + event_id` 或业务唯一键建唯一约束RocketMQ 的消费位点只解决投递进度,不替代业务幂等。
- owner service 的 MySQL outbox 仍是可靠事实源。RocketMQ 发布失败由 owner outbox worker 补偿重试;下游处理失败由各自 consumer group 重投和本地幂等表收敛。 - owner service 的 MySQL outbox 仍是可靠事实源。RocketMQ 发布失败由 owner outbox worker 补偿重试;下游处理失败由各自 consumer group 重投和本地幂等表收敛。
@ -152,13 +185,17 @@ docs/flutter对接/Flutter App 通知与钱包余额对接.md
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-notice-room-outbox` | notice-service 消费 `RoomUserKicked` 等私有通知事实 | | `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-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-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-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-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-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-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 开箱命令 | | `hyapp_room_treasure_open` | `hyapp-room-treasure-open-producer` | `hyapp-room-treasure-open` | 宝箱倒计时到点唤醒 room-service 开箱命令 |
RocketMQ 不拥有房间或账务状态。`room_outbox``wallet_outbox` 仍是各 owner service 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 ## Storage Model
@ -171,6 +208,7 @@ MySQL 是本地和线上运行的持久化底座:
- `cron-service`: `hyapp_cron`,保存后台任务定义、运行记录和调度锁;实际业务批处理仍由各 owner service 执行。 - `cron-service`: `hyapp_cron`,保存后台任务定义、运行记录和调度锁;实际业务批处理仍由各 owner service 执行。
- `game-service`: `hyapp_game`,保存游戏平台、房间游戏会话和游戏交易映射。 - `game-service`: `hyapp_game`,保存游戏平台、房间游戏会话和游戏交易映射。
- `notice-service`: `hyapp_notice`,保存外部通知投递位点、重试锁和死信状态;业务事实仍留在 owner service outbox。 - `notice-service`: `hyapp_notice`,保存外部通知投递位点、重试锁和死信状态;业务事实仍留在 owner service outbox。
- `statistics-service`: `hyapp_statistics`,保存事件消费幂等和数据大屏聚合读模型;禁止把 owner service 明细表当作统计查询来源。
- `hyapp_admin`: 后台管理后端独立库,只保存后台账号、权限和审计;不要把后台审计耦合进 App 业务库。 - `hyapp_admin`: 后台管理后端独立库,只保存后台账号、权限和审计;不要把后台审计耦合进 App 业务库。
`room-service` uses MySQL as the room recovery durable source: `room-service` uses MySQL as the room recovery durable source:
@ -220,6 +258,7 @@ services/activity-service/ activity foundation and room event consumer boundary
services/cron-service/ background scheduling and owner-service batch trigger services/cron-service/ background scheduling and owner-service batch trigger
services/game-service/ game session and wallet integration services/game-service/ game session and wallet integration
services/notice-service/ async user notice delivery workers services/notice-service/ async user notice delivery workers
services/statistics-service/ data dashboard event consumers and aggregate query API
``` ```
## Current Limits ## Current Limits
@ -230,4 +269,5 @@ services/notice-service/ async user notice delivery workers
- `notice-service` 本地默认不启用腾讯云 IM worker线上启用钱包通知或房间通知 RocketMQ consumer 时,必须同时启用 `tencent_im.enabled=true`,且不能直连 wallet/room 数据库扫描 outbox。 - `notice-service` 本地默认不启用腾讯云 IM worker线上启用钱包通知或房间通知 RocketMQ consumer 时,必须同时启用 `tencent_im.enabled=true`,且不能直连 wallet/room 数据库扫描 outbox。
- `activity-service` 已承接每日任务、消息/活动基础能力和 room outbox consumer本地可走 direct gRPC线上推荐走 RocketMQ `hyapp_room_outbox` - `activity-service` 已承接每日任务、消息/活动基础能力和 room outbox consumer本地可走 direct gRPC线上推荐走 RocketMQ `hyapp_room_outbox`
- `room-service` App 已启动 `room_outbox` 补偿 worker`direct` 模式表示直连 publisher 完成,`mq` 模式表示事件已发布到 RocketMQ下游成功由各服务消费位点保证。 - `room-service` App 已启动 `room_outbox` 补偿 worker`direct` 模式表示直连 publisher 完成,`mq` 模式表示事件已发布到 RocketMQ下游成功由各服务消费位点保证。
- `statistics-service` 只保证从已发布 MQ 事件生成聚合表;如果历史库没有对应 outbox 事实,例如早期用户没有 `UserRegistered`、本地没有 `game_outbox`,统计结果会按当前事实为 0不允许临时扫业务表补数。
- JWT、腾讯云 IM secret 等本地配置只能用开发值,线上必须走安全配置源。 - JWT、腾讯云 IM secret 等本地配置只能用开发值,线上必须走安全配置源。

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v5.26.1 // protoc v7.35.0
// source: proto/activity/v1/activity.proto // source: proto/activity/v1/activity.proto
package activityv1 package activityv1
@ -11415,6 +11415,274 @@ func (x *ListLuckyGiftDrawsResponse) GetTotal() int64 {
return 0 return 0
} }
type LuckyGiftDrawSummary struct {
state protoimpl.MessageState `protogen:"open.v1"`
PoolId string `protobuf:"bytes,1,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
TotalDraws int64 `protobuf:"varint,2,opt,name=total_draws,json=totalDraws,proto3" json:"total_draws,omitempty"`
UniqueUsers int64 `protobuf:"varint,3,opt,name=unique_users,json=uniqueUsers,proto3" json:"unique_users,omitempty"`
UniqueRooms int64 `protobuf:"varint,4,opt,name=unique_rooms,json=uniqueRooms,proto3" json:"unique_rooms,omitempty"`
TotalSpentCoins int64 `protobuf:"varint,5,opt,name=total_spent_coins,json=totalSpentCoins,proto3" json:"total_spent_coins,omitempty"`
TotalRewardCoins int64 `protobuf:"varint,6,opt,name=total_reward_coins,json=totalRewardCoins,proto3" json:"total_reward_coins,omitempty"`
BaseRewardCoins int64 `protobuf:"varint,7,opt,name=base_reward_coins,json=baseRewardCoins,proto3" json:"base_reward_coins,omitempty"`
RoomAtmosphereRewardCoins int64 `protobuf:"varint,8,opt,name=room_atmosphere_reward_coins,json=roomAtmosphereRewardCoins,proto3" json:"room_atmosphere_reward_coins,omitempty"`
ActivitySubsidyCoins int64 `protobuf:"varint,9,opt,name=activity_subsidy_coins,json=activitySubsidyCoins,proto3" json:"activity_subsidy_coins,omitempty"`
ActualRtpPpm int64 `protobuf:"varint,10,opt,name=actual_rtp_ppm,json=actualRtpPpm,proto3" json:"actual_rtp_ppm,omitempty"`
PendingDraws int64 `protobuf:"varint,11,opt,name=pending_draws,json=pendingDraws,proto3" json:"pending_draws,omitempty"`
GrantedDraws int64 `protobuf:"varint,12,opt,name=granted_draws,json=grantedDraws,proto3" json:"granted_draws,omitempty"`
FailedDraws int64 `protobuf:"varint,13,opt,name=failed_draws,json=failedDraws,proto3" json:"failed_draws,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LuckyGiftDrawSummary) Reset() {
*x = LuckyGiftDrawSummary{}
mi := &file_proto_activity_v1_activity_proto_msgTypes[141]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LuckyGiftDrawSummary) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LuckyGiftDrawSummary) ProtoMessage() {}
func (x *LuckyGiftDrawSummary) ProtoReflect() protoreflect.Message {
mi := &file_proto_activity_v1_activity_proto_msgTypes[141]
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 LuckyGiftDrawSummary.ProtoReflect.Descriptor instead.
func (*LuckyGiftDrawSummary) Descriptor() ([]byte, []int) {
return file_proto_activity_v1_activity_proto_rawDescGZIP(), []int{141}
}
func (x *LuckyGiftDrawSummary) GetPoolId() string {
if x != nil {
return x.PoolId
}
return ""
}
func (x *LuckyGiftDrawSummary) GetTotalDraws() int64 {
if x != nil {
return x.TotalDraws
}
return 0
}
func (x *LuckyGiftDrawSummary) GetUniqueUsers() int64 {
if x != nil {
return x.UniqueUsers
}
return 0
}
func (x *LuckyGiftDrawSummary) GetUniqueRooms() int64 {
if x != nil {
return x.UniqueRooms
}
return 0
}
func (x *LuckyGiftDrawSummary) GetTotalSpentCoins() int64 {
if x != nil {
return x.TotalSpentCoins
}
return 0
}
func (x *LuckyGiftDrawSummary) GetTotalRewardCoins() int64 {
if x != nil {
return x.TotalRewardCoins
}
return 0
}
func (x *LuckyGiftDrawSummary) GetBaseRewardCoins() int64 {
if x != nil {
return x.BaseRewardCoins
}
return 0
}
func (x *LuckyGiftDrawSummary) GetRoomAtmosphereRewardCoins() int64 {
if x != nil {
return x.RoomAtmosphereRewardCoins
}
return 0
}
func (x *LuckyGiftDrawSummary) GetActivitySubsidyCoins() int64 {
if x != nil {
return x.ActivitySubsidyCoins
}
return 0
}
func (x *LuckyGiftDrawSummary) GetActualRtpPpm() int64 {
if x != nil {
return x.ActualRtpPpm
}
return 0
}
func (x *LuckyGiftDrawSummary) GetPendingDraws() int64 {
if x != nil {
return x.PendingDraws
}
return 0
}
func (x *LuckyGiftDrawSummary) GetGrantedDraws() int64 {
if x != nil {
return x.GrantedDraws
}
return 0
}
func (x *LuckyGiftDrawSummary) GetFailedDraws() int64 {
if x != nil {
return x.FailedDraws
}
return 0
}
type GetLuckyGiftDrawSummaryRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
GiftId string `protobuf:"bytes,2,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"`
UserId int64 `protobuf:"varint,3,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
RoomId string `protobuf:"bytes,4,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"`
Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"`
PoolId string `protobuf:"bytes,6,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetLuckyGiftDrawSummaryRequest) Reset() {
*x = GetLuckyGiftDrawSummaryRequest{}
mi := &file_proto_activity_v1_activity_proto_msgTypes[142]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetLuckyGiftDrawSummaryRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetLuckyGiftDrawSummaryRequest) ProtoMessage() {}
func (x *GetLuckyGiftDrawSummaryRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_activity_v1_activity_proto_msgTypes[142]
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 GetLuckyGiftDrawSummaryRequest.ProtoReflect.Descriptor instead.
func (*GetLuckyGiftDrawSummaryRequest) Descriptor() ([]byte, []int) {
return file_proto_activity_v1_activity_proto_rawDescGZIP(), []int{142}
}
func (x *GetLuckyGiftDrawSummaryRequest) GetMeta() *RequestMeta {
if x != nil {
return x.Meta
}
return nil
}
func (x *GetLuckyGiftDrawSummaryRequest) GetGiftId() string {
if x != nil {
return x.GiftId
}
return ""
}
func (x *GetLuckyGiftDrawSummaryRequest) GetUserId() int64 {
if x != nil {
return x.UserId
}
return 0
}
func (x *GetLuckyGiftDrawSummaryRequest) GetRoomId() string {
if x != nil {
return x.RoomId
}
return ""
}
func (x *GetLuckyGiftDrawSummaryRequest) GetStatus() string {
if x != nil {
return x.Status
}
return ""
}
func (x *GetLuckyGiftDrawSummaryRequest) GetPoolId() string {
if x != nil {
return x.PoolId
}
return ""
}
type GetLuckyGiftDrawSummaryResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Summary *LuckyGiftDrawSummary `protobuf:"bytes,1,opt,name=summary,proto3" json:"summary,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetLuckyGiftDrawSummaryResponse) Reset() {
*x = GetLuckyGiftDrawSummaryResponse{}
mi := &file_proto_activity_v1_activity_proto_msgTypes[143]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetLuckyGiftDrawSummaryResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetLuckyGiftDrawSummaryResponse) ProtoMessage() {}
func (x *GetLuckyGiftDrawSummaryResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_activity_v1_activity_proto_msgTypes[143]
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 GetLuckyGiftDrawSummaryResponse.ProtoReflect.Descriptor instead.
func (*GetLuckyGiftDrawSummaryResponse) Descriptor() ([]byte, []int) {
return file_proto_activity_v1_activity_proto_rawDescGZIP(), []int{143}
}
func (x *GetLuckyGiftDrawSummaryResponse) GetSummary() *LuckyGiftDrawSummary {
if x != nil {
return x.Summary
}
return nil
}
var File_proto_activity_v1_activity_proto protoreflect.FileDescriptor var File_proto_activity_v1_activity_proto protoreflect.FileDescriptor
const file_proto_activity_v1_activity_proto_rawDesc = "" + const file_proto_activity_v1_activity_proto_rawDesc = "" +
@ -12461,7 +12729,32 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
"\apool_id\x18\b \x01(\tR\x06poolId\"p\n" + "\apool_id\x18\b \x01(\tR\x06poolId\"p\n" +
"\x1aListLuckyGiftDrawsResponse\x12<\n" + "\x1aListLuckyGiftDrawsResponse\x12<\n" +
"\x05draws\x18\x01 \x03(\v2&.hyapp.activity.v1.LuckyGiftDrawResultR\x05draws\x12\x14\n" + "\x05draws\x18\x01 \x03(\v2&.hyapp.activity.v1.LuckyGiftDrawResultR\x05draws\x12\x14\n" +
"\x05total\x18\x02 \x01(\x03R\x05total2\xe2\x01\n" + "\x05total\x18\x02 \x01(\x03R\x05total\"\xa6\x04\n" +
"\x14LuckyGiftDrawSummary\x12\x17\n" +
"\apool_id\x18\x01 \x01(\tR\x06poolId\x12\x1f\n" +
"\vtotal_draws\x18\x02 \x01(\x03R\n" +
"totalDraws\x12!\n" +
"\funique_users\x18\x03 \x01(\x03R\vuniqueUsers\x12!\n" +
"\funique_rooms\x18\x04 \x01(\x03R\vuniqueRooms\x12*\n" +
"\x11total_spent_coins\x18\x05 \x01(\x03R\x0ftotalSpentCoins\x12,\n" +
"\x12total_reward_coins\x18\x06 \x01(\x03R\x10totalRewardCoins\x12*\n" +
"\x11base_reward_coins\x18\a \x01(\x03R\x0fbaseRewardCoins\x12?\n" +
"\x1croom_atmosphere_reward_coins\x18\b \x01(\x03R\x19roomAtmosphereRewardCoins\x124\n" +
"\x16activity_subsidy_coins\x18\t \x01(\x03R\x14activitySubsidyCoins\x12$\n" +
"\x0eactual_rtp_ppm\x18\n" +
" \x01(\x03R\factualRtpPpm\x12#\n" +
"\rpending_draws\x18\v \x01(\x03R\fpendingDraws\x12#\n" +
"\rgranted_draws\x18\f \x01(\x03R\fgrantedDraws\x12!\n" +
"\ffailed_draws\x18\r \x01(\x03R\vfailedDraws\"\xd0\x01\n" +
"\x1eGetLuckyGiftDrawSummaryRequest\x122\n" +
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x17\n" +
"\agift_id\x18\x02 \x01(\tR\x06giftId\x12\x17\n" +
"\auser_id\x18\x03 \x01(\x03R\x06userId\x12\x17\n" +
"\aroom_id\x18\x04 \x01(\tR\x06roomId\x12\x16\n" +
"\x06status\x18\x05 \x01(\tR\x06status\x12\x17\n" +
"\apool_id\x18\x06 \x01(\tR\x06poolId\"d\n" +
"\x1fGetLuckyGiftDrawSummaryResponse\x12A\n" +
"\asummary\x18\x01 \x01(\v2'.hyapp.activity.v1.LuckyGiftDrawSummaryR\asummary2\xe2\x01\n" +
"\x0fActivityService\x12_\n" + "\x0fActivityService\x12_\n" +
"\fPingActivity\x12&.hyapp.activity.v1.PingActivityRequest\x1a'.hyapp.activity.v1.PingActivityResponse\x12n\n" + "\fPingActivity\x12&.hyapp.activity.v1.PingActivityRequest\x1a'.hyapp.activity.v1.PingActivityResponse\x12n\n" +
"\x11GetActivityStatus\x12+.hyapp.activity.v1.GetActivityStatusRequest\x1a,.hyapp.activity.v1.GetActivityStatusResponse2\xb1\x06\n" + "\x11GetActivityStatus\x12+.hyapp.activity.v1.GetActivityStatusRequest\x1a,.hyapp.activity.v1.GetActivityStatusResponse2\xb1\x06\n" +
@ -12534,12 +12827,13 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
"\x0fUpsertLevelTier\x12).hyapp.activity.v1.UpsertLevelTierRequest\x1a*.hyapp.activity.v1.UpsertLevelTierResponse2\x9f\x02\n" + "\x0fUpsertLevelTier\x12).hyapp.activity.v1.UpsertLevelTierRequest\x1a*.hyapp.activity.v1.UpsertLevelTierResponse2\x9f\x02\n" +
"\x17AdminAchievementService\x12u\n" + "\x17AdminAchievementService\x12u\n" +
"\x1aListAchievementDefinitions\x12*.hyapp.activity.v1.ListAchievementsRequest\x1a+.hyapp.activity.v1.ListAchievementsResponse\x12\x8c\x01\n" + "\x1aListAchievementDefinitions\x12*.hyapp.activity.v1.ListAchievementsRequest\x1a+.hyapp.activity.v1.ListAchievementsResponse\x12\x8c\x01\n" +
"\x1bUpsertAchievementDefinition\x125.hyapp.activity.v1.UpsertAchievementDefinitionRequest\x1a6.hyapp.activity.v1.UpsertAchievementDefinitionResponse2\xf2\x03\n" + "\x1bUpsertAchievementDefinition\x125.hyapp.activity.v1.UpsertAchievementDefinitionRequest\x1a6.hyapp.activity.v1.UpsertAchievementDefinitionResponse2\xf5\x04\n" +
"\x15AdminLuckyGiftService\x12q\n" + "\x15AdminLuckyGiftService\x12q\n" +
"\x12GetLuckyGiftConfig\x12,.hyapp.activity.v1.GetLuckyGiftConfigRequest\x1a-.hyapp.activity.v1.GetLuckyGiftConfigResponse\x12z\n" + "\x12GetLuckyGiftConfig\x12,.hyapp.activity.v1.GetLuckyGiftConfigRequest\x1a-.hyapp.activity.v1.GetLuckyGiftConfigResponse\x12z\n" +
"\x15UpsertLuckyGiftConfig\x12/.hyapp.activity.v1.UpsertLuckyGiftConfigRequest\x1a0.hyapp.activity.v1.UpsertLuckyGiftConfigResponse\x12w\n" + "\x15UpsertLuckyGiftConfig\x12/.hyapp.activity.v1.UpsertLuckyGiftConfigRequest\x1a0.hyapp.activity.v1.UpsertLuckyGiftConfigResponse\x12w\n" +
"\x14ListLuckyGiftConfigs\x12..hyapp.activity.v1.ListLuckyGiftConfigsRequest\x1a/.hyapp.activity.v1.ListLuckyGiftConfigsResponse\x12q\n" + "\x14ListLuckyGiftConfigs\x12..hyapp.activity.v1.ListLuckyGiftConfigsRequest\x1a/.hyapp.activity.v1.ListLuckyGiftConfigsResponse\x12q\n" +
"\x12ListLuckyGiftDraws\x12,.hyapp.activity.v1.ListLuckyGiftDrawsRequest\x1a-.hyapp.activity.v1.ListLuckyGiftDrawsResponseB.Z,hyapp.local/api/proto/activity/v1;activityv1b\x06proto3" "\x12ListLuckyGiftDraws\x12,.hyapp.activity.v1.ListLuckyGiftDrawsRequest\x1a-.hyapp.activity.v1.ListLuckyGiftDrawsResponse\x12\x80\x01\n" +
"\x17GetLuckyGiftDrawSummary\x121.hyapp.activity.v1.GetLuckyGiftDrawSummaryRequest\x1a2.hyapp.activity.v1.GetLuckyGiftDrawSummaryResponseB.Z,hyapp.local/api/proto/activity/v1;activityv1b\x06proto3"
var ( var (
file_proto_activity_v1_activity_proto_rawDescOnce sync.Once file_proto_activity_v1_activity_proto_rawDescOnce sync.Once
@ -12553,7 +12847,7 @@ func file_proto_activity_v1_activity_proto_rawDescGZIP() []byte {
return file_proto_activity_v1_activity_proto_rawDescData return file_proto_activity_v1_activity_proto_rawDescData
} }
var file_proto_activity_v1_activity_proto_msgTypes = make([]protoimpl.MessageInfo, 141) var file_proto_activity_v1_activity_proto_msgTypes = make([]protoimpl.MessageInfo, 144)
var file_proto_activity_v1_activity_proto_goTypes = []any{ var file_proto_activity_v1_activity_proto_goTypes = []any{
(*RequestMeta)(nil), // 0: hyapp.activity.v1.RequestMeta (*RequestMeta)(nil), // 0: hyapp.activity.v1.RequestMeta
(*PingActivityRequest)(nil), // 1: hyapp.activity.v1.PingActivityRequest (*PingActivityRequest)(nil), // 1: hyapp.activity.v1.PingActivityRequest
@ -12696,7 +12990,10 @@ var file_proto_activity_v1_activity_proto_goTypes = []any{
(*ListLuckyGiftConfigsResponse)(nil), // 138: hyapp.activity.v1.ListLuckyGiftConfigsResponse (*ListLuckyGiftConfigsResponse)(nil), // 138: hyapp.activity.v1.ListLuckyGiftConfigsResponse
(*ListLuckyGiftDrawsRequest)(nil), // 139: hyapp.activity.v1.ListLuckyGiftDrawsRequest (*ListLuckyGiftDrawsRequest)(nil), // 139: hyapp.activity.v1.ListLuckyGiftDrawsRequest
(*ListLuckyGiftDrawsResponse)(nil), // 140: hyapp.activity.v1.ListLuckyGiftDrawsResponse (*ListLuckyGiftDrawsResponse)(nil), // 140: hyapp.activity.v1.ListLuckyGiftDrawsResponse
(*v1.EventEnvelope)(nil), // 141: hyapp.events.room.v1.EventEnvelope (*LuckyGiftDrawSummary)(nil), // 141: hyapp.activity.v1.LuckyGiftDrawSummary
(*GetLuckyGiftDrawSummaryRequest)(nil), // 142: hyapp.activity.v1.GetLuckyGiftDrawSummaryRequest
(*GetLuckyGiftDrawSummaryResponse)(nil), // 143: hyapp.activity.v1.GetLuckyGiftDrawSummaryResponse
(*v1.EventEnvelope)(nil), // 144: hyapp.events.room.v1.EventEnvelope
} }
var file_proto_activity_v1_activity_proto_depIdxs = []int32{ var file_proto_activity_v1_activity_proto_depIdxs = []int32{
0, // 0: hyapp.activity.v1.PingActivityRequest.meta:type_name -> hyapp.activity.v1.RequestMeta 0, // 0: hyapp.activity.v1.PingActivityRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
@ -12721,7 +13018,7 @@ var file_proto_activity_v1_activity_proto_depIdxs = []int32{
0, // 19: hyapp.activity.v1.PublishGlobalBroadcastRequest.meta:type_name -> hyapp.activity.v1.RequestMeta 0, // 19: hyapp.activity.v1.PublishGlobalBroadcastRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
0, // 20: hyapp.activity.v1.RemoveRegionBroadcastMemberRequest.meta:type_name -> hyapp.activity.v1.RequestMeta 0, // 20: hyapp.activity.v1.RemoveRegionBroadcastMemberRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
0, // 21: hyapp.activity.v1.ConsumeRoomEventRequest.meta:type_name -> hyapp.activity.v1.RequestMeta 0, // 21: hyapp.activity.v1.ConsumeRoomEventRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
141, // 22: hyapp.activity.v1.ConsumeRoomEventRequest.envelope:type_name -> hyapp.events.room.v1.EventEnvelope 144, // 22: hyapp.activity.v1.ConsumeRoomEventRequest.envelope:type_name -> hyapp.events.room.v1.EventEnvelope
0, // 23: hyapp.activity.v1.ListTaskDefinitionsRequest.meta:type_name -> hyapp.activity.v1.RequestMeta 0, // 23: hyapp.activity.v1.ListTaskDefinitionsRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
42, // 24: hyapp.activity.v1.ListTaskDefinitionsResponse.tasks:type_name -> hyapp.activity.v1.TaskDefinition 42, // 24: hyapp.activity.v1.ListTaskDefinitionsResponse.tasks:type_name -> hyapp.activity.v1.TaskDefinition
0, // 25: hyapp.activity.v1.UpsertTaskDefinitionRequest.meta:type_name -> hyapp.activity.v1.RequestMeta 0, // 25: hyapp.activity.v1.UpsertTaskDefinitionRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
@ -12812,129 +13109,133 @@ var file_proto_activity_v1_activity_proto_depIdxs = []int32{
127, // 110: hyapp.activity.v1.ListLuckyGiftConfigsResponse.configs:type_name -> hyapp.activity.v1.LuckyGiftConfig 127, // 110: hyapp.activity.v1.ListLuckyGiftConfigsResponse.configs:type_name -> hyapp.activity.v1.LuckyGiftConfig
0, // 111: hyapp.activity.v1.ListLuckyGiftDrawsRequest.meta:type_name -> hyapp.activity.v1.RequestMeta 0, // 111: hyapp.activity.v1.ListLuckyGiftDrawsRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
130, // 112: hyapp.activity.v1.ListLuckyGiftDrawsResponse.draws:type_name -> hyapp.activity.v1.LuckyGiftDrawResult 130, // 112: hyapp.activity.v1.ListLuckyGiftDrawsResponse.draws:type_name -> hyapp.activity.v1.LuckyGiftDrawResult
1, // 113: hyapp.activity.v1.ActivityService.PingActivity:input_type -> hyapp.activity.v1.PingActivityRequest 0, // 113: hyapp.activity.v1.GetLuckyGiftDrawSummaryRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
3, // 114: hyapp.activity.v1.ActivityService.GetActivityStatus:input_type -> hyapp.activity.v1.GetActivityStatusRequest 141, // 114: hyapp.activity.v1.GetLuckyGiftDrawSummaryResponse.summary:type_name -> hyapp.activity.v1.LuckyGiftDrawSummary
6, // 115: hyapp.activity.v1.MessageInboxService.ListMessageTabs:input_type -> hyapp.activity.v1.ListMessageTabsRequest 1, // 115: hyapp.activity.v1.ActivityService.PingActivity:input_type -> hyapp.activity.v1.PingActivityRequest
9, // 116: hyapp.activity.v1.MessageInboxService.ListInboxMessages:input_type -> hyapp.activity.v1.ListInboxMessagesRequest 3, // 116: hyapp.activity.v1.ActivityService.GetActivityStatus:input_type -> hyapp.activity.v1.GetActivityStatusRequest
11, // 117: hyapp.activity.v1.MessageInboxService.MarkInboxMessageRead:input_type -> hyapp.activity.v1.MarkInboxMessageReadRequest 6, // 117: hyapp.activity.v1.MessageInboxService.ListMessageTabs:input_type -> hyapp.activity.v1.ListMessageTabsRequest
13, // 118: hyapp.activity.v1.MessageInboxService.MarkInboxSectionRead:input_type -> hyapp.activity.v1.MarkInboxSectionReadRequest 9, // 118: hyapp.activity.v1.MessageInboxService.ListInboxMessages:input_type -> hyapp.activity.v1.ListInboxMessagesRequest
15, // 119: hyapp.activity.v1.MessageInboxService.DeleteInboxMessage:input_type -> hyapp.activity.v1.DeleteInboxMessageRequest 11, // 119: hyapp.activity.v1.MessageInboxService.MarkInboxMessageRead:input_type -> hyapp.activity.v1.MarkInboxMessageReadRequest
17, // 120: hyapp.activity.v1.MessageInboxService.CreateInboxMessage:input_type -> hyapp.activity.v1.CreateInboxMessageRequest 13, // 120: hyapp.activity.v1.MessageInboxService.MarkInboxSectionRead:input_type -> hyapp.activity.v1.MarkInboxSectionReadRequest
19, // 121: hyapp.activity.v1.MessageInboxService.CreateFanoutJob:input_type -> hyapp.activity.v1.CreateFanoutJobRequest 15, // 121: hyapp.activity.v1.MessageInboxService.DeleteInboxMessage:input_type -> hyapp.activity.v1.DeleteInboxMessageRequest
21, // 122: hyapp.activity.v1.ActivityCronService.ProcessMessageFanoutBatch:input_type -> hyapp.activity.v1.CronBatchRequest 17, // 122: hyapp.activity.v1.MessageInboxService.CreateInboxMessage:input_type -> hyapp.activity.v1.CreateInboxMessageRequest
21, // 123: hyapp.activity.v1.ActivityCronService.ProcessLevelRewardBatch:input_type -> hyapp.activity.v1.CronBatchRequest 19, // 123: hyapp.activity.v1.MessageInboxService.CreateFanoutJob:input_type -> hyapp.activity.v1.CreateFanoutJobRequest
21, // 124: hyapp.activity.v1.ActivityCronService.ProcessAchievementRewardBatch:input_type -> hyapp.activity.v1.CronBatchRequest 21, // 124: hyapp.activity.v1.ActivityCronService.ProcessMessageFanoutBatch:input_type -> hyapp.activity.v1.CronBatchRequest
25, // 125: hyapp.activity.v1.TaskService.ListUserTasks:input_type -> hyapp.activity.v1.ListUserTasksRequest 21, // 125: hyapp.activity.v1.ActivityCronService.ProcessLevelRewardBatch:input_type -> hyapp.activity.v1.CronBatchRequest
27, // 126: hyapp.activity.v1.TaskService.ClaimTaskReward:input_type -> hyapp.activity.v1.ClaimTaskRewardRequest 21, // 126: hyapp.activity.v1.ActivityCronService.ProcessAchievementRewardBatch:input_type -> hyapp.activity.v1.CronBatchRequest
29, // 127: hyapp.activity.v1.TaskService.ConsumeTaskEvent:input_type -> hyapp.activity.v1.ConsumeTaskEventRequest 25, // 127: hyapp.activity.v1.TaskService.ListUserTasks:input_type -> hyapp.activity.v1.ListUserTasksRequest
94, // 128: hyapp.activity.v1.GrowthLevelService.GetMyLevelOverview:input_type -> hyapp.activity.v1.GetMyLevelOverviewRequest 27, // 128: hyapp.activity.v1.TaskService.ClaimTaskReward:input_type -> hyapp.activity.v1.ClaimTaskRewardRequest
96, // 129: hyapp.activity.v1.GrowthLevelService.GetLevelTrack:input_type -> hyapp.activity.v1.GetLevelTrackRequest 29, // 129: hyapp.activity.v1.TaskService.ConsumeTaskEvent:input_type -> hyapp.activity.v1.ConsumeTaskEventRequest
99, // 130: hyapp.activity.v1.GrowthLevelService.ListLevelRewards:input_type -> hyapp.activity.v1.ListLevelRewardsRequest 94, // 130: hyapp.activity.v1.GrowthLevelService.GetMyLevelOverview:input_type -> hyapp.activity.v1.GetMyLevelOverviewRequest
101, // 131: hyapp.activity.v1.GrowthLevelService.ConsumeLevelEvent:input_type -> hyapp.activity.v1.ConsumeLevelEventRequest 96, // 131: hyapp.activity.v1.GrowthLevelService.GetLevelTrack:input_type -> hyapp.activity.v1.GetLevelTrackRequest
114, // 132: hyapp.activity.v1.AchievementService.ListAchievements:input_type -> hyapp.activity.v1.ListAchievementsRequest 99, // 132: hyapp.activity.v1.GrowthLevelService.ListLevelRewards:input_type -> hyapp.activity.v1.ListLevelRewardsRequest
116, // 133: hyapp.activity.v1.AchievementService.ConsumeAchievementEvent:input_type -> hyapp.activity.v1.ConsumeAchievementEventRequest 101, // 133: hyapp.activity.v1.GrowthLevelService.ConsumeLevelEvent:input_type -> hyapp.activity.v1.ConsumeLevelEventRequest
119, // 134: hyapp.activity.v1.AchievementService.ListMyBadges:input_type -> hyapp.activity.v1.ListMyBadgesRequest 114, // 134: hyapp.activity.v1.AchievementService.ListAchievements:input_type -> hyapp.activity.v1.ListAchievementsRequest
121, // 135: hyapp.activity.v1.AchievementService.SetBadgeDisplay:input_type -> hyapp.activity.v1.SetBadgeDisplayRequest 116, // 135: hyapp.activity.v1.AchievementService.ConsumeAchievementEvent:input_type -> hyapp.activity.v1.ConsumeAchievementEventRequest
128, // 136: hyapp.activity.v1.LuckyGiftService.CheckLuckyGift:input_type -> hyapp.activity.v1.CheckLuckyGiftRequest 119, // 136: hyapp.activity.v1.AchievementService.ListMyBadges:input_type -> hyapp.activity.v1.ListMyBadgesRequest
131, // 137: hyapp.activity.v1.LuckyGiftService.ExecuteLuckyGiftDraw:input_type -> hyapp.activity.v1.ExecuteLuckyGiftDrawRequest 121, // 137: hyapp.activity.v1.AchievementService.SetBadgeDisplay:input_type -> hyapp.activity.v1.SetBadgeDisplayRequest
32, // 138: hyapp.activity.v1.BroadcastService.EnsureBroadcastGroups:input_type -> hyapp.activity.v1.EnsureBroadcastGroupsRequest 128, // 138: hyapp.activity.v1.LuckyGiftService.CheckLuckyGift:input_type -> hyapp.activity.v1.CheckLuckyGiftRequest
34, // 139: hyapp.activity.v1.BroadcastService.PublishRegionBroadcast:input_type -> hyapp.activity.v1.PublishRegionBroadcastRequest 131, // 139: hyapp.activity.v1.LuckyGiftService.ExecuteLuckyGiftDraw:input_type -> hyapp.activity.v1.ExecuteLuckyGiftDrawRequest
35, // 140: hyapp.activity.v1.BroadcastService.PublishGlobalBroadcast:input_type -> hyapp.activity.v1.PublishGlobalBroadcastRequest 32, // 140: hyapp.activity.v1.BroadcastService.EnsureBroadcastGroups:input_type -> hyapp.activity.v1.EnsureBroadcastGroupsRequest
37, // 141: hyapp.activity.v1.BroadcastService.RemoveRegionBroadcastMember:input_type -> hyapp.activity.v1.RemoveRegionBroadcastMemberRequest 34, // 141: hyapp.activity.v1.BroadcastService.PublishRegionBroadcast:input_type -> hyapp.activity.v1.PublishRegionBroadcastRequest
21, // 142: hyapp.activity.v1.BroadcastService.ProcessBroadcastOutboxBatch:input_type -> hyapp.activity.v1.CronBatchRequest 35, // 142: hyapp.activity.v1.BroadcastService.PublishGlobalBroadcast:input_type -> hyapp.activity.v1.PublishGlobalBroadcastRequest
40, // 143: hyapp.activity.v1.RoomEventConsumerService.ConsumeRoomEvent:input_type -> hyapp.activity.v1.ConsumeRoomEventRequest 37, // 143: hyapp.activity.v1.BroadcastService.RemoveRegionBroadcastMember:input_type -> hyapp.activity.v1.RemoveRegionBroadcastMemberRequest
43, // 144: hyapp.activity.v1.AdminTaskService.ListTaskDefinitions:input_type -> hyapp.activity.v1.ListTaskDefinitionsRequest 21, // 144: hyapp.activity.v1.BroadcastService.ProcessBroadcastOutboxBatch:input_type -> hyapp.activity.v1.CronBatchRequest
45, // 145: hyapp.activity.v1.AdminTaskService.UpsertTaskDefinition:input_type -> hyapp.activity.v1.UpsertTaskDefinitionRequest 40, // 145: hyapp.activity.v1.RoomEventConsumerService.ConsumeRoomEvent:input_type -> hyapp.activity.v1.ConsumeRoomEventRequest
47, // 146: hyapp.activity.v1.AdminTaskService.SetTaskDefinitionStatus:input_type -> hyapp.activity.v1.SetTaskDefinitionStatusRequest 43, // 146: hyapp.activity.v1.AdminTaskService.ListTaskDefinitions:input_type -> hyapp.activity.v1.ListTaskDefinitionsRequest
52, // 147: hyapp.activity.v1.RegistrationRewardService.GetRegistrationRewardEligibility:input_type -> hyapp.activity.v1.GetRegistrationRewardEligibilityRequest 45, // 147: hyapp.activity.v1.AdminTaskService.UpsertTaskDefinition:input_type -> hyapp.activity.v1.UpsertTaskDefinitionRequest
54, // 148: hyapp.activity.v1.RegistrationRewardService.IssueRegistrationReward:input_type -> hyapp.activity.v1.IssueRegistrationRewardRequest 47, // 148: hyapp.activity.v1.AdminTaskService.SetTaskDefinitionStatus:input_type -> hyapp.activity.v1.SetTaskDefinitionStatusRequest
56, // 149: hyapp.activity.v1.AdminRegistrationRewardService.GetRegistrationRewardConfig:input_type -> hyapp.activity.v1.GetRegistrationRewardConfigRequest 52, // 149: hyapp.activity.v1.RegistrationRewardService.GetRegistrationRewardEligibility:input_type -> hyapp.activity.v1.GetRegistrationRewardEligibilityRequest
58, // 150: hyapp.activity.v1.AdminRegistrationRewardService.UpdateRegistrationRewardConfig:input_type -> hyapp.activity.v1.UpdateRegistrationRewardConfigRequest 54, // 150: hyapp.activity.v1.RegistrationRewardService.IssueRegistrationReward:input_type -> hyapp.activity.v1.IssueRegistrationRewardRequest
60, // 151: hyapp.activity.v1.AdminRegistrationRewardService.ListRegistrationRewardClaims:input_type -> hyapp.activity.v1.ListRegistrationRewardClaimsRequest 56, // 151: hyapp.activity.v1.AdminRegistrationRewardService.GetRegistrationRewardConfig:input_type -> hyapp.activity.v1.GetRegistrationRewardConfigRequest
66, // 152: hyapp.activity.v1.FirstRechargeRewardService.GetFirstRechargeRewardStatus:input_type -> hyapp.activity.v1.GetFirstRechargeRewardStatusRequest 58, // 152: hyapp.activity.v1.AdminRegistrationRewardService.UpdateRegistrationRewardConfig:input_type -> hyapp.activity.v1.UpdateRegistrationRewardConfigRequest
68, // 153: hyapp.activity.v1.FirstRechargeRewardService.ConsumeFirstRechargeReward:input_type -> hyapp.activity.v1.ConsumeFirstRechargeRewardRequest 60, // 153: hyapp.activity.v1.AdminRegistrationRewardService.ListRegistrationRewardClaims:input_type -> hyapp.activity.v1.ListRegistrationRewardClaimsRequest
70, // 154: hyapp.activity.v1.AdminFirstRechargeRewardService.GetFirstRechargeRewardConfig:input_type -> hyapp.activity.v1.GetFirstRechargeRewardConfigRequest 66, // 154: hyapp.activity.v1.FirstRechargeRewardService.GetFirstRechargeRewardStatus:input_type -> hyapp.activity.v1.GetFirstRechargeRewardStatusRequest
72, // 155: hyapp.activity.v1.AdminFirstRechargeRewardService.UpdateFirstRechargeRewardConfig:input_type -> hyapp.activity.v1.UpdateFirstRechargeRewardConfigRequest 68, // 155: hyapp.activity.v1.FirstRechargeRewardService.ConsumeFirstRechargeReward:input_type -> hyapp.activity.v1.ConsumeFirstRechargeRewardRequest
74, // 156: hyapp.activity.v1.AdminFirstRechargeRewardService.ListFirstRechargeRewardClaims:input_type -> hyapp.activity.v1.ListFirstRechargeRewardClaimsRequest 70, // 156: hyapp.activity.v1.AdminFirstRechargeRewardService.GetFirstRechargeRewardConfig:input_type -> hyapp.activity.v1.GetFirstRechargeRewardConfigRequest
79, // 157: hyapp.activity.v1.SevenDayCheckInService.GetSevenDayCheckInStatus:input_type -> hyapp.activity.v1.GetSevenDayCheckInStatusRequest 72, // 157: hyapp.activity.v1.AdminFirstRechargeRewardService.UpdateFirstRechargeRewardConfig:input_type -> hyapp.activity.v1.UpdateFirstRechargeRewardConfigRequest
81, // 158: hyapp.activity.v1.SevenDayCheckInService.SignSevenDayCheckIn:input_type -> hyapp.activity.v1.SignSevenDayCheckInRequest 74, // 158: hyapp.activity.v1.AdminFirstRechargeRewardService.ListFirstRechargeRewardClaims:input_type -> hyapp.activity.v1.ListFirstRechargeRewardClaimsRequest
83, // 159: hyapp.activity.v1.AdminSevenDayCheckInService.GetSevenDayCheckInConfig:input_type -> hyapp.activity.v1.GetSevenDayCheckInConfigRequest 79, // 159: hyapp.activity.v1.SevenDayCheckInService.GetSevenDayCheckInStatus:input_type -> hyapp.activity.v1.GetSevenDayCheckInStatusRequest
85, // 160: hyapp.activity.v1.AdminSevenDayCheckInService.UpdateSevenDayCheckInConfig:input_type -> hyapp.activity.v1.UpdateSevenDayCheckInConfigRequest 81, // 160: hyapp.activity.v1.SevenDayCheckInService.SignSevenDayCheckIn:input_type -> hyapp.activity.v1.SignSevenDayCheckInRequest
88, // 161: hyapp.activity.v1.AdminSevenDayCheckInService.ListSevenDayCheckInClaims:input_type -> hyapp.activity.v1.ListSevenDayCheckInClaimsRequest 83, // 161: hyapp.activity.v1.AdminSevenDayCheckInService.GetSevenDayCheckInConfig:input_type -> hyapp.activity.v1.GetSevenDayCheckInConfigRequest
109, // 162: hyapp.activity.v1.AdminGrowthLevelService.ListLevelConfig:input_type -> hyapp.activity.v1.ListLevelConfigRequest 85, // 162: hyapp.activity.v1.AdminSevenDayCheckInService.UpdateSevenDayCheckInConfig:input_type -> hyapp.activity.v1.UpdateSevenDayCheckInConfigRequest
103, // 163: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelTrack:input_type -> hyapp.activity.v1.UpsertLevelTrackRequest 88, // 163: hyapp.activity.v1.AdminSevenDayCheckInService.ListSevenDayCheckInClaims:input_type -> hyapp.activity.v1.ListSevenDayCheckInClaimsRequest
105, // 164: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelRule:input_type -> hyapp.activity.v1.UpsertLevelRuleRequest 109, // 164: hyapp.activity.v1.AdminGrowthLevelService.ListLevelConfig:input_type -> hyapp.activity.v1.ListLevelConfigRequest
107, // 165: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelTier:input_type -> hyapp.activity.v1.UpsertLevelTierRequest 103, // 165: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelTrack:input_type -> hyapp.activity.v1.UpsertLevelTrackRequest
114, // 166: hyapp.activity.v1.AdminAchievementService.ListAchievementDefinitions:input_type -> hyapp.activity.v1.ListAchievementsRequest 105, // 166: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelRule:input_type -> hyapp.activity.v1.UpsertLevelRuleRequest
123, // 167: hyapp.activity.v1.AdminAchievementService.UpsertAchievementDefinition:input_type -> hyapp.activity.v1.UpsertAchievementDefinitionRequest 107, // 167: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelTier:input_type -> hyapp.activity.v1.UpsertLevelTierRequest
133, // 168: hyapp.activity.v1.AdminLuckyGiftService.GetLuckyGiftConfig:input_type -> hyapp.activity.v1.GetLuckyGiftConfigRequest 114, // 168: hyapp.activity.v1.AdminAchievementService.ListAchievementDefinitions:input_type -> hyapp.activity.v1.ListAchievementsRequest
135, // 169: hyapp.activity.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:input_type -> hyapp.activity.v1.UpsertLuckyGiftConfigRequest 123, // 169: hyapp.activity.v1.AdminAchievementService.UpsertAchievementDefinition:input_type -> hyapp.activity.v1.UpsertAchievementDefinitionRequest
137, // 170: hyapp.activity.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:input_type -> hyapp.activity.v1.ListLuckyGiftConfigsRequest 133, // 170: hyapp.activity.v1.AdminLuckyGiftService.GetLuckyGiftConfig:input_type -> hyapp.activity.v1.GetLuckyGiftConfigRequest
139, // 171: hyapp.activity.v1.AdminLuckyGiftService.ListLuckyGiftDraws:input_type -> hyapp.activity.v1.ListLuckyGiftDrawsRequest 135, // 171: hyapp.activity.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:input_type -> hyapp.activity.v1.UpsertLuckyGiftConfigRequest
2, // 172: hyapp.activity.v1.ActivityService.PingActivity:output_type -> hyapp.activity.v1.PingActivityResponse 137, // 172: hyapp.activity.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:input_type -> hyapp.activity.v1.ListLuckyGiftConfigsRequest
4, // 173: hyapp.activity.v1.ActivityService.GetActivityStatus:output_type -> hyapp.activity.v1.GetActivityStatusResponse 139, // 173: hyapp.activity.v1.AdminLuckyGiftService.ListLuckyGiftDraws:input_type -> hyapp.activity.v1.ListLuckyGiftDrawsRequest
7, // 174: hyapp.activity.v1.MessageInboxService.ListMessageTabs:output_type -> hyapp.activity.v1.ListMessageTabsResponse 142, // 174: hyapp.activity.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:input_type -> hyapp.activity.v1.GetLuckyGiftDrawSummaryRequest
10, // 175: hyapp.activity.v1.MessageInboxService.ListInboxMessages:output_type -> hyapp.activity.v1.ListInboxMessagesResponse 2, // 175: hyapp.activity.v1.ActivityService.PingActivity:output_type -> hyapp.activity.v1.PingActivityResponse
12, // 176: hyapp.activity.v1.MessageInboxService.MarkInboxMessageRead:output_type -> hyapp.activity.v1.MarkInboxMessageReadResponse 4, // 176: hyapp.activity.v1.ActivityService.GetActivityStatus:output_type -> hyapp.activity.v1.GetActivityStatusResponse
14, // 177: hyapp.activity.v1.MessageInboxService.MarkInboxSectionRead:output_type -> hyapp.activity.v1.MarkInboxSectionReadResponse 7, // 177: hyapp.activity.v1.MessageInboxService.ListMessageTabs:output_type -> hyapp.activity.v1.ListMessageTabsResponse
16, // 178: hyapp.activity.v1.MessageInboxService.DeleteInboxMessage:output_type -> hyapp.activity.v1.DeleteInboxMessageResponse 10, // 178: hyapp.activity.v1.MessageInboxService.ListInboxMessages:output_type -> hyapp.activity.v1.ListInboxMessagesResponse
18, // 179: hyapp.activity.v1.MessageInboxService.CreateInboxMessage:output_type -> hyapp.activity.v1.CreateInboxMessageResponse 12, // 179: hyapp.activity.v1.MessageInboxService.MarkInboxMessageRead:output_type -> hyapp.activity.v1.MarkInboxMessageReadResponse
20, // 180: hyapp.activity.v1.MessageInboxService.CreateFanoutJob:output_type -> hyapp.activity.v1.CreateFanoutJobResponse 14, // 180: hyapp.activity.v1.MessageInboxService.MarkInboxSectionRead:output_type -> hyapp.activity.v1.MarkInboxSectionReadResponse
22, // 181: hyapp.activity.v1.ActivityCronService.ProcessMessageFanoutBatch:output_type -> hyapp.activity.v1.CronBatchResponse 16, // 181: hyapp.activity.v1.MessageInboxService.DeleteInboxMessage:output_type -> hyapp.activity.v1.DeleteInboxMessageResponse
22, // 182: hyapp.activity.v1.ActivityCronService.ProcessLevelRewardBatch:output_type -> hyapp.activity.v1.CronBatchResponse 18, // 182: hyapp.activity.v1.MessageInboxService.CreateInboxMessage:output_type -> hyapp.activity.v1.CreateInboxMessageResponse
22, // 183: hyapp.activity.v1.ActivityCronService.ProcessAchievementRewardBatch:output_type -> hyapp.activity.v1.CronBatchResponse 20, // 183: hyapp.activity.v1.MessageInboxService.CreateFanoutJob:output_type -> hyapp.activity.v1.CreateFanoutJobResponse
26, // 184: hyapp.activity.v1.TaskService.ListUserTasks:output_type -> hyapp.activity.v1.ListUserTasksResponse 22, // 184: hyapp.activity.v1.ActivityCronService.ProcessMessageFanoutBatch:output_type -> hyapp.activity.v1.CronBatchResponse
28, // 185: hyapp.activity.v1.TaskService.ClaimTaskReward:output_type -> hyapp.activity.v1.ClaimTaskRewardResponse 22, // 185: hyapp.activity.v1.ActivityCronService.ProcessLevelRewardBatch:output_type -> hyapp.activity.v1.CronBatchResponse
30, // 186: hyapp.activity.v1.TaskService.ConsumeTaskEvent:output_type -> hyapp.activity.v1.ConsumeTaskEventResponse 22, // 186: hyapp.activity.v1.ActivityCronService.ProcessAchievementRewardBatch:output_type -> hyapp.activity.v1.CronBatchResponse
95, // 187: hyapp.activity.v1.GrowthLevelService.GetMyLevelOverview:output_type -> hyapp.activity.v1.GetMyLevelOverviewResponse 26, // 187: hyapp.activity.v1.TaskService.ListUserTasks:output_type -> hyapp.activity.v1.ListUserTasksResponse
97, // 188: hyapp.activity.v1.GrowthLevelService.GetLevelTrack:output_type -> hyapp.activity.v1.GetLevelTrackResponse 28, // 188: hyapp.activity.v1.TaskService.ClaimTaskReward:output_type -> hyapp.activity.v1.ClaimTaskRewardResponse
100, // 189: hyapp.activity.v1.GrowthLevelService.ListLevelRewards:output_type -> hyapp.activity.v1.ListLevelRewardsResponse 30, // 189: hyapp.activity.v1.TaskService.ConsumeTaskEvent:output_type -> hyapp.activity.v1.ConsumeTaskEventResponse
102, // 190: hyapp.activity.v1.GrowthLevelService.ConsumeLevelEvent:output_type -> hyapp.activity.v1.ConsumeLevelEventResponse 95, // 190: hyapp.activity.v1.GrowthLevelService.GetMyLevelOverview:output_type -> hyapp.activity.v1.GetMyLevelOverviewResponse
115, // 191: hyapp.activity.v1.AchievementService.ListAchievements:output_type -> hyapp.activity.v1.ListAchievementsResponse 97, // 191: hyapp.activity.v1.GrowthLevelService.GetLevelTrack:output_type -> hyapp.activity.v1.GetLevelTrackResponse
117, // 192: hyapp.activity.v1.AchievementService.ConsumeAchievementEvent:output_type -> hyapp.activity.v1.ConsumeAchievementEventResponse 100, // 192: hyapp.activity.v1.GrowthLevelService.ListLevelRewards:output_type -> hyapp.activity.v1.ListLevelRewardsResponse
120, // 193: hyapp.activity.v1.AchievementService.ListMyBadges:output_type -> hyapp.activity.v1.ListMyBadgesResponse 102, // 193: hyapp.activity.v1.GrowthLevelService.ConsumeLevelEvent:output_type -> hyapp.activity.v1.ConsumeLevelEventResponse
122, // 194: hyapp.activity.v1.AchievementService.SetBadgeDisplay:output_type -> hyapp.activity.v1.SetBadgeDisplayResponse 115, // 194: hyapp.activity.v1.AchievementService.ListAchievements:output_type -> hyapp.activity.v1.ListAchievementsResponse
129, // 195: hyapp.activity.v1.LuckyGiftService.CheckLuckyGift:output_type -> hyapp.activity.v1.CheckLuckyGiftResponse 117, // 195: hyapp.activity.v1.AchievementService.ConsumeAchievementEvent:output_type -> hyapp.activity.v1.ConsumeAchievementEventResponse
132, // 196: hyapp.activity.v1.LuckyGiftService.ExecuteLuckyGiftDraw:output_type -> hyapp.activity.v1.ExecuteLuckyGiftDrawResponse 120, // 196: hyapp.activity.v1.AchievementService.ListMyBadges:output_type -> hyapp.activity.v1.ListMyBadgesResponse
33, // 197: hyapp.activity.v1.BroadcastService.EnsureBroadcastGroups:output_type -> hyapp.activity.v1.EnsureBroadcastGroupsResponse 122, // 197: hyapp.activity.v1.AchievementService.SetBadgeDisplay:output_type -> hyapp.activity.v1.SetBadgeDisplayResponse
36, // 198: hyapp.activity.v1.BroadcastService.PublishRegionBroadcast:output_type -> hyapp.activity.v1.PublishBroadcastResponse 129, // 198: hyapp.activity.v1.LuckyGiftService.CheckLuckyGift:output_type -> hyapp.activity.v1.CheckLuckyGiftResponse
36, // 199: hyapp.activity.v1.BroadcastService.PublishGlobalBroadcast:output_type -> hyapp.activity.v1.PublishBroadcastResponse 132, // 199: hyapp.activity.v1.LuckyGiftService.ExecuteLuckyGiftDraw:output_type -> hyapp.activity.v1.ExecuteLuckyGiftDrawResponse
38, // 200: hyapp.activity.v1.BroadcastService.RemoveRegionBroadcastMember:output_type -> hyapp.activity.v1.RemoveRegionBroadcastMemberResponse 33, // 200: hyapp.activity.v1.BroadcastService.EnsureBroadcastGroups:output_type -> hyapp.activity.v1.EnsureBroadcastGroupsResponse
39, // 201: hyapp.activity.v1.BroadcastService.ProcessBroadcastOutboxBatch:output_type -> hyapp.activity.v1.ProcessBroadcastOutboxBatchResponse 36, // 201: hyapp.activity.v1.BroadcastService.PublishRegionBroadcast:output_type -> hyapp.activity.v1.PublishBroadcastResponse
41, // 202: hyapp.activity.v1.RoomEventConsumerService.ConsumeRoomEvent:output_type -> hyapp.activity.v1.ConsumeRoomEventResponse 36, // 202: hyapp.activity.v1.BroadcastService.PublishGlobalBroadcast:output_type -> hyapp.activity.v1.PublishBroadcastResponse
44, // 203: hyapp.activity.v1.AdminTaskService.ListTaskDefinitions:output_type -> hyapp.activity.v1.ListTaskDefinitionsResponse 38, // 203: hyapp.activity.v1.BroadcastService.RemoveRegionBroadcastMember:output_type -> hyapp.activity.v1.RemoveRegionBroadcastMemberResponse
46, // 204: hyapp.activity.v1.AdminTaskService.UpsertTaskDefinition:output_type -> hyapp.activity.v1.UpsertTaskDefinitionResponse 39, // 204: hyapp.activity.v1.BroadcastService.ProcessBroadcastOutboxBatch:output_type -> hyapp.activity.v1.ProcessBroadcastOutboxBatchResponse
48, // 205: hyapp.activity.v1.AdminTaskService.SetTaskDefinitionStatus:output_type -> hyapp.activity.v1.SetTaskDefinitionStatusResponse 41, // 205: hyapp.activity.v1.RoomEventConsumerService.ConsumeRoomEvent:output_type -> hyapp.activity.v1.ConsumeRoomEventResponse
53, // 206: hyapp.activity.v1.RegistrationRewardService.GetRegistrationRewardEligibility:output_type -> hyapp.activity.v1.GetRegistrationRewardEligibilityResponse 44, // 206: hyapp.activity.v1.AdminTaskService.ListTaskDefinitions:output_type -> hyapp.activity.v1.ListTaskDefinitionsResponse
55, // 207: hyapp.activity.v1.RegistrationRewardService.IssueRegistrationReward:output_type -> hyapp.activity.v1.IssueRegistrationRewardResponse 46, // 207: hyapp.activity.v1.AdminTaskService.UpsertTaskDefinition:output_type -> hyapp.activity.v1.UpsertTaskDefinitionResponse
57, // 208: hyapp.activity.v1.AdminRegistrationRewardService.GetRegistrationRewardConfig:output_type -> hyapp.activity.v1.GetRegistrationRewardConfigResponse 48, // 208: hyapp.activity.v1.AdminTaskService.SetTaskDefinitionStatus:output_type -> hyapp.activity.v1.SetTaskDefinitionStatusResponse
59, // 209: hyapp.activity.v1.AdminRegistrationRewardService.UpdateRegistrationRewardConfig:output_type -> hyapp.activity.v1.UpdateRegistrationRewardConfigResponse 53, // 209: hyapp.activity.v1.RegistrationRewardService.GetRegistrationRewardEligibility:output_type -> hyapp.activity.v1.GetRegistrationRewardEligibilityResponse
61, // 210: hyapp.activity.v1.AdminRegistrationRewardService.ListRegistrationRewardClaims:output_type -> hyapp.activity.v1.ListRegistrationRewardClaimsResponse 55, // 210: hyapp.activity.v1.RegistrationRewardService.IssueRegistrationReward:output_type -> hyapp.activity.v1.IssueRegistrationRewardResponse
67, // 211: hyapp.activity.v1.FirstRechargeRewardService.GetFirstRechargeRewardStatus:output_type -> hyapp.activity.v1.GetFirstRechargeRewardStatusResponse 57, // 211: hyapp.activity.v1.AdminRegistrationRewardService.GetRegistrationRewardConfig:output_type -> hyapp.activity.v1.GetRegistrationRewardConfigResponse
69, // 212: hyapp.activity.v1.FirstRechargeRewardService.ConsumeFirstRechargeReward:output_type -> hyapp.activity.v1.ConsumeFirstRechargeRewardResponse 59, // 212: hyapp.activity.v1.AdminRegistrationRewardService.UpdateRegistrationRewardConfig:output_type -> hyapp.activity.v1.UpdateRegistrationRewardConfigResponse
71, // 213: hyapp.activity.v1.AdminFirstRechargeRewardService.GetFirstRechargeRewardConfig:output_type -> hyapp.activity.v1.GetFirstRechargeRewardConfigResponse 61, // 213: hyapp.activity.v1.AdminRegistrationRewardService.ListRegistrationRewardClaims:output_type -> hyapp.activity.v1.ListRegistrationRewardClaimsResponse
73, // 214: hyapp.activity.v1.AdminFirstRechargeRewardService.UpdateFirstRechargeRewardConfig:output_type -> hyapp.activity.v1.UpdateFirstRechargeRewardConfigResponse 67, // 214: hyapp.activity.v1.FirstRechargeRewardService.GetFirstRechargeRewardStatus:output_type -> hyapp.activity.v1.GetFirstRechargeRewardStatusResponse
75, // 215: hyapp.activity.v1.AdminFirstRechargeRewardService.ListFirstRechargeRewardClaims:output_type -> hyapp.activity.v1.ListFirstRechargeRewardClaimsResponse 69, // 215: hyapp.activity.v1.FirstRechargeRewardService.ConsumeFirstRechargeReward:output_type -> hyapp.activity.v1.ConsumeFirstRechargeRewardResponse
80, // 216: hyapp.activity.v1.SevenDayCheckInService.GetSevenDayCheckInStatus:output_type -> hyapp.activity.v1.GetSevenDayCheckInStatusResponse 71, // 216: hyapp.activity.v1.AdminFirstRechargeRewardService.GetFirstRechargeRewardConfig:output_type -> hyapp.activity.v1.GetFirstRechargeRewardConfigResponse
82, // 217: hyapp.activity.v1.SevenDayCheckInService.SignSevenDayCheckIn:output_type -> hyapp.activity.v1.SignSevenDayCheckInResponse 73, // 217: hyapp.activity.v1.AdminFirstRechargeRewardService.UpdateFirstRechargeRewardConfig:output_type -> hyapp.activity.v1.UpdateFirstRechargeRewardConfigResponse
84, // 218: hyapp.activity.v1.AdminSevenDayCheckInService.GetSevenDayCheckInConfig:output_type -> hyapp.activity.v1.GetSevenDayCheckInConfigResponse 75, // 218: hyapp.activity.v1.AdminFirstRechargeRewardService.ListFirstRechargeRewardClaims:output_type -> hyapp.activity.v1.ListFirstRechargeRewardClaimsResponse
86, // 219: hyapp.activity.v1.AdminSevenDayCheckInService.UpdateSevenDayCheckInConfig:output_type -> hyapp.activity.v1.UpdateSevenDayCheckInConfigResponse 80, // 219: hyapp.activity.v1.SevenDayCheckInService.GetSevenDayCheckInStatus:output_type -> hyapp.activity.v1.GetSevenDayCheckInStatusResponse
89, // 220: hyapp.activity.v1.AdminSevenDayCheckInService.ListSevenDayCheckInClaims:output_type -> hyapp.activity.v1.ListSevenDayCheckInClaimsResponse 82, // 220: hyapp.activity.v1.SevenDayCheckInService.SignSevenDayCheckIn:output_type -> hyapp.activity.v1.SignSevenDayCheckInResponse
110, // 221: hyapp.activity.v1.AdminGrowthLevelService.ListLevelConfig:output_type -> hyapp.activity.v1.ListLevelConfigResponse 84, // 221: hyapp.activity.v1.AdminSevenDayCheckInService.GetSevenDayCheckInConfig:output_type -> hyapp.activity.v1.GetSevenDayCheckInConfigResponse
104, // 222: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelTrack:output_type -> hyapp.activity.v1.UpsertLevelTrackResponse 86, // 222: hyapp.activity.v1.AdminSevenDayCheckInService.UpdateSevenDayCheckInConfig:output_type -> hyapp.activity.v1.UpdateSevenDayCheckInConfigResponse
106, // 223: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelRule:output_type -> hyapp.activity.v1.UpsertLevelRuleResponse 89, // 223: hyapp.activity.v1.AdminSevenDayCheckInService.ListSevenDayCheckInClaims:output_type -> hyapp.activity.v1.ListSevenDayCheckInClaimsResponse
108, // 224: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelTier:output_type -> hyapp.activity.v1.UpsertLevelTierResponse 110, // 224: hyapp.activity.v1.AdminGrowthLevelService.ListLevelConfig:output_type -> hyapp.activity.v1.ListLevelConfigResponse
115, // 225: hyapp.activity.v1.AdminAchievementService.ListAchievementDefinitions:output_type -> hyapp.activity.v1.ListAchievementsResponse 104, // 225: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelTrack:output_type -> hyapp.activity.v1.UpsertLevelTrackResponse
124, // 226: hyapp.activity.v1.AdminAchievementService.UpsertAchievementDefinition:output_type -> hyapp.activity.v1.UpsertAchievementDefinitionResponse 106, // 226: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelRule:output_type -> hyapp.activity.v1.UpsertLevelRuleResponse
134, // 227: hyapp.activity.v1.AdminLuckyGiftService.GetLuckyGiftConfig:output_type -> hyapp.activity.v1.GetLuckyGiftConfigResponse 108, // 227: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelTier:output_type -> hyapp.activity.v1.UpsertLevelTierResponse
136, // 228: hyapp.activity.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:output_type -> hyapp.activity.v1.UpsertLuckyGiftConfigResponse 115, // 228: hyapp.activity.v1.AdminAchievementService.ListAchievementDefinitions:output_type -> hyapp.activity.v1.ListAchievementsResponse
138, // 229: hyapp.activity.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:output_type -> hyapp.activity.v1.ListLuckyGiftConfigsResponse 124, // 229: hyapp.activity.v1.AdminAchievementService.UpsertAchievementDefinition:output_type -> hyapp.activity.v1.UpsertAchievementDefinitionResponse
140, // 230: hyapp.activity.v1.AdminLuckyGiftService.ListLuckyGiftDraws:output_type -> hyapp.activity.v1.ListLuckyGiftDrawsResponse 134, // 230: hyapp.activity.v1.AdminLuckyGiftService.GetLuckyGiftConfig:output_type -> hyapp.activity.v1.GetLuckyGiftConfigResponse
172, // [172:231] is the sub-list for method output_type 136, // 231: hyapp.activity.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:output_type -> hyapp.activity.v1.UpsertLuckyGiftConfigResponse
113, // [113:172] is the sub-list for method input_type 138, // 232: hyapp.activity.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:output_type -> hyapp.activity.v1.ListLuckyGiftConfigsResponse
113, // [113:113] is the sub-list for extension type_name 140, // 233: hyapp.activity.v1.AdminLuckyGiftService.ListLuckyGiftDraws:output_type -> hyapp.activity.v1.ListLuckyGiftDrawsResponse
113, // [113:113] is the sub-list for extension extendee 143, // 234: hyapp.activity.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:output_type -> hyapp.activity.v1.GetLuckyGiftDrawSummaryResponse
0, // [0:113] is the sub-list for field type_name 175, // [175:235] is the sub-list for method output_type
115, // [115:175] is the sub-list for method input_type
115, // [115:115] is the sub-list for extension type_name
115, // [115:115] is the sub-list for extension extendee
0, // [0:115] is the sub-list for field type_name
} }
func init() { file_proto_activity_v1_activity_proto_init() } func init() { file_proto_activity_v1_activity_proto_init() }
@ -12948,7 +13249,7 @@ func file_proto_activity_v1_activity_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_activity_v1_activity_proto_rawDesc), len(file_proto_activity_v1_activity_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_activity_v1_activity_proto_rawDesc), len(file_proto_activity_v1_activity_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 141, NumMessages: 144,
NumExtensions: 0, NumExtensions: 0,
NumServices: 19, NumServices: 19,
}, },

View File

@ -1272,6 +1272,35 @@ message ListLuckyGiftDrawsResponse {
int64 total = 2; 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 // ActivityService RPC
service ActivityService { service ActivityService {
rpc PingActivity(PingActivityRequest) returns (PingActivityResponse); rpc PingActivity(PingActivityRequest) returns (PingActivityResponse);
@ -1405,4 +1434,5 @@ service AdminLuckyGiftService {
rpc UpsertLuckyGiftConfig(UpsertLuckyGiftConfigRequest) returns (UpsertLuckyGiftConfigResponse); rpc UpsertLuckyGiftConfig(UpsertLuckyGiftConfigRequest) returns (UpsertLuckyGiftConfigResponse);
rpc ListLuckyGiftConfigs(ListLuckyGiftConfigsRequest) returns (ListLuckyGiftConfigsResponse); rpc ListLuckyGiftConfigs(ListLuckyGiftConfigsRequest) returns (ListLuckyGiftConfigsResponse);
rpc ListLuckyGiftDraws(ListLuckyGiftDrawsRequest) returns (ListLuckyGiftDrawsResponse); 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. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.2 // - protoc-gen-go-grpc v1.6.2
// - protoc v5.26.1 // - protoc v7.35.0
// source: proto/activity/v1/activity.proto // source: proto/activity/v1/activity.proto
package activityv1 package activityv1
@ -3342,10 +3342,11 @@ var AdminAchievementService_ServiceDesc = grpc.ServiceDesc{
} }
const ( const (
AdminLuckyGiftService_GetLuckyGiftConfig_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/GetLuckyGiftConfig" AdminLuckyGiftService_GetLuckyGiftConfig_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/GetLuckyGiftConfig"
AdminLuckyGiftService_UpsertLuckyGiftConfig_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/UpsertLuckyGiftConfig" AdminLuckyGiftService_UpsertLuckyGiftConfig_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/UpsertLuckyGiftConfig"
AdminLuckyGiftService_ListLuckyGiftConfigs_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/ListLuckyGiftConfigs" AdminLuckyGiftService_ListLuckyGiftConfigs_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/ListLuckyGiftConfigs"
AdminLuckyGiftService_ListLuckyGiftDraws_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/ListLuckyGiftDraws" AdminLuckyGiftService_ListLuckyGiftDraws_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/ListLuckyGiftDraws"
AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/GetLuckyGiftDrawSummary"
) )
// AdminLuckyGiftServiceClient is the client API for AdminLuckyGiftService service. // AdminLuckyGiftServiceClient is the client API for AdminLuckyGiftService service.
@ -3358,6 +3359,7 @@ type AdminLuckyGiftServiceClient interface {
UpsertLuckyGiftConfig(ctx context.Context, in *UpsertLuckyGiftConfigRequest, opts ...grpc.CallOption) (*UpsertLuckyGiftConfigResponse, error) UpsertLuckyGiftConfig(ctx context.Context, in *UpsertLuckyGiftConfigRequest, opts ...grpc.CallOption) (*UpsertLuckyGiftConfigResponse, error)
ListLuckyGiftConfigs(ctx context.Context, in *ListLuckyGiftConfigsRequest, opts ...grpc.CallOption) (*ListLuckyGiftConfigsResponse, error) ListLuckyGiftConfigs(ctx context.Context, in *ListLuckyGiftConfigsRequest, opts ...grpc.CallOption) (*ListLuckyGiftConfigsResponse, error)
ListLuckyGiftDraws(ctx context.Context, in *ListLuckyGiftDrawsRequest, opts ...grpc.CallOption) (*ListLuckyGiftDrawsResponse, 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 { type adminLuckyGiftServiceClient struct {
@ -3408,6 +3410,16 @@ func (c *adminLuckyGiftServiceClient) ListLuckyGiftDraws(ctx context.Context, in
return out, nil 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. // AdminLuckyGiftServiceServer is the server API for AdminLuckyGiftService service.
// All implementations must embed UnimplementedAdminLuckyGiftServiceServer // All implementations must embed UnimplementedAdminLuckyGiftServiceServer
// for forward compatibility. // for forward compatibility.
@ -3418,6 +3430,7 @@ type AdminLuckyGiftServiceServer interface {
UpsertLuckyGiftConfig(context.Context, *UpsertLuckyGiftConfigRequest) (*UpsertLuckyGiftConfigResponse, error) UpsertLuckyGiftConfig(context.Context, *UpsertLuckyGiftConfigRequest) (*UpsertLuckyGiftConfigResponse, error)
ListLuckyGiftConfigs(context.Context, *ListLuckyGiftConfigsRequest) (*ListLuckyGiftConfigsResponse, error) ListLuckyGiftConfigs(context.Context, *ListLuckyGiftConfigsRequest) (*ListLuckyGiftConfigsResponse, error)
ListLuckyGiftDraws(context.Context, *ListLuckyGiftDrawsRequest) (*ListLuckyGiftDrawsResponse, error) ListLuckyGiftDraws(context.Context, *ListLuckyGiftDrawsRequest) (*ListLuckyGiftDrawsResponse, error)
GetLuckyGiftDrawSummary(context.Context, *GetLuckyGiftDrawSummaryRequest) (*GetLuckyGiftDrawSummaryResponse, error)
mustEmbedUnimplementedAdminLuckyGiftServiceServer() mustEmbedUnimplementedAdminLuckyGiftServiceServer()
} }
@ -3440,6 +3453,9 @@ func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftConfigs(context.Con
func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftDraws(context.Context, *ListLuckyGiftDrawsRequest) (*ListLuckyGiftDrawsResponse, error) { func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftDraws(context.Context, *ListLuckyGiftDrawsRequest) (*ListLuckyGiftDrawsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListLuckyGiftDraws not implemented") 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) mustEmbedUnimplementedAdminLuckyGiftServiceServer() {}
func (UnimplementedAdminLuckyGiftServiceServer) testEmbeddedByValue() {} func (UnimplementedAdminLuckyGiftServiceServer) testEmbeddedByValue() {}
@ -3533,6 +3549,24 @@ func _AdminLuckyGiftService_ListLuckyGiftDraws_Handler(srv interface{}, ctx cont
return interceptor(ctx, in, info, handler) 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. // AdminLuckyGiftService_ServiceDesc is the grpc.ServiceDesc for AdminLuckyGiftService service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
@ -3556,6 +3590,10 @@ var AdminLuckyGiftService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListLuckyGiftDraws", MethodName: "ListLuckyGiftDraws",
Handler: _AdminLuckyGiftService_ListLuckyGiftDraws_Handler, Handler: _AdminLuckyGiftService_ListLuckyGiftDraws_Handler,
}, },
{
MethodName: "GetLuckyGiftDrawSummary",
Handler: _AdminLuckyGiftService_GetLuckyGiftDrawSummary_Handler,
},
}, },
Streams: []grpc.StreamDesc{}, Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto", Metadata: "proto/activity/v1/activity.proto",

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v5.26.1 // protoc v7.35.0
// source: proto/events/room/v1/events.proto // source: proto/events/room/v1/events.proto
package roomeventsv1 package roomeventsv1

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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,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

@ -28,6 +28,7 @@ type Client interface {
UpsertLuckyGiftConfig(ctx context.Context, req *activityv1.UpsertLuckyGiftConfigRequest) (*activityv1.UpsertLuckyGiftConfigResponse, error) UpsertLuckyGiftConfig(ctx context.Context, req *activityv1.UpsertLuckyGiftConfigRequest) (*activityv1.UpsertLuckyGiftConfigResponse, error)
ListLuckyGiftConfigs(ctx context.Context, req *activityv1.ListLuckyGiftConfigsRequest) (*activityv1.ListLuckyGiftConfigsResponse, error) ListLuckyGiftConfigs(ctx context.Context, req *activityv1.ListLuckyGiftConfigsRequest) (*activityv1.ListLuckyGiftConfigsResponse, error)
ListLuckyGiftDraws(ctx context.Context, req *activityv1.ListLuckyGiftDrawsRequest) (*activityv1.ListLuckyGiftDrawsResponse, 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) RemoveRegionBroadcastMember(ctx context.Context, req *activityv1.RemoveRegionBroadcastMemberRequest) (*activityv1.RemoveRegionBroadcastMemberResponse, error)
ListLevelConfig(ctx context.Context, req *activityv1.ListLevelConfigRequest) (*activityv1.ListLevelConfigResponse, error) ListLevelConfig(ctx context.Context, req *activityv1.ListLevelConfigRequest) (*activityv1.ListLevelConfigResponse, error)
UpsertLevelTrack(ctx context.Context, req *activityv1.UpsertLevelTrackRequest) (*activityv1.UpsertLevelTrackResponse, error) UpsertLevelTrack(ctx context.Context, req *activityv1.UpsertLevelTrackRequest) (*activityv1.UpsertLevelTrackResponse, error)
@ -131,6 +132,10 @@ func (c *GRPCClient) ListLuckyGiftDraws(ctx context.Context, req *activityv1.Lis
return c.luckyGiftClient.ListLuckyGiftDraws(ctx, req) 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) { func (c *GRPCClient) RemoveRegionBroadcastMember(ctx context.Context, req *activityv1.RemoveRegionBroadcastMemberRequest) (*activityv1.RemoveRegionBroadcastMemberResponse, error) {
return c.broadcastClient.RemoveRegionBroadcastMember(ctx, req) return c.broadcastClient.RemoveRegionBroadcastMember(ctx, req)
} }

View File

@ -132,6 +132,21 @@ func (AppVersion) TableName() string {
return "admin_app_versions" 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 { type RefreshToken struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index;not null" json:"userId"` 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)}) 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) { func (h *Handler) ListBanners(c *gin.Context) {
options := shared.ListOptions(c) options := shared.ListOptions(c)
items, err := h.service.ListBanners(appctx.FromContext(c.Request.Context()), repository.AppBannerListOptions{ 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}) 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) { func bannerID(c *gin.Context) (uint, bool) {
value, err := strconv.ParseUint(c.Param("banner_id"), 10, 64) value, err := strconv.ParseUint(c.Param("banner_id"), 10, 64)
if err != nil || value == 0 { if err != nil || value == 0 {
@ -188,6 +306,15 @@ func appVersionID(c *gin.Context) (uint, bool) {
return uint(value), true 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 { func parseOptionalInt64(values ...string) int64 {
for _, value := range values { for _, value := range values {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)

View File

@ -5,8 +5,9 @@ type updateH5LinksRequest struct {
} }
type h5LinkPayload struct { type h5LinkPayload struct {
Key string `json:"key" binding:"required"` Key string `json:"key" binding:"required"`
URL string `json:"url"` Label string `json:"label" binding:"required"`
URL string `json:"url" binding:"required"`
} }
type bannerRequest struct { type bannerRequest struct {
@ -33,3 +34,10 @@ type appVersionRequest struct {
DownloadURL string `json:"downloadUrl" binding:"required"` DownloadURL string `json:"downloadUrl" binding:"required"`
Description string `json:"description"` 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.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", 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.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) protected.POST("/admin/app-config/banners", middleware.RequirePermission("app-config:update"), h.CreateBanner)

View File

@ -25,23 +25,10 @@ const (
bannerStatusExpired = "expired" 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 { type AppConfigService struct {
store *repository.Store store *repository.Store
} }
type H5LinkDefinition struct {
Key string `json:"key"`
Label string `json:"label"`
}
type H5Link struct { type H5Link struct {
Key string `json:"key"` Key string `json:"key"`
Label string `json:"label"` Label string `json:"label"`
@ -82,6 +69,17 @@ type AppVersion struct {
UpdatedAtMs int64 `json:"updatedAtMs"` 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 { func NewService(store *repository.Store) *AppConfigService {
return &AppConfigService{store: store} return &AppConfigService{store: store}
} }
@ -92,22 +90,9 @@ func (s *AppConfigService) ListH5Links() ([]H5Link, error) {
return nil, err return nil, err
} }
configByKey := make(map[string]model.AppConfig, len(configs)) items := make([]H5Link, 0, len(configs))
for _, config := range configs { for _, config := range configs {
configByKey[config.Key] = config items = append(items, h5LinkFromModel(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)
} }
return items, nil return items, nil
} }
@ -117,29 +102,19 @@ func (s *AppConfigService) UpdateH5Links(request updateH5LinksRequest) ([]H5Link
return nil, errors.New("items are required") return nil, errors.New("items are required")
} }
allowed := h5LinkDefinitionSet()
items := make([]model.AppConfig, 0, len(request.Items)) items := make([]model.AppConfig, 0, len(request.Items))
seen := make(map[string]struct{}, len(request.Items)) seen := make(map[string]struct{}, len(request.Items))
for _, item := range request.Items { for _, item := range request.Items {
key := strings.TrimSpace(item.Key) config, err := h5LinkModelFromPayload(item)
if _, ok := allowed[key]; !ok { if err != nil {
return nil, fmt.Errorf("invalid h5 link key: %s", key) return nil, err
} }
key := config.Key
if _, ok := seen[key]; ok { if _, ok := seen[key]; ok {
return nil, fmt.Errorf("duplicate h5 link key: %s", key) return nil, fmt.Errorf("duplicate h5 link key: %s", key)
} }
seen[key] = struct{}{} seen[key] = struct{}{}
items = append(items, config)
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],
})
} }
if err := s.store.UpsertAppConfigs(items); err != nil { if err := s.store.UpsertAppConfigs(items); err != nil {
@ -148,6 +123,39 @@ func (s *AppConfigService) UpdateH5Links(request updateH5LinksRequest) ([]H5Link
return s.ListH5Links() 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) { func (s *AppConfigService) ListBanners(appCode string, options repository.AppBannerListOptions) ([]AppBanner, error) {
options.AppCode = appctx.Normalize(appCode) options.AppCode = appctx.Normalize(appCode)
options.Status = normalizeBannerStatus(options.Status) 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) return s.store.DeleteAppVersion(appctx.Normalize(appCode), id)
} }
func h5LinkDefinitionSet() map[string]string { func (s *AppConfigService) ListExploreTabs(appCode string, options repository.AppExploreTabListOptions) ([]ExploreTab, error) {
out := make(map[string]string, len(h5LinkDefinitions)) options.AppCode = appctx.Normalize(appCode)
for _, definition := range h5LinkDefinitions { options.Keyword = strings.TrimSpace(options.Keyword)
out[definition.Key] = definition.Label 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 { func validateH5URL(value string) error {
@ -288,6 +336,60 @@ func validateH5URL(value string) error {
return nil 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) { func bannerModelFromRequest(appCode string, req bannerRequest) (model.AppBanner, error) {
item := model.AppBanner{ item := model.AppBanner{
AppCode: appctx.Normalize(appCode), 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 { func normalizeBannerType(value string) string {
return strings.ToLower(strings.TrimSpace(value)) return strings.ToLower(strings.TrimSpace(value))
} }

View File

@ -5,6 +5,31 @@ import (
"time" "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) { func TestBannerModelFromRequestRequiresRoomSmallImageForRoomScope(t *testing.T) {
_, err := bannerModelFromRequest("lalu", bannerRequest{ _, err := bannerModelFromRequest("lalu", bannerRequest{
CoverURL: "https://cdn.example.com/banner.png", 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) 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

@ -103,6 +103,22 @@ type drawDTO struct {
CreatedAtMS int64 `json:"created_at_ms"` 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) { func (h *Handler) GetLuckyGiftConfig(c *gin.Context) {
resp, err := h.activity.GetLuckyGiftConfig(c.Request.Context(), &activityv1.GetLuckyGiftConfigRequest{ resp, err := h.activity.GetLuckyGiftConfig(c.Request.Context(), &activityv1.GetLuckyGiftConfigRequest{
Meta: h.meta(c), Meta: h.meta(c),
@ -176,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()}) 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 { func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta {
return &activityv1.RequestMeta{ return &activityv1.RequestMeta{
RequestId: middleware.CurrentRequestID(c), RequestId: middleware.CurrentRequestID(c),
@ -330,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 { func parseOptionalInt64(value string) int64 {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
if value == "" { if value == "" {

View File

@ -15,4 +15,5 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.PUT("/admin/activity/lucky-gifts/config", middleware.RequirePermission("lucky-gift:update"), h.UpsertLuckyGiftConfig) 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/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/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

@ -17,6 +17,7 @@ type resourceDTO struct {
PriceType string `json:"priceType"` PriceType string `json:"priceType"`
CoinPrice int64 `json:"coinPrice"` CoinPrice int64 `json:"coinPrice"`
GiftPointAmount int64 `json:"giftPointAmount"` GiftPointAmount int64 `json:"giftPointAmount"`
BadgeForm string `json:"badgeForm,omitempty"`
UsageScopes []string `json:"usageScopes"` UsageScopes []string `json:"usageScopes"`
AssetURL string `json:"assetUrl"` AssetURL string `json:"assetUrl"`
PreviewURL string `json:"previewUrl"` PreviewURL string `json:"previewUrl"`
@ -178,6 +179,7 @@ func resourceFromProto(item *walletv1.Resource) resourceDTO {
PriceType: item.GetPriceType(), PriceType: item.GetPriceType(),
CoinPrice: item.GetCoinPrice(), CoinPrice: item.GetCoinPrice(),
GiftPointAmount: item.GetGiftPointAmount(), GiftPointAmount: item.GetGiftPointAmount(),
BadgeForm: badgeFormForResource(item.GetResourceType(), item.GetMetadataJson()),
UsageScopes: item.GetUsageScopes(), UsageScopes: item.GetUsageScopes(),
AssetURL: item.GetAssetUrl(), AssetURL: item.GetAssetUrl(),
PreviewURL: item.GetPreviewUrl(), PreviewURL: item.GetPreviewUrl(),

View File

@ -78,6 +78,10 @@ func (h *Handler) CreateResource(c *gin.Context) {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
} }
if err := validateResourceBadgeForm(req); err != nil {
response.BadRequest(c, err.Error())
return
}
resp, err := h.wallet.CreateResource(c.Request.Context(), req.createProto(c)) resp, err := h.wallet.CreateResource(c.Request.Context(), req.createProto(c))
if err != nil { if err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
@ -178,6 +182,10 @@ func (h *Handler) UpdateResource(c *gin.Context) {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
} }
if err := validateResourceBadgeForm(req); err != nil {
response.BadRequest(c, err.Error())
return
}
protoReq := req.updateProto(c, resourceID) protoReq := req.updateProto(c, resourceID)
resp, err := h.wallet.UpdateResource(c.Request.Context(), protoReq) resp, err := h.wallet.UpdateResource(c.Request.Context(), protoReq)
if err != nil { if err != nil {

View File

@ -70,3 +70,29 @@ func TestEmojiPackMetadataKeepsLimitedRegions(t *testing.T) {
t.Fatalf("unexpected region ids: %+v", payload.RegionIDs) 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,6 +18,7 @@ const dayMillis int64 = 24 * 60 * 60 * 1000
const ( const (
resourceTypeCoin = "coin" resourceTypeCoin = "coin"
resourceTypeBadge = "badge"
resourceTypeEmojiPack = "emoji_pack" resourceTypeEmojiPack = "emoji_pack"
) )
@ -26,6 +27,11 @@ const (
resourcePriceTypeFree = "free" resourcePriceTypeFree = "free"
) )
const (
badgeFormStrip = "strip"
badgeFormTile = "tile"
)
const ( const (
defaultEmojiPackCategory = "默认" defaultEmojiPackCategory = "默认"
emojiPackPricingFree = "free" emojiPackPricingFree = "free"
@ -41,6 +47,7 @@ type resourceRequest struct {
PriceType string `json:"priceType"` PriceType string `json:"priceType"`
CoinPrice int64 `json:"coinPrice"` CoinPrice int64 `json:"coinPrice"`
GiftPointAmount int64 `json:"giftPointAmount"` GiftPointAmount int64 `json:"giftPointAmount"`
BadgeForm string `json:"badgeForm"`
ManagerGrantEnabled *bool `json:"managerGrantEnabled"` ManagerGrantEnabled *bool `json:"managerGrantEnabled"`
AssetURL string `json:"assetUrl"` AssetURL string `json:"assetUrl"`
PreviewURL string `json:"previewUrl"` PreviewURL string `json:"previewUrl"`
@ -65,6 +72,11 @@ type emojiPackMetadataPayload struct {
RegionScope string `json:"region_scope"` RegionScope string `json:"region_scope"`
} }
type badgeMetadataPayload struct {
BadgeForm string `json:"badge_form"`
DefaultSlot string `json:"default_slot,omitempty"`
}
type resourceGroupRequest struct { type resourceGroupRequest struct {
GroupCode string `json:"groupCode"` GroupCode string `json:"groupCode"`
Name string `json:"name"` Name string `json:"name"`
@ -130,6 +142,7 @@ func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceReq
resourceType := normalizeResourceType(r.ResourceType) resourceType := normalizeResourceType(r.ResourceType)
walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount) walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount)
priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice) priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice)
metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm)
return &walletv1.CreateResourceRequest{ return &walletv1.CreateResourceRequest{
RequestId: middleware.CurrentRequestID(c), RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()), AppCode: appctx.FromContext(c.Request.Context()),
@ -149,7 +162,7 @@ func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceReq
AssetUrl: strings.TrimSpace(r.AssetURL), AssetUrl: strings.TrimSpace(r.AssetURL),
PreviewUrl: strings.TrimSpace(r.PreviewURL), PreviewUrl: strings.TrimSpace(r.PreviewURL),
AnimationUrl: strings.TrimSpace(r.AnimationURL), AnimationUrl: strings.TrimSpace(r.AnimationURL),
MetadataJson: "{}", MetadataJson: metadataJSON,
SortOrder: r.SortOrder, SortOrder: r.SortOrder,
OperatorUserId: actorID(c), OperatorUserId: actorID(c),
} }
@ -159,6 +172,7 @@ func (r resourceRequest) updateProto(c *gin.Context, resourceID int64) *walletv1
resourceType := normalizeResourceType(r.ResourceType) resourceType := normalizeResourceType(r.ResourceType)
walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount) walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount)
priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice) priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice)
metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm)
return &walletv1.UpdateResourceRequest{ return &walletv1.UpdateResourceRequest{
RequestId: middleware.CurrentRequestID(c), RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()), AppCode: appctx.FromContext(c.Request.Context()),
@ -179,7 +193,7 @@ func (r resourceRequest) updateProto(c *gin.Context, resourceID int64) *walletv1
AssetUrl: strings.TrimSpace(r.AssetURL), AssetUrl: strings.TrimSpace(r.AssetURL),
PreviewUrl: strings.TrimSpace(r.PreviewURL), PreviewUrl: strings.TrimSpace(r.PreviewURL),
AnimationUrl: strings.TrimSpace(r.AnimationURL), AnimationUrl: strings.TrimSpace(r.AnimationURL),
MetadataJson: "{}", MetadataJson: metadataJSON,
SortOrder: r.SortOrder, SortOrder: r.SortOrder,
OperatorUserId: actorID(c), OperatorUserId: actorID(c),
} }
@ -379,6 +393,16 @@ func validateResourcePricing(req resourceRequest) error {
return nil 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 { func resourceGrantStrategy(resourceType string) string {
switch resourceType { switch resourceType {
case resourceTypeCoin: case resourceTypeCoin:
@ -392,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) { func emojiPackMetadataJSON(regionIDs []int64, category string, pricingType string) (string, error) {
normalizedRegionIDs := normalizeRegionIDs(regionIDs) normalizedRegionIDs := normalizeRegionIDs(regionIDs)
metadata := emojiPackMetadataPayload{ metadata := emojiPackMetadataPayload{

View File

@ -15,12 +15,36 @@ type AppVersionListOptions struct {
Keyword string Keyword string
} }
type AppExploreTabListOptions struct {
AppCode string
Keyword string
Enabled *bool
}
func (s *Store) ListAppConfigs(group string) ([]model.AppConfig, error) { func (s *Store) ListAppConfigs(group string) ([]model.AppConfig, error) {
var items []model.AppConfig var items []model.AppConfig
err := s.db.Where("`group` = ?", strings.TrimSpace(group)).Order("`key` ASC").Find(&items).Error err := s.db.Where("`group` = ?", strings.TrimSpace(group)).Order("`key` ASC").Find(&items).Error
return items, err 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 { func (s *Store) UpsertAppConfigs(items []model.AppConfig) error {
if len(items) == 0 { if len(items) == 0 {
return nil return nil
@ -77,3 +101,36 @@ func (s *Store) UpdateAppVersion(item *model.AppVersion) error {
func (s *Store) DeleteAppVersion(appCode string, id uint) 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 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.AppConfig{},
&model.AppBanner{}, &model.AppBanner{},
&model.AppVersion{}, &model.AppVersion{},
&model.AppExploreTab{},
&model.RefreshToken{}, &model.RefreshToken{},
&model.LoginLog{}, &model.LoginLog{},
&model.OperationLog{}, &model.OperationLog{},

View File

@ -224,8 +224,9 @@ func (s *Store) seedMenus() error {
{ParentID: &roomsID, Title: "房间配置", Code: "room-config", Path: "/rooms/config", Icon: "settings", PermissionCode: "room-config:view", Sort: 67, Visible: true}, {ParentID: &roomsID, Title: "房间配置", Code: "room-config", Path: "/rooms/config", Icon: "settings", PermissionCode: "room-config:view", Sort: 67, Visible: true},
{ParentID: &appConfigID, Title: "H5配置", Code: "app-config-h5", Path: "/app-config/h5", Icon: "settings", PermissionCode: "app-config:view", Sort: 66, Visible: true}, {ParentID: &appConfigID, Title: "H5配置", Code: "app-config-h5", Path: "/app-config/h5", Icon: "settings", PermissionCode: "app-config:view", Sort: 66, Visible: true},
{ParentID: &appConfigID, Title: "BANNER配置", Code: "app-config-banners", Path: "/app-config/banners", Icon: "image", PermissionCode: "app-config:view", Sort: 67, Visible: true}, {ParentID: &appConfigID, Title: "BANNER配置", Code: "app-config-banners", Path: "/app-config/banners", Icon: "image", PermissionCode: "app-config:view", Sort: 67, Visible: true},
{ParentID: &appConfigID, Title: "内购配置", Code: "payment-recharge-products", Path: "/app-config/recharge-products", Icon: "wallet", PermissionCode: "payment-product:view", Sort: 68, Visible: true}, {ParentID: &appConfigID, Title: "Explore配置", Code: "app-config-explore", Path: "/app-config/explore", Icon: "explore", PermissionCode: "app-config:view", Sort: 68, Visible: true},
{ParentID: &appConfigID, Title: "版本管理", Code: "app-config-versions", Path: "/app-config/versions", Icon: "settings", PermissionCode: "app-version:view", Sort: 69, Visible: true}, {ParentID: &appConfigID, Title: "内购配置", Code: "payment-recharge-products", Path: "/app-config/recharge-products", Icon: "wallet", PermissionCode: "payment-product:view", Sort: 69, Visible: true},
{ParentID: &appConfigID, Title: "版本管理", Code: "app-config-versions", Path: "/app-config/versions", Icon: "settings", PermissionCode: "app-version:view", Sort: 70, Visible: true},
{ParentID: &resourceID, Title: "资源列表", Code: "resource-list", Path: "/resources", Icon: "inventory", PermissionCode: "resource:view", Sort: 67, Visible: true}, {ParentID: &resourceID, Title: "资源列表", Code: "resource-list", Path: "/resources", Icon: "inventory", PermissionCode: "resource:view", Sort: 67, Visible: true},
{ParentID: &resourceID, Title: "资源组列表", Code: "resource-group-list", Path: "/resource-groups", Icon: "category", PermissionCode: "resource-group:view", Sort: 68, Visible: true}, {ParentID: &resourceID, Title: "资源组列表", Code: "resource-group-list", Path: "/resource-groups", Icon: "category", PermissionCode: "resource-group:view", Sort: 68, Visible: true},
{ParentID: &resourceID, Title: "礼物列表", Code: "gift-list", Path: "/gifts", Icon: "gift", PermissionCode: "gift:view", Sort: 69, Visible: true}, {ParentID: &resourceID, Title: "礼物列表", Code: "gift-list", Path: "/gifts", Icon: "gift", PermissionCode: "gift:view", Sort: 69, Visible: true},

View File

@ -0,0 +1,39 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- Explore tab 是 App 运行时配置后台保存全量配置gateway 只下发启用项。
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
CREATE TABLE IF NOT EXISTS admin_app_explore_tabs (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID',
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
tab VARCHAR(80) NOT NULL COMMENT 'Explore tab 展示文案',
h5_url VARCHAR(2048) NOT NULL COMMENT 'Explore tab H5 链接',
enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越小越靠前',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
UNIQUE KEY uk_admin_app_explore_tabs_tab (app_code, tab),
INDEX idx_admin_app_explore_tabs_app_sort (app_code, enabled, sort_order, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='后台 App Explore tab 配置表';
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
SELECT parent.id, 'Explore配置', 'app-config-explore', '/app-config/explore', 'explore', 'app-config:view', 68, TRUE, @now_ms, @now_ms
FROM admin_menus parent
WHERE parent.code = 'app-config'
ON DUPLICATE KEY UPDATE
parent_id = VALUES(parent_id),
title = VALUES(title),
path = VALUES(path),
icon = VALUES(icon),
permission_code = VALUES(permission_code),
sort = VALUES(sort),
visible = VALUES(visible),
updated_at_ms = @now_ms;
UPDATE admin_menus
SET sort = 69, updated_at_ms = @now_ms
WHERE code = 'payment-recharge-products' AND sort < 69;
UPDATE admin_menus
SET sort = 70, updated_at_ms = @now_ms
WHERE code = 'app-config-versions' AND sort < 70;

View File

@ -12,7 +12,7 @@ health_http_addr: ":13106"
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC" mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC"
user_service_addr: "user-service:13005" user_service_addr: "user-service:13005"
wallet_service_addr: "wallet-service:13004" wallet_service_addr: "wallet-service:13004"
mysql_auto_migrate: false mysql_auto_migrate: true
first_recharge_reward_worker: first_recharge_reward_worker:
enabled: false enabled: false
red_packet_broadcast_worker: red_packet_broadcast_worker:

View File

@ -12,7 +12,7 @@ health_http_addr: ":13106"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC" mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC"
user_service_addr: "127.0.0.1:13005" user_service_addr: "127.0.0.1:13005"
wallet_service_addr: "127.0.0.1:13004" wallet_service_addr: "127.0.0.1:13004"
mysql_auto_migrate: false mysql_auto_migrate: true
first_recharge_reward_worker: first_recharge_reward_worker:
enabled: false enabled: false
red_packet_broadcast_worker: red_packet_broadcast_worker:

View File

@ -71,6 +71,12 @@ func New(cfg config.Config) (*App, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if cfg.MySQLAutoMigrate {
if err := repository.Migrate(startupCtx); err != nil {
_ = repository.Close()
return nil, err
}
}
listener, err := net.Listen("tcp", cfg.GRPCAddr) listener, err := net.Listen("tcp", cfg.GRPCAddr)
if err != nil { if err != nil {

View File

@ -127,6 +127,22 @@ type DrawResult struct {
CreatedAtMS int64 CreatedAtMS int64
} }
type DrawSummary struct {
PoolID string
TotalDraws int64
UniqueUsers int64
UniqueRooms int64
TotalSpentCoins int64
TotalRewardCoins int64
BaseRewardCoins int64
RoomAtmosphereRewardCoins int64
ActivitySubsidyCoins int64
ActualRTPPPM int64
PendingDraws int64
GrantedDraws int64
FailedDraws int64
}
type AdminConfigQuery struct { type AdminConfigQuery struct {
PoolID string PoolID string
} }

View File

@ -18,6 +18,7 @@ type Repository interface {
CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error) CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error)
ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error)
ListLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error) ListLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error)
GetLuckyGiftDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error)
} }
// Service 承载幸运礼物查询、抽奖命令和后台规则配置。 // Service 承载幸运礼物查询、抽奖命令和后台规则配置。
@ -108,10 +109,7 @@ func (s *Service) ListDraws(ctx context.Context, query domain.DrawQuery) ([]doma
if err := s.requireRepository(); err != nil { if err := s.requireRepository(); err != nil {
return nil, 0, err return nil, 0, err
} }
query.PoolID = normalizePoolID(query.PoolID) query = normalizeDrawQuery(query)
query.GiftID = strings.TrimSpace(query.GiftID)
query.RoomID = strings.TrimSpace(query.RoomID)
query.Status = strings.TrimSpace(query.Status)
if query.Page <= 0 { if query.Page <= 0 {
query.Page = 1 query.Page = 1
} }
@ -124,9 +122,24 @@ func (s *Service) ListDraws(ctx context.Context, query domain.DrawQuery) ([]doma
return s.repository.ListLuckyGiftDraws(ctx, query) return s.repository.ListLuckyGiftDraws(ctx, query)
} }
func (s *Service) GetDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) {
if err := s.requireRepository(); err != nil {
return domain.DrawSummary{}, err
}
return s.repository.GetLuckyGiftDrawSummary(ctx, normalizeDrawQuery(query))
}
func (s *Service) requireRepository() error { func (s *Service) requireRepository() error {
if s == nil || s.repository == nil { if s == nil || s.repository == nil {
return xerr.New(xerr.Unavailable, "lucky gift repository is not configured") return xerr.New(xerr.Unavailable, "lucky gift repository is not configured")
} }
return nil return nil
} }
func normalizeDrawQuery(query domain.DrawQuery) domain.DrawQuery {
query.PoolID = normalizePoolID(query.PoolID)
query.GiftID = strings.TrimSpace(query.GiftID)
query.RoomID = strings.TrimSpace(query.RoomID)
query.Status = strings.TrimSpace(query.Status)
return query
}

View File

@ -68,3 +68,7 @@ func (r *fakeLuckyGiftRepository) ExecuteLuckyGiftDraw(context.Context, domain.D
func (r *fakeLuckyGiftRepository) ListLuckyGiftDraws(context.Context, domain.DrawQuery) ([]domain.DrawResult, int64, error) { func (r *fakeLuckyGiftRepository) ListLuckyGiftDraws(context.Context, domain.DrawQuery) ([]domain.DrawResult, int64, error) {
return nil, 0, nil return nil, 0, nil
} }
func (r *fakeLuckyGiftRepository) GetLuckyGiftDrawSummary(context.Context, domain.DrawQuery) (domain.DrawSummary, error) {
return domain.DrawSummary{}, nil
}

View File

@ -594,30 +594,7 @@ func (r *Repository) ListLuckyGiftDraws(ctx context.Context, query domain.DrawQu
if r == nil || r.db == nil { if r == nil || r.db == nil {
return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
} }
appCode := appcode.FromContext(ctx) whereSQL, args := luckyDrawWhereClause(appcode.FromContext(ctx), query)
where := []string{"app_code = ?"}
args := []any{appCode}
if query.PoolID != "" {
where = append(where, "pool_id = ?")
args = append(args, query.PoolID)
}
if query.GiftID != "" {
where = append(where, "gift_id = ?")
args = append(args, query.GiftID)
}
if query.UserID > 0 {
where = append(where, "user_id = ?")
args = append(args, query.UserID)
}
if query.RoomID != "" {
where = append(where, "room_id = ?")
args = append(args, query.RoomID)
}
if query.Status != "" {
where = append(where, "reward_status = ?")
args = append(args, query.Status)
}
whereSQL := strings.Join(where, " AND ")
var total int64 var total int64
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM lucky_draw_records WHERE `+whereSQL, args...).Scan(&total); err != nil { if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM lucky_draw_records WHERE `+whereSQL, args...).Scan(&total); err != nil {
return nil, 0, err return nil, 0, err
@ -652,6 +629,74 @@ func (r *Repository) ListLuckyGiftDraws(ctx context.Context, query domain.DrawQu
return items, total, rows.Err() return items, total, rows.Err()
} }
func (r *Repository) GetLuckyGiftDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) {
if r == nil || r.db == nil {
return domain.DrawSummary{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
whereSQL, args := luckyDrawWhereClause(appcode.FromContext(ctx), query)
var summary domain.DrawSummary
summary.PoolID = query.PoolID
if err := r.db.QueryRowContext(ctx, `
SELECT
COUNT(*),
COUNT(DISTINCT user_id),
COUNT(DISTINCT room_id),
COALESCE(SUM(coin_spent), 0),
COALESCE(SUM(effective_reward_coins), 0),
COALESCE(SUM(base_reward_coins), 0),
COALESCE(SUM(room_atmosphere_reward_coins), 0),
COALESCE(SUM(activity_subsidy_coins), 0),
COALESCE(SUM(CASE WHEN reward_status = 'pending' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN reward_status = 'granted' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN reward_status = 'failed' THEN 1 ELSE 0 END), 0)
FROM lucky_draw_records
WHERE `+whereSQL, args...).Scan(
&summary.TotalDraws,
&summary.UniqueUsers,
&summary.UniqueRooms,
&summary.TotalSpentCoins,
&summary.TotalRewardCoins,
&summary.BaseRewardCoins,
&summary.RoomAtmosphereRewardCoins,
&summary.ActivitySubsidyCoins,
&summary.PendingDraws,
&summary.GrantedDraws,
&summary.FailedDraws,
); err != nil {
return domain.DrawSummary{}, err
}
if summary.TotalSpentCoins > 0 {
summary.ActualRTPPPM = summary.TotalRewardCoins * luckyPPMScale / summary.TotalSpentCoins
}
return summary, nil
}
func luckyDrawWhereClause(appCode string, query domain.DrawQuery) (string, []any) {
where := []string{"app_code = ?"}
args := []any{appCode}
if query.PoolID != "" {
where = append(where, "pool_id = ?")
args = append(args, query.PoolID)
}
if query.GiftID != "" {
where = append(where, "gift_id = ?")
args = append(args, query.GiftID)
}
if query.UserID > 0 {
where = append(where, "user_id = ?")
args = append(args, query.UserID)
}
if query.RoomID != "" {
where = append(where, "room_id = ?")
args = append(args, query.RoomID)
}
if query.Status != "" {
where = append(where, "reward_status = ?")
args = append(args, query.Status)
}
return strings.Join(where, " AND "), args
}
type luckyQueryer interface { type luckyQueryer interface {
QueryRowContext(context.Context, string, ...any) *sql.Row QueryRowContext(context.Context, string, ...any) *sql.Row
} }

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"fmt"
"strings" "strings"
_ "github.com/go-sql-driver/mysql" _ "github.com/go-sql-driver/mysql"
@ -36,6 +37,17 @@ func Open(ctx context.Context, dsn string) (*Repository, error) {
return &Repository{db: db}, nil return &Repository{db: db}, nil
} }
// Migrate performs narrow, idempotent local schema fixes for activity-service.
func (r *Repository) Migrate(ctx context.Context) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
if err := r.ensureLuckyDrawPoolID(ctx); err != nil {
return err
}
return nil
}
// Close 释放 MySQL 连接池。 // Close 释放 MySQL 连接池。
func (r *Repository) Close() error { func (r *Repository) Close() error {
if r == nil || r.db == nil { if r == nil || r.db == nil {
@ -54,6 +66,79 @@ func (r *Repository) Ping(ctx context.Context) error {
return r.db.PingContext(ctx) return r.db.PingContext(ctx)
} }
func (r *Repository) ensureLuckyDrawPoolID(ctx context.Context) error {
const table = "lucky_draw_records"
hasPoolID, err := r.columnExists(ctx, table, "pool_id")
if err != nil {
return err
}
if !hasPoolID {
if _, err := r.db.ExecContext(ctx, `
ALTER TABLE lucky_draw_records
ADD COLUMN pool_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '幸运礼物奖池 ID' AFTER anchor_id`); err != nil {
return err
}
}
if _, err := r.db.ExecContext(ctx, `UPDATE lucky_draw_records SET pool_id = gift_id WHERE pool_id = ''`); err != nil {
return err
}
hasPoolIndex, err := r.indexExists(ctx, table, "idx_lucky_draw_pool")
if err != nil {
return err
}
if !hasPoolIndex {
if _, err := r.db.ExecContext(ctx, `CREATE INDEX idx_lucky_draw_pool ON lucky_draw_records (app_code, pool_id, created_at_ms)`); err != nil {
return err
}
}
return nil
}
func (r *Repository) columnExists(ctx context.Context, table string, column string) (bool, error) {
database, err := r.currentDatabase(ctx)
if err != nil {
return false, err
}
var count int
if err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
database, table, column,
).Scan(&count); err != nil {
return false, err
}
return count > 0, nil
}
func (r *Repository) indexExists(ctx context.Context, table string, index string) (bool, error) {
database, err := r.currentDatabase(ctx)
if err != nil {
return false, err
}
var count int
if err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND INDEX_NAME = ?`,
database, table, index,
).Scan(&count); err != nil {
return false, err
}
return count > 0, nil
}
func (r *Repository) currentDatabase(ctx context.Context) (string, error) {
var database sql.NullString
if err := r.db.QueryRowContext(ctx, `SELECT DATABASE()`).Scan(&database); err != nil {
return "", err
}
if !database.Valid || strings.TrimSpace(database.String) == "" {
return "", fmt.Errorf("mysql database is not selected")
}
return database.String, nil
}
// GetActivity 从 MySQL 活动配置表读取同步查询投影。 // GetActivity 从 MySQL 活动配置表读取同步查询投影。
func (r *Repository) GetActivity(ctx context.Context, activityID string) (activitydomain.Activity, error) { func (r *Repository) GetActivity(ctx context.Context, activityID string) (activitydomain.Activity, error) {
if r == nil || r.db == nil { if r == nil || r.db == nil {

View File

@ -0,0 +1,37 @@
package mysql
import (
"context"
"testing"
"hyapp/internal/testutil/mysqlschema"
)
func TestMigrateAddsLuckyDrawPoolIDToLegacyTable(t *testing.T) {
schema := mysqlschema.New(t, mysqlschema.Config{
EnvVar: "ACTIVITY_SERVICE_MYSQL_TEST_DSN",
InitDBPath: mysqlschema.InitDBPath(t, mysqlschema.CallerFile(t, 1), "..", "..", "..", "deploy", "mysql", "initdb", "001_activity_service.sql"),
DatabasePrefix: "hyapp_activity_migrate_test",
})
if _, err := schema.DB.ExecContext(context.Background(), `ALTER TABLE lucky_draw_records DROP INDEX idx_lucky_draw_pool`); err != nil {
t.Fatalf("drop legacy pool index: %v", err)
}
if _, err := schema.DB.ExecContext(context.Background(), `ALTER TABLE lucky_draw_records DROP COLUMN pool_id`); err != nil {
t.Fatalf("drop legacy pool column: %v", err)
}
repository, err := Open(context.Background(), schema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
if err := repository.Migrate(context.Background()); err != nil {
t.Fatalf("Migrate failed: %v", err)
}
if hasColumn, err := repository.columnExists(context.Background(), "lucky_draw_records", "pool_id"); err != nil || !hasColumn {
t.Fatalf("pool_id column missing after migrate: has=%v err=%v", hasColumn, err)
}
if hasIndex, err := repository.indexExists(context.Background(), "lucky_draw_records", "idx_lucky_draw_pool"); err != nil || !hasIndex {
t.Fatalf("idx_lucky_draw_pool missing after migrate: has=%v err=%v", hasIndex, err)
}
}

View File

@ -129,6 +129,21 @@ func (s *AdminLuckyGiftServer) ListLuckyGiftDraws(ctx context.Context, req *acti
return resp, nil return resp, nil
} }
func (s *AdminLuckyGiftServer) GetLuckyGiftDrawSummary(ctx context.Context, req *activityv1.GetLuckyGiftDrawSummaryRequest) (*activityv1.GetLuckyGiftDrawSummaryResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
summary, err := s.svc.GetDrawSummary(ctx, domain.DrawQuery{
GiftID: req.GetGiftId(),
PoolID: req.GetPoolId(),
UserID: req.GetUserId(),
RoomID: req.GetRoomId(),
Status: req.GetStatus(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.GetLuckyGiftDrawSummaryResponse{Summary: luckyDrawSummaryToProto(summary)}, nil
}
func luckyConfigFromProto(config *activityv1.LuckyGiftConfig) domain.Config { func luckyConfigFromProto(config *activityv1.LuckyGiftConfig) domain.Config {
if config == nil { if config == nil {
return domain.Config{} return domain.Config{}
@ -270,3 +285,21 @@ func luckyDrawResultToProto(result domain.DrawResult) *activityv1.LuckyGiftDrawR
CreatedAtMs: result.CreatedAtMS, CreatedAtMs: result.CreatedAtMS,
} }
} }
func luckyDrawSummaryToProto(summary domain.DrawSummary) *activityv1.LuckyGiftDrawSummary {
return &activityv1.LuckyGiftDrawSummary{
PoolId: summary.PoolID,
TotalDraws: summary.TotalDraws,
UniqueUsers: summary.UniqueUsers,
UniqueRooms: summary.UniqueRooms,
TotalSpentCoins: summary.TotalSpentCoins,
TotalRewardCoins: summary.TotalRewardCoins,
BaseRewardCoins: summary.BaseRewardCoins,
RoomAtmosphereRewardCoins: summary.RoomAtmosphereRewardCoins,
ActivitySubsidyCoins: summary.ActivitySubsidyCoins,
ActualRtpPpm: summary.ActualRTPPPM,
PendingDraws: summary.PendingDraws,
GrantedDraws: summary.GrantedDraws,
FailedDraws: summary.FailedDraws,
}
}

View File

@ -52,7 +52,7 @@ grpc_client:
retry_backoff_multiplier: 2 retry_backoff_multiplier: 2
retryable_status_codes: ["UNAVAILABLE"] retryable_status_codes: ["UNAVAILABLE"]
app_config: app_config:
# H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configsgateway 只读下发给 App。 # App 运行时配置由后台 APP配置写入 hyapp_admingateway 只读下发给 App。
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC" mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC"
leaderboard: leaderboard:
# 活动用户榜单只读 wallet_transactions 的送礼事实;时间窗口统一按 UTC 计算。 # 活动用户榜单只读 wallet_transactions 的送礼事实;时间窗口统一按 UTC 计算。

View File

@ -49,7 +49,7 @@ grpc_client:
retry_backoff_multiplier: 2 retry_backoff_multiplier: 2
retryable_status_codes: ["UNAVAILABLE"] retryable_status_codes: ["UNAVAILABLE"]
app_config: app_config:
# H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configsgateway 只读下发给 App。 # App 运行时配置由后台 APP配置写入 hyapp_admingateway 只读下发给 App。
mysql_dsn: "TENCENT_MYSQL_USER:TENCENT_MYSQL_PASSWORD@tcp(TENCENT_MYSQL_HOST:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC" mysql_dsn: "TENCENT_MYSQL_USER:TENCENT_MYSQL_PASSWORD@tcp(TENCENT_MYSQL_HOST:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC"
leaderboard: leaderboard:
# 活动用户榜单只读钱包送礼事实;线上建议配置只读账号。 # 活动用户榜单只读钱包送礼事实;线上建议配置只读账号。

View File

@ -52,7 +52,7 @@ grpc_client:
retry_backoff_multiplier: 2 retry_backoff_multiplier: 2
retryable_status_codes: ["UNAVAILABLE"] retryable_status_codes: ["UNAVAILABLE"]
app_config: app_config:
# H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configsgateway 只读下发给 App。 # App 运行时配置由后台 APP配置写入 hyapp_admingateway 只读下发给 App。
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC" mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC"
leaderboard: leaderboard:
# 活动用户榜单只读 wallet_transactions 的送礼事实;时间窗口统一按 UTC 计算。 # 活动用户榜单只读 wallet_transactions 的送礼事实;时间窗口统一按 UTC 计算。

View File

@ -13,7 +13,12 @@ import (
const h5LinkGroup = "h5-links" const h5LinkGroup = "h5-links"
const listH5LinksSQL = "SELECT `key`, COALESCE(value, ''), updated_at_ms FROM admin_app_configs WHERE `group` = ?" const listH5LinksSQL = "SELECT `key`, COALESCE(description, ''), COALESCE(value, ''), updated_at_ms FROM admin_app_configs WHERE `group` = ? ORDER BY `key` ASC"
const listExploreTabsSQL = `
SELECT id, app_code, tab, h5_url, enabled, sort_order, updated_at_ms
FROM admin_app_explore_tabs
WHERE app_code = ? AND enabled = TRUE
ORDER BY sort_order ASC, id DESC`
const listAppBannersSQL = ` const listAppBannersSQL = `
SELECT id, app_code, cover_url, room_small_image_url, banner_type, display_scope, param, platform, sort_order, region_id, country_code, description, starts_at_ms, ends_at_ms, updated_at_ms SELECT id, app_code, cover_url, room_small_image_url, banner_type, display_scope, param, platform, sort_order, region_id, country_code, description, starts_at_ms, ends_at_ms, updated_at_ms
FROM admin_app_banners FROM admin_app_banners
@ -39,6 +44,17 @@ type H5Link struct {
UpdatedAtMs int64 `json:"updated_at_ms"` UpdatedAtMs int64 `json:"updated_at_ms"`
} }
// ExploreTab 是后台 APP配置/Explore配置 中启用的 H5 tab。
type ExploreTab struct {
ID uint `json:"id"`
AppCode string `json:"app_code"`
Tab string `json:"tab"`
H5URL string `json:"h5_url"`
Enabled bool `json:"enabled"`
SortOrder int `json:"sort_order"`
UpdatedAtMs int64 `json:"updated_at_ms"`
}
// BannerQuery 是 App banner 的公开筛选条件。 // BannerQuery 是 App banner 的公开筛选条件。
type BannerQuery struct { type BannerQuery struct {
AppCode string AppCode string
@ -89,24 +105,12 @@ type Version struct {
// Reader 是 HTTP 层读取 H5 配置的最小依赖。 // Reader 是 HTTP 层读取 H5 配置的最小依赖。
type Reader interface { type Reader interface {
ListH5Links(ctx context.Context) ([]H5Link, error) ListH5Links(ctx context.Context) ([]H5Link, error)
ListExploreTabs(ctx context.Context, appCode string) ([]ExploreTab, error)
ListBanners(ctx context.Context, query BannerQuery) ([]Banner, error) ListBanners(ctx context.Context, query BannerQuery) ([]Banner, error)
LatestVersion(ctx context.Context, query VersionQuery) (Version, error) LatestVersion(ctx context.Context, query VersionQuery) (Version, error)
} }
type h5LinkDefinition struct { // MySQLReader 从 hyapp_admin 读取后台 App 配置。
Key string
Label string
}
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"},
}
// MySQLReader 从 hyapp_admin.admin_app_configs 读取后台 H5 配置。
type MySQLReader struct { type MySQLReader struct {
db *sql.DB db *sql.DB
} }
@ -130,7 +134,7 @@ func (r *MySQLReader) Close() error {
return r.db.Close() return r.db.Close()
} }
// ListH5Links 返回固定 H5 入口集合;未配置的入口保留空 URL方便客户端按 key 稳定渲染 // ListH5Links 返回后台动态维护的 H5 入口集合
func (r *MySQLReader) ListH5Links(ctx context.Context) ([]H5Link, error) { func (r *MySQLReader) ListH5Links(ctx context.Context) ([]H5Link, error) {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return nil, errors.New("app config reader is not configured") return nil, errors.New("app config reader is not configured")
@ -142,40 +146,49 @@ func (r *MySQLReader) ListH5Links(ctx context.Context) ([]H5Link, error) {
} }
defer rows.Close() defer rows.Close()
configs := make(map[string]H5Link, len(h5LinkDefinitions)) items := make([]H5Link, 0)
for rows.Next() { for rows.Next() {
var key string var item H5Link
var url string if err := rows.Scan(&item.Key, &item.Label, &item.URL, &item.UpdatedAtMs); err != nil {
var updatedAtMS int64
if err := rows.Scan(&key, &url, &updatedAtMS); err != nil {
return nil, err return nil, err
} }
item.Key = strings.TrimSpace(item.Key)
key = strings.TrimSpace(key) item.Label = strings.TrimSpace(item.Label)
link := H5Link{ if item.Label == "" {
Key: key, item.Label = item.Key
URL: strings.TrimSpace(url),
} }
link.UpdatedAtMs = updatedAtMS item.URL = strings.TrimSpace(item.URL)
configs[key] = link items = append(items, item)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, err return nil, err
} }
return items, nil
}
items := make([]H5Link, 0, len(h5LinkDefinitions)) // ListExploreTabs 返回当前 App 启用的 Explore H5 tabs后台关闭的 tab 不下发给客户端。
for _, definition := range h5LinkDefinitions { func (r *MySQLReader) ListExploreTabs(ctx context.Context, appCode string) ([]ExploreTab, error) {
item := H5Link{ if r == nil || r.db == nil {
Key: definition.Key, return nil, errors.New("app config reader is not configured")
Label: definition.Label, }
}
if config, ok := configs[definition.Key]; ok { rows, err := r.db.QueryContext(ctx, listExploreTabsSQL, normalizeAppCode(appCode))
item.URL = config.URL if err != nil {
item.UpdatedAtMs = config.UpdatedAtMs return nil, err
}
defer rows.Close()
items := make([]ExploreTab, 0)
for rows.Next() {
var item ExploreTab
if err := rows.Scan(&item.ID, &item.AppCode, &item.Tab, &item.H5URL, &item.Enabled, &item.SortOrder, &item.UpdatedAtMs); err != nil {
return nil, err
} }
items = append(items, item) items = append(items, item)
} }
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil return items, nil
} }
@ -261,10 +274,11 @@ func (r *MySQLReader) LatestVersion(ctx context.Context, query VersionQuery) (Ve
// StaticReader 给测试和临时环境提供内存版 H5 配置读取。 // StaticReader 给测试和临时环境提供内存版 H5 配置读取。
type StaticReader struct { type StaticReader struct {
Links []H5Link Links []H5Link
Banners []Banner ExploreTabs []ExploreTab
Version Version Banners []Banner
Err error Version Version
Err error
} }
// ListH5Links 返回预置 H5 配置。 // ListH5Links 返回预置 H5 配置。
@ -278,6 +292,17 @@ func (r StaticReader) ListH5Links(context.Context) ([]H5Link, error) {
return out, nil return out, nil
} }
// ListExploreTabs 返回预置 Explore tab 配置。
func (r StaticReader) ListExploreTabs(context.Context, string) ([]ExploreTab, error) {
if r.Err != nil {
return nil, r.Err
}
out := make([]ExploreTab, len(r.ExploreTabs))
copy(out, r.ExploreTabs)
return out, nil
}
// ListBanners 返回预置 App banner 配置。 // ListBanners 返回预置 App banner 配置。
func (r StaticReader) ListBanners(context.Context, BannerQuery) ([]Banner, error) { func (r StaticReader) ListBanners(context.Context, BannerQuery) ([]Banner, error) {
if r.Err != nil { if r.Err != nil {

View File

@ -110,7 +110,7 @@ type LoginRiskFastGuardConfig struct {
// AppConfigConfig 描述 gateway 读取后台 App 运行时配置的只读数据源。 // AppConfigConfig 描述 gateway 读取后台 App 运行时配置的只读数据源。
type AppConfigConfig struct { type AppConfigConfig struct {
// MySQLDSN 指向后台管理库 hyapp_admingateway 只读 admin_app_configs // MySQLDSN 指向后台管理库 hyapp_admingateway 只读 App 运行时配置表
MySQLDSN string `yaml:"mysql_dsn"` MySQLDSN string `yaml:"mysql_dsn"`
} }

View File

@ -67,11 +67,12 @@ func (h *Handler) getAppBootstrap(writer http.ResponseWriter, request *http.Requ
{Key: "me", Title: "我的", Enabled: true}, {Key: "me", Title: "我的", Enabled: true},
}, },
ConfigVersions: map[string]string{ ConfigVersions: map[string]string{
"countries": "v1", "countries": "v1",
"banners": "v1", "banners": "v1",
"h5_links": "v1", "h5_links": "v1",
"resources": "v1", "explore_tabs": "v1",
"gifts": "v1", "resources": "v1",
"gifts": "v1",
}, },
}) })
} }
@ -94,6 +95,22 @@ func (h *Handler) listH5Links(writer http.ResponseWriter, request *http.Request)
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": len(items)}) httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": len(items)})
} }
// listExploreTabs 返回后台 APP配置/Explore配置 中启用的 H5 tab。
func (h *Handler) listExploreTabs(writer http.ResponseWriter, request *http.Request) {
if h.appConfigReader == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
items, err := h.appConfigReader.ListExploreTabs(request.Context(), appcode.FromContext(request.Context()))
if err != nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": len(items)})
}
// listAppBanners 返回后台 APP配置/BANNER配置 中维护的指定显示范围 banner。 // listAppBanners 返回后台 APP配置/BANNER配置 中维护的指定显示范围 banner。
func (h *Handler) listAppBanners(writer http.ResponseWriter, request *http.Request) { func (h *Handler) listAppBanners(writer http.ResponseWriter, request *http.Request) {
if h.appConfigReader == nil { if h.appConfigReader == nil {

View File

@ -12,6 +12,7 @@ import (
// ConfigReader 是 gateway 对后台 App 配置的只读依赖。 // ConfigReader 是 gateway 对后台 App 配置的只读依赖。
type ConfigReader interface { type ConfigReader interface {
ListH5Links(ctx context.Context) ([]appconfig.H5Link, error) ListH5Links(ctx context.Context) ([]appconfig.H5Link, error)
ListExploreTabs(ctx context.Context, appCode string) ([]appconfig.ExploreTab, error)
ListBanners(ctx context.Context, query appconfig.BannerQuery) ([]appconfig.Banner, error) ListBanners(ctx context.Context, query appconfig.BannerQuery) ([]appconfig.Banner, error)
LatestVersion(ctx context.Context, query appconfig.VersionQuery) (appconfig.Version, error) LatestVersion(ctx context.Context, query appconfig.VersionQuery) (appconfig.Version, error)
} }
@ -51,6 +52,10 @@ func (h *Handler) ListH5Links(writer http.ResponseWriter, request *http.Request)
h.listH5Links(writer, request) h.listH5Links(writer, request)
} }
func (h *Handler) ListExploreTabs(writer http.ResponseWriter, request *http.Request) {
h.listExploreTabs(writer, request)
}
func (h *Handler) ListAppBanners(writer http.ResponseWriter, request *http.Request) { func (h *Handler) ListAppBanners(writer http.ResponseWriter, request *http.Request) {
h.listAppBanners(writer, request) h.listAppBanners(writer, request)
} }

View File

@ -42,6 +42,7 @@ type AppHandlers struct {
ListLoginRiskBlockedCountries http.HandlerFunc ListLoginRiskBlockedCountries http.HandlerFunc
GetAppBootstrap http.HandlerFunc GetAppBootstrap http.HandlerFunc
ListH5Links http.HandlerFunc ListH5Links http.HandlerFunc
ListExploreTabs http.HandlerFunc
ListAppBanners http.HandlerFunc ListAppBanners http.HandlerFunc
GetAppVersion http.HandlerFunc GetAppVersion http.HandlerFunc
GetResourceGroup http.HandlerFunc GetResourceGroup http.HandlerFunc
@ -239,6 +240,7 @@ func (r routes) registerAppRoutes() {
r.public("/login-risk/blocked-countries", http.MethodGet, h.ListLoginRiskBlockedCountries) r.public("/login-risk/blocked-countries", http.MethodGet, h.ListLoginRiskBlockedCountries)
r.public("/app/bootstrap", "", h.GetAppBootstrap) r.public("/app/bootstrap", "", h.GetAppBootstrap)
r.public("/app/h5-links", "", h.ListH5Links) r.public("/app/h5-links", "", h.ListH5Links)
r.public("/app/explore-tabs", http.MethodGet, h.ListExploreTabs)
r.public("/app/banners", "", h.ListAppBanners) r.public("/app/banners", "", h.ListAppBanners)
r.public("/app/version", http.MethodGet, h.GetAppVersion) r.public("/app/version", http.MethodGet, h.GetAppVersion)
r.public("/resource-groups/{group_id}", "", h.GetResourceGroup) r.public("/resource-groups/{group_id}", "", h.GetResourceGroup)

View File

@ -2217,6 +2217,53 @@ func TestListH5LinksRequiresConfigReader(t *testing.T) {
assertEnvelope(t, recorder, http.StatusBadGateway, httpkit.CodeUpstreamError, "req-h5-links-missing") assertEnvelope(t, recorder, http.StatusBadGateway, httpkit.CodeUpstreamError, "req-h5-links-missing")
} }
func TestListExploreTabsReturnsEnabledAdminConfig(t *testing.T) {
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetAppConfigReader(appconfig.StaticReader{ExploreTabs: []appconfig.ExploreTab{
{ID: 7, AppCode: "lalu", Tab: "推荐", H5URL: "https://h5.example.com/explore/recommend", Enabled: true, SortOrder: 1, UpdatedAtMs: 1700000000000},
{ID: 8, AppCode: "lalu", Tab: "游戏", H5URL: "https://h5.example.com/explore/games", Enabled: true, SortOrder: 2, UpdatedAtMs: 1700000001000},
}})
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/app/explore-tabs", nil)
request.Header.Set("X-Request-ID", "req-explore-tabs")
request.Header.Set("X-App-Package", "com.org.laluparty")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != httpkit.CodeOK || !ok || data["total"].(float64) != 2 {
t.Fatalf("unexpected explore response: %+v", response)
}
items, ok := data["items"].([]any)
if !ok || len(items) != 2 {
t.Fatalf("explore items shape mismatch: %+v", data)
}
first, ok := items[0].(map[string]any)
if !ok || first["tab"] != "推荐" || first["h5_url"] != "https://h5.example.com/explore/recommend" || first["enabled"] != true {
t.Fatalf("explore item mismatch: %+v", first)
}
}
func TestListExploreTabsRequiresConfigReader(t *testing.T) {
router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}).Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/app/explore-tabs", nil)
request.Header.Set("X-Request-ID", "req-explore-tabs-missing")
request.Header.Set("X-App-Package", "com.org.laluparty")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusBadGateway, httpkit.CodeUpstreamError, "req-explore-tabs-missing")
}
func TestAppBootstrapIsPublicAndLightweight(t *testing.T) { func TestAppBootstrapIsPublicAndLightweight(t *testing.T) {
router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}).Routes(auth.NewVerifier("secret")) router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}).Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/app/bootstrap", nil) request := httptest.NewRequest(http.MethodGet, "/api/v1/app/bootstrap", nil)

View File

@ -128,6 +128,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
ListLoginRiskBlockedCountries: userAPI.ListLoginRiskBlockedCountries, ListLoginRiskBlockedCountries: userAPI.ListLoginRiskBlockedCountries,
GetAppBootstrap: appAPI.GetAppBootstrap, GetAppBootstrap: appAPI.GetAppBootstrap,
ListH5Links: appAPI.ListH5Links, ListH5Links: appAPI.ListH5Links,
ListExploreTabs: appAPI.ListExploreTabs,
ListAppBanners: appAPI.ListAppBanners, ListAppBanners: appAPI.ListAppBanners,
GetAppVersion: appAPI.GetAppVersion, GetAppVersion: appAPI.GetAppVersion,
GetResourceGroup: resourceAPI.GetResourceGroup, GetResourceGroup: resourceAPI.GetResourceGroup,