From 1b3cf31a7d13d196d6e48cecad56903b7c3c954b Mon Sep 17 00:00:00 2001 From: zhx Date: Mon, 25 May 2026 19:36:27 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 19 +- README.md | 54 +- api/proto/activity/v1/activity.pb.go | 563 ++++++++++++++---- api/proto/activity/v1/activity.proto | 30 + api/proto/activity/v1/activity_grpc.pb.go | 48 +- api/proto/events/room/v1/events.pb.go | 2 +- api/proto/game/v1/game.pb.go | 2 +- api/proto/game/v1/game_grpc.pb.go | 2 +- api/proto/room/v1/room.pb.go | 2 +- api/proto/room/v1/room_grpc.pb.go | 2 +- api/proto/user/v1/auth.pb.go | 2 +- api/proto/user/v1/auth_grpc.pb.go | 2 +- api/proto/user/v1/host.pb.go | 2 +- api/proto/user/v1/host_grpc.pb.go | 2 +- api/proto/user/v1/user.pb.go | 2 +- api/proto/user/v1/user_grpc.pb.go | 2 +- api/proto/wallet/v1/wallet.pb.go | 2 +- api/proto/wallet/v1/wallet_grpc.pb.go | 2 +- .../flutter对接/Google Play充值Flutter对接.md | 382 ++++++++++++ docs/统计服务技术方案.md | 268 +++++++++ .../integration/activityclient/client.go | 5 + server/admin/internal/model/models.go | 15 + .../internal/modules/appconfig/handler.go | 127 ++++ .../internal/modules/appconfig/request.go | 12 +- .../internal/modules/appconfig/routes.go | 8 + .../internal/modules/appconfig/service.go | 231 +++++-- .../modules/appconfig/service_test.go | 51 ++ .../internal/modules/luckygift/handler.go | 53 ++ .../internal/modules/luckygift/routes.go | 1 + server/admin/internal/modules/resource/dto.go | 2 + .../internal/modules/resource/handler.go | 8 + .../modules/resource/operator_test.go | 26 + .../internal/modules/resource/request.go | 74 ++- .../repository/app_config_repository.go | 57 ++ .../admin/internal/repository/repository.go | 1 + server/admin/internal/repository/seed.go | 5 +- .../migrations/022_app_explore_config.sql | 39 ++ .../configs/config.docker.yaml | 2 +- services/activity-service/configs/config.yaml | 2 +- services/activity-service/internal/app/app.go | 6 + .../internal/domain/luckygift/lucky_gift.go | 16 + .../internal/service/luckygift/service.go | 21 +- .../service/luckygift/service_test.go | 4 + .../storage/mysql/lucky_gift_repository.go | 93 ++- .../internal/storage/mysql/repository.go | 85 +++ .../mysql/repository_migration_test.go | 37 ++ .../transport/grpc/lucky_gift_server.go | 33 + .../configs/config.docker.yaml | 2 +- .../configs/config.tencent.example.yaml | 2 +- services/gateway-service/configs/config.yaml | 2 +- .../internal/appconfig/mysql.go | 109 ++-- .../gateway-service/internal/config/config.go | 2 +- .../http/appapi/app_config_handler.go | 27 +- .../internal/transport/http/appapi/handler.go | 5 + .../transport/http/httproutes/router.go | 2 + .../internal/transport/http/response_test.go | 47 ++ .../internal/transport/http/router.go | 1 + 57 files changed, 2310 insertions(+), 293 deletions(-) create mode 100644 docs/flutter对接/Google Play充值Flutter对接.md create mode 100644 docs/统计服务技术方案.md create mode 100644 server/admin/migrations/022_app_explore_config.sql create mode 100644 services/activity-service/internal/storage/mysql/repository_migration_test.go diff --git a/AGENTS.md b/AGENTS.md index 262bb3f5..4d1cff2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,10 +21,23 @@ - `notice-service` 负责从各 owner service 的 outbox/事件源读取事实并投递私有通知、腾讯云 IM 单聊、后续 push/inbox;钱包、活动和房间不能直接耦合私有通知投递实现。 - `user-service` 负责用户主数据,不应该成为房间高频流程的同步中心。 - `cron-service` 只负责后台调度、任务运行记录和通过内部 gRPC 触发 owner service 批处理;不能直接拥有房间、账务、用户或活动业务状态。 +- `statistics-service` 只负责消费 owner service 已发布到 RocketMQ 的业务事实并写本服务聚合表;它不是业务事实 owner,不能直连 room/wallet/user/game 等 owner 数据库补数或查询明细。 - `server/admin` 是后台管理服务独立 module,负责管理员、RBAC、菜单、审计和后台 HTTP API;访问 app 后端只能通过 `hyapp.local/api` 生成的 protobuf client 或明确的集成接口。 - `api` 是独立 Go module,module path 为 `hyapp.local/api`,只放 protobuf contract 和生成代码,不放服务实现或业务工具包。 -- `wallet_outbox` 和 `room_outbox` 只能由各自 owner service 直接读取并发布到 RocketMQ;其他服务不能配置 `wallet_mysql_dsn`、`room_mysql_dsn` 或直连 owner 数据库扫描 outbox。 -- 下游服务消费 wallet/room outbox 必须使用自己的 RocketMQ consumer group 和本服务幂等表;消费位点属于消息系统和本地幂等事实,不能共享 owner service 的 outbox 状态字段。 +- `wallet_outbox`、`room_outbox`、`user_outbox` 和 `game_outbox` 只能由各自 owner service 直接读取并发布到 RocketMQ;其他服务不能配置 owner 数据库 DSN 或直连 owner 数据库扫描 outbox。 +- 下游服务消费 owner outbox 必须使用自己的 RocketMQ consumer group 和本服务幂等表;消费位点属于消息系统和本地幂等事实,不能共享 owner service 的 outbox 状态字段。 +- 数据大屏查询入口在 `server/admin`,由 admin 调 `statistics-service` 内部查询 API;`gateway-service` 是 App HTTP 入口,除非有明确 App 产品需求,否则不要把后台统计大屏暴露到 gateway。 + +## Statistics Rules + +- 硬性规则:统计查询不能查全表,不能回查 owner service 明细表,不能在 HTTP handler 临时扫业务流水;所有统计展示一律查询 `statistics-service` 的聚合表。 +- `statistics-service` 的事实输入只来自 RocketMQ:`WalletRechargeRecorded`、`GameOrderSettled`、`RoomGiftSent`、`RoomUserJoined`、`UserRegistered` 等事件必须先由 owner service outbox 持久化,再由 owner service 发布。 +- 充值口径以 `WalletRechargeRecorded` 为准;渠道分桶只能使用事件里的 `recharge_type`,当前缺渠道时归入币商充值,不能从支付表或用户资料反推。 +- 游戏口径以 `GameOrderSettled` 为准;`debit/bet` 计投注流水,`credit/payout` 计返奖,`refund/reverse` 计退款,利润按 `投注 - 返奖 - 退款` 计算。 +- 活跃口径以事实事件去重用户:进房来自 `RoomUserJoined`,游戏活跃来自 `GameOrderSettled`,统计服务用本地唯一键收敛日活,不能依赖 Redis presence。 +- 留存口径以 `UserRegistered` cohort 和 `stat_user_day_activity` 活跃表计算;注册、活跃、充值、游戏和礼物事件都按 UTC `stat_day` 聚合。 +- ARPU 直接由充值聚合除以活跃用户;ARPPU/UP 值直接由充值聚合除以付费用户。产品若要调整 UP 值定义,必须先改明确字段名和边界测试。 +- 国家维度只能来自事件字段,例如用户注册 region、房间 `visible_region_id`、充值目标区域或游戏事件区域;不能用服务所在地、IP 或本地时区推断。 ## Persistence Rules @@ -32,6 +45,7 @@ - `memory` repository/directory 只用于单元测试或明确的本地隔离测试。 - MySQL 是房间恢复的持久来源:`rooms`、`room_snapshots`、`room_command_log`、`room_outbox`。 - `wallet-service` 的 `wallet_outbox` 是账务事实的持久来源;它负责补偿发布到 RocketMQ,下游不得把钱包库当作事件查询库。 +- `statistics-service` 的 MySQL 聚合表是数据大屏唯一查询来源:`statistics_event_consumption` 记录幂等事实,`stat_app_day_country`、`stat_game_day_country`、`stat_user_day_activity`、`stat_user_registration`、`stat_recharge_day_payers` 和 `stat_lucky_gift_day_payers` 保存统计读模型。 - Redis 只保存当前路由/lease 等热状态,不能作为唯一恢复来源。 - Redis lease 更新必须保持原子性,不能改回非原子的 GET + SET 接管逻辑。 @@ -68,6 +82,7 @@ - `13007`: cron gRPC - `13008`: game gRPC - `13009`: notice gRPC +- `13010`: statistics query HTTP - `23306`: local Docker MySQL host port - `13379`: local Docker Redis host port diff --git a/README.md b/README.md index 38af740c..182ce8f3 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连 - `activity-service` 当前承接每日任务、活动/系统消息基础能力和房间事件 consumer;可通过 room outbox direct gRPC 或 RocketMQ `hyapp_room_outbox` 独立 consumer group 消费房间事实。 - `cron-service` 当前负责后台调度、任务运行记录,以及通过内部 gRPC 触发 owner service 批处理;它不直接拥有房间、账务、用户或活动业务状态。 - `notice-service` 当前消费 `wallet_outbox` 的余额变更事件和 `room_outbox` 的踢人事件,异步投递腾讯云 IM 单聊私有通知;房间群消息和群成员控制仍由 `room-service` 的房间 IM bridge 处理,MQ 模式下通过独立 consumer group 解耦。 +- `statistics-service` 当前消费 user/wallet/room/game outbox 的 RocketMQ 事实,写入本服务 MySQL 聚合表,并通过内部 HTTP 查询 API 支撑后台数据大屏;它不拥有注册、账务、房间或游戏业务事实。 - 腾讯云 IM 承担客户端长连接、群消息、公屏、单聊、离线/漫游和全球接入能力;本仓库不再部署 `im-service`。 - 多 App 共用同一套后端服务,入口由 `gateway-service` 通过包名或 `app_code` 解析租户;所有业务库表按 `app_code` 隔离,设计细节见 `docs/多App租户架构.md`。 @@ -23,9 +24,10 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连 - 客户端命令面:`gateway-service` 暴露 HTTP JSON。 - 客户端实时面:腾讯云 IM SDK,客户端用 gateway 签发的 UserSig 登录。 - 服务内部调用:gRPC + protobuf,契约在独立 Go module `api`,业务代码通过 `hyapp.local/api/proto/...` 引用生成包。 -- 房间外事件:protobuf outbox 先落 MySQL;生产可由 RocketMQ fanout 到 activity、notice、IM bridge 和后续 push/inbox,消费端按 `event_id` 幂等。 -- outbox 读取权只属于 owner service。`room-service` 读取 `room_outbox` 并发布 RocketMQ,`wallet-service` 读取 `wallet_outbox` 并发布 RocketMQ;其他服务不能通过 `room_mysql_dsn`、`wallet_mysql_dsn` 直连 owner 数据库扫描 outbox。 -- 下游消费 wallet/room outbox 必须使用本服务独立 RocketMQ consumer group,并在本服务库内用幂等表或唯一键记录已消费 `event_id`;消费位点不能写回或复用 owner service 的 outbox 状态。 +- 房间外事件:protobuf/JSON outbox 先落 MySQL;生产可由 RocketMQ fanout 到 activity、notice、IM bridge、statistics 和后续 push/inbox,消费端按 `event_id` 幂等。 +- outbox 读取权只属于 owner service。`room-service` 读取 `room_outbox`、`wallet-service` 读取 `wallet_outbox`、`user-service` 读取 `user_outbox`、`game-service` 读取 `game_outbox` 并发布 RocketMQ;其他服务不能直连 owner 数据库扫描 outbox。 +- 下游消费 owner outbox 必须使用本服务独立 RocketMQ consumer group,并在本服务库内用幂等表或唯一键记录已消费 `event_id`;消费位点不能写回或复用 owner service 的 outbox 状态。 +- 后台统计查询面:`server/admin` 调 `statistics-service` 内部 HTTP API;`gateway-service` 是 App 入口,除非有明确 App 产品需求,否则不暴露后台数据大屏统计。 ## Time Model @@ -51,9 +53,40 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连 | `cron-service` | `13007` | gRPC | | `game-service` | `13008` | gRPC | | `notice-service` | `13009` | gRPC | +| `statistics-service` | `13010` | internal query HTTP | +| `statistics-service` | `13110` | health HTTP | | MySQL | `23306 -> 3306` | local Docker only | | Redis | `13379 -> 6379` | local Docker only | +## Statistics + +统计系统的硬边界是“不查全表,不回查 owner 明细库”。数据大屏只能查询 `statistics-service` 的聚合表;任何新指标都必须先定义事件事实、聚合表字段和 UTC 时间范围,再接入查询接口。 + +事实来源: + +- `user-service`: `UserRegistered`,用于新增用户和留存 cohort。 +- `wallet-service`: `WalletRechargeRecorded`,用于总充值、首充、付费用户、币商/MifaPay/Google 渠道分桶、ARPU、ARPPU 和 UP 值。 +- `room-service`: `RoomUserJoined` 用于进房活跃,`RoomGiftSent` 用于礼物消耗、幸运礼物流水和幸运礼物付费用户;国家维度来自事件中的 `visible_region_id`。 +- `game-service`: `GameOrderSettled`,`debit/bet` 计投注流水,`credit/payout` 计返奖,`refund/reverse` 计退款;游戏利润为 `投注 - 返奖 - 退款`。 + +聚合读模型: + +- `statistics_event_consumption`: 本服务消费幂等表,按 `app_code + source + event_id` 去重。 +- `stat_app_day_country`: App/国家/UTC 日总览,保存新增、活跃、付费、充值渠道、礼物、幸运礼物、游戏总览。 +- `stat_user_day_activity`: 用户 UTC 日活跃事实,用于 DAU 和留存 join。 +- `stat_user_registration`: 注册 cohort,用于次留、7 留和 30 留。 +- `stat_recharge_day_payers`: 充值付费用户去重,用于付费人数、ARPPU 和 UP 值。 +- `stat_lucky_gift_day_payers`: 幸运礼物付费用户去重。 +- `stat_game_day_country` / `stat_game_day_players`: 游戏维度流水、返奖、退款、玩家数和排行。 + +查询契约: + +```text +GET /internal/v1/statistics/overview?app_code=lalu&start_ms=&end_ms=&country_id= +``` + +`start_ms`/`end_ms` 统一使用 `[start_ms, end_ms)`。返回值只来自聚合表;`ARPU = recharge_usd_minor / active_users`,`ARPPU = recharge_usd_minor / paid_users`,当前 `up_value_usd_minor` 与 ARPPU 同口径。产品若要改变 UP 值定义,必须先改字段名、契约和边界测试。 + ## Run Locally Use Docker for local and test environments: @@ -134,12 +167,12 @@ docs/flutter对接/Flutter App 通知与钱包余额对接.md ## 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` 读取和发布。 -- `user-service`、`activity-service`、`notice-service`、`cron-service` 等非 owner service 不能配置 `room_mysql_dsn`、`wallet_mysql_dsn` 或扫描其他服务 outbox 表。 +- `room_outbox` 只由 `room-service` 读取和发布;`wallet_outbox` 只由 `wallet-service` 读取和发布;`user_outbox` 只由 `user-service` 读取和发布;`game_outbox` 只由 `game-service` 读取和发布。 +- `statistics-service`、`activity-service`、`notice-service`、`cron-service` 等非 owner service 不能配置 owner 数据库 DSN 或扫描其他服务 outbox 表。 - 每个业务消费者使用独立 consumer group。即使消费同一个 topic,不同业务也不能共用 group,否则会互相抢消息。 - 每个消费者在自己的库内维护幂等事实,例如按 `app_code + event_id` 或业务唯一键建唯一约束;RocketMQ 的消费位点只解决投递进度,不替代业务幂等。 - owner service 的 MySQL outbox 仍是可靠事实源。RocketMQ 发布失败由 owner outbox worker 补偿重试;下游处理失败由各自 consumer group 重投和本地幂等表收敛。 @@ -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-room-im-bridge` | room-service IM bridge 消费房间群系统消息和群成员控制事实 | | `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-user-mictime-room-outbox` | user-service 消费 `RoomMicChanged` 并更新用户麦时读模型 | +| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-statistics-room-outbox` | statistics-service 消费 `RoomGiftSent`、`RoomUserJoined` 并更新聚合表 | +| `hyapp_user_outbox` | `hyapp-user-outbox-producer` | `hyapp-statistics-user-outbox` | statistics-service 消费 `UserRegistered` 并更新注册 cohort | | `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-user-invite-wallet-outbox` | user-service 消费 `WalletRechargeRecorded` 并更新邀请有效充值 | | `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-activity-first-recharge-wallet-outbox` | activity-service 消费 `WalletRechargeRecorded` 并处理首充奖励 | | `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-activity-red-packet-wallet-outbox` | activity-service 消费 `WalletRedPacketCreated` 并生成区域飘屏 outbox | | `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-notice-wallet-outbox` | notice-service 消费余额、账务等私有通知事实 | +| `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-statistics-wallet-outbox` | statistics-service 消费 `WalletRechargeRecorded` 并更新充值聚合 | +| `hyapp_game_outbox` | `hyapp-game-outbox-producer` | `hyapp-statistics-game-outbox` | statistics-service 消费 `GameOrderSettled` 并更新游戏聚合 | | `hyapp_room_treasure_open` | `hyapp-room-treasure-open-producer` | `hyapp-room-treasure-open` | 宝箱倒计时到点唤醒 room-service 开箱命令 | -RocketMQ 不拥有房间或账务状态。`room_outbox`、`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 @@ -171,6 +208,7 @@ MySQL 是本地和线上运行的持久化底座: - `cron-service`: `hyapp_cron`,保存后台任务定义、运行记录和调度锁;实际业务批处理仍由各 owner service 执行。 - `game-service`: `hyapp_game`,保存游戏平台、房间游戏会话和游戏交易映射。 - `notice-service`: `hyapp_notice`,保存外部通知投递位点、重试锁和死信状态;业务事实仍留在 owner service outbox。 +- `statistics-service`: `hyapp_statistics`,保存事件消费幂等和数据大屏聚合读模型;禁止把 owner service 明细表当作统计查询来源。 - `hyapp_admin`: 后台管理后端独立库,只保存后台账号、权限和审计;不要把后台审计耦合进 App 业务库。 `room-service` uses MySQL as the room recovery durable source: @@ -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/game-service/ game session and wallet integration services/notice-service/ async user notice delivery workers +services/statistics-service/ data dashboard event consumers and aggregate query API ``` ## Current Limits @@ -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。 - `activity-service` 已承接每日任务、消息/活动基础能力和 room outbox consumer;本地可走 direct gRPC,线上推荐走 RocketMQ `hyapp_room_outbox`。 - `room-service` App 已启动 `room_outbox` 补偿 worker;`direct` 模式表示直连 publisher 完成,`mq` 模式表示事件已发布到 RocketMQ,下游成功由各服务消费位点保证。 +- `statistics-service` 只保证从已发布 MQ 事件生成聚合表;如果历史库没有对应 outbox 事实,例如早期用户没有 `UserRegistered`、本地没有 `game_outbox`,统计结果会按当前事实为 0,不允许临时扫业务表补数。 - JWT、腾讯云 IM secret 等本地配置只能用开发值,线上必须走安全配置源。 diff --git a/api/proto/activity/v1/activity.pb.go b/api/proto/activity/v1/activity.pb.go index 4a513c45..44e3e26b 100644 --- a/api/proto/activity/v1/activity.pb.go +++ b/api/proto/activity/v1/activity.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.26.1 +// protoc v7.35.0 // source: proto/activity/v1/activity.proto package activityv1 @@ -11415,6 +11415,274 @@ func (x *ListLuckyGiftDrawsResponse) GetTotal() int64 { 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 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" + "\x1aListLuckyGiftDrawsResponse\x12<\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" + "\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" + @@ -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" + "\x17AdminAchievementService\x12u\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" + "\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" + "\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 ( 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 } -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{ (*RequestMeta)(nil), // 0: hyapp.activity.v1.RequestMeta (*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 (*ListLuckyGiftDrawsRequest)(nil), // 139: hyapp.activity.v1.ListLuckyGiftDrawsRequest (*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{ 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, // 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 - 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 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 @@ -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 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 - 1, // 113: hyapp.activity.v1.ActivityService.PingActivity:input_type -> hyapp.activity.v1.PingActivityRequest - 3, // 114: hyapp.activity.v1.ActivityService.GetActivityStatus:input_type -> hyapp.activity.v1.GetActivityStatusRequest - 6, // 115: hyapp.activity.v1.MessageInboxService.ListMessageTabs:input_type -> hyapp.activity.v1.ListMessageTabsRequest - 9, // 116: hyapp.activity.v1.MessageInboxService.ListInboxMessages:input_type -> hyapp.activity.v1.ListInboxMessagesRequest - 11, // 117: hyapp.activity.v1.MessageInboxService.MarkInboxMessageRead:input_type -> hyapp.activity.v1.MarkInboxMessageReadRequest - 13, // 118: hyapp.activity.v1.MessageInboxService.MarkInboxSectionRead:input_type -> hyapp.activity.v1.MarkInboxSectionReadRequest - 15, // 119: hyapp.activity.v1.MessageInboxService.DeleteInboxMessage:input_type -> hyapp.activity.v1.DeleteInboxMessageRequest - 17, // 120: hyapp.activity.v1.MessageInboxService.CreateInboxMessage:input_type -> hyapp.activity.v1.CreateInboxMessageRequest - 19, // 121: hyapp.activity.v1.MessageInboxService.CreateFanoutJob:input_type -> hyapp.activity.v1.CreateFanoutJobRequest - 21, // 122: hyapp.activity.v1.ActivityCronService.ProcessMessageFanoutBatch:input_type -> hyapp.activity.v1.CronBatchRequest - 21, // 123: hyapp.activity.v1.ActivityCronService.ProcessLevelRewardBatch:input_type -> hyapp.activity.v1.CronBatchRequest - 21, // 124: hyapp.activity.v1.ActivityCronService.ProcessAchievementRewardBatch:input_type -> hyapp.activity.v1.CronBatchRequest - 25, // 125: hyapp.activity.v1.TaskService.ListUserTasks:input_type -> hyapp.activity.v1.ListUserTasksRequest - 27, // 126: hyapp.activity.v1.TaskService.ClaimTaskReward:input_type -> hyapp.activity.v1.ClaimTaskRewardRequest - 29, // 127: hyapp.activity.v1.TaskService.ConsumeTaskEvent:input_type -> hyapp.activity.v1.ConsumeTaskEventRequest - 94, // 128: hyapp.activity.v1.GrowthLevelService.GetMyLevelOverview:input_type -> hyapp.activity.v1.GetMyLevelOverviewRequest - 96, // 129: hyapp.activity.v1.GrowthLevelService.GetLevelTrack:input_type -> hyapp.activity.v1.GetLevelTrackRequest - 99, // 130: hyapp.activity.v1.GrowthLevelService.ListLevelRewards:input_type -> hyapp.activity.v1.ListLevelRewardsRequest - 101, // 131: hyapp.activity.v1.GrowthLevelService.ConsumeLevelEvent:input_type -> hyapp.activity.v1.ConsumeLevelEventRequest - 114, // 132: hyapp.activity.v1.AchievementService.ListAchievements:input_type -> hyapp.activity.v1.ListAchievementsRequest - 116, // 133: hyapp.activity.v1.AchievementService.ConsumeAchievementEvent:input_type -> hyapp.activity.v1.ConsumeAchievementEventRequest - 119, // 134: hyapp.activity.v1.AchievementService.ListMyBadges:input_type -> hyapp.activity.v1.ListMyBadgesRequest - 121, // 135: hyapp.activity.v1.AchievementService.SetBadgeDisplay:input_type -> hyapp.activity.v1.SetBadgeDisplayRequest - 128, // 136: hyapp.activity.v1.LuckyGiftService.CheckLuckyGift:input_type -> hyapp.activity.v1.CheckLuckyGiftRequest - 131, // 137: hyapp.activity.v1.LuckyGiftService.ExecuteLuckyGiftDraw:input_type -> hyapp.activity.v1.ExecuteLuckyGiftDrawRequest - 32, // 138: hyapp.activity.v1.BroadcastService.EnsureBroadcastGroups:input_type -> hyapp.activity.v1.EnsureBroadcastGroupsRequest - 34, // 139: hyapp.activity.v1.BroadcastService.PublishRegionBroadcast:input_type -> hyapp.activity.v1.PublishRegionBroadcastRequest - 35, // 140: hyapp.activity.v1.BroadcastService.PublishGlobalBroadcast:input_type -> hyapp.activity.v1.PublishGlobalBroadcastRequest - 37, // 141: hyapp.activity.v1.BroadcastService.RemoveRegionBroadcastMember:input_type -> hyapp.activity.v1.RemoveRegionBroadcastMemberRequest - 21, // 142: hyapp.activity.v1.BroadcastService.ProcessBroadcastOutboxBatch:input_type -> hyapp.activity.v1.CronBatchRequest - 40, // 143: hyapp.activity.v1.RoomEventConsumerService.ConsumeRoomEvent:input_type -> hyapp.activity.v1.ConsumeRoomEventRequest - 43, // 144: hyapp.activity.v1.AdminTaskService.ListTaskDefinitions:input_type -> hyapp.activity.v1.ListTaskDefinitionsRequest - 45, // 145: hyapp.activity.v1.AdminTaskService.UpsertTaskDefinition:input_type -> hyapp.activity.v1.UpsertTaskDefinitionRequest - 47, // 146: hyapp.activity.v1.AdminTaskService.SetTaskDefinitionStatus:input_type -> hyapp.activity.v1.SetTaskDefinitionStatusRequest - 52, // 147: hyapp.activity.v1.RegistrationRewardService.GetRegistrationRewardEligibility:input_type -> hyapp.activity.v1.GetRegistrationRewardEligibilityRequest - 54, // 148: hyapp.activity.v1.RegistrationRewardService.IssueRegistrationReward:input_type -> hyapp.activity.v1.IssueRegistrationRewardRequest - 56, // 149: hyapp.activity.v1.AdminRegistrationRewardService.GetRegistrationRewardConfig:input_type -> hyapp.activity.v1.GetRegistrationRewardConfigRequest - 58, // 150: hyapp.activity.v1.AdminRegistrationRewardService.UpdateRegistrationRewardConfig:input_type -> hyapp.activity.v1.UpdateRegistrationRewardConfigRequest - 60, // 151: hyapp.activity.v1.AdminRegistrationRewardService.ListRegistrationRewardClaims:input_type -> hyapp.activity.v1.ListRegistrationRewardClaimsRequest - 66, // 152: hyapp.activity.v1.FirstRechargeRewardService.GetFirstRechargeRewardStatus:input_type -> hyapp.activity.v1.GetFirstRechargeRewardStatusRequest - 68, // 153: hyapp.activity.v1.FirstRechargeRewardService.ConsumeFirstRechargeReward:input_type -> hyapp.activity.v1.ConsumeFirstRechargeRewardRequest - 70, // 154: hyapp.activity.v1.AdminFirstRechargeRewardService.GetFirstRechargeRewardConfig:input_type -> hyapp.activity.v1.GetFirstRechargeRewardConfigRequest - 72, // 155: hyapp.activity.v1.AdminFirstRechargeRewardService.UpdateFirstRechargeRewardConfig:input_type -> hyapp.activity.v1.UpdateFirstRechargeRewardConfigRequest - 74, // 156: hyapp.activity.v1.AdminFirstRechargeRewardService.ListFirstRechargeRewardClaims:input_type -> hyapp.activity.v1.ListFirstRechargeRewardClaimsRequest - 79, // 157: hyapp.activity.v1.SevenDayCheckInService.GetSevenDayCheckInStatus:input_type -> hyapp.activity.v1.GetSevenDayCheckInStatusRequest - 81, // 158: hyapp.activity.v1.SevenDayCheckInService.SignSevenDayCheckIn:input_type -> hyapp.activity.v1.SignSevenDayCheckInRequest - 83, // 159: hyapp.activity.v1.AdminSevenDayCheckInService.GetSevenDayCheckInConfig:input_type -> hyapp.activity.v1.GetSevenDayCheckInConfigRequest - 85, // 160: hyapp.activity.v1.AdminSevenDayCheckInService.UpdateSevenDayCheckInConfig:input_type -> hyapp.activity.v1.UpdateSevenDayCheckInConfigRequest - 88, // 161: hyapp.activity.v1.AdminSevenDayCheckInService.ListSevenDayCheckInClaims:input_type -> hyapp.activity.v1.ListSevenDayCheckInClaimsRequest - 109, // 162: hyapp.activity.v1.AdminGrowthLevelService.ListLevelConfig:input_type -> hyapp.activity.v1.ListLevelConfigRequest - 103, // 163: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelTrack:input_type -> hyapp.activity.v1.UpsertLevelTrackRequest - 105, // 164: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelRule:input_type -> hyapp.activity.v1.UpsertLevelRuleRequest - 107, // 165: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelTier:input_type -> hyapp.activity.v1.UpsertLevelTierRequest - 114, // 166: hyapp.activity.v1.AdminAchievementService.ListAchievementDefinitions:input_type -> hyapp.activity.v1.ListAchievementsRequest - 123, // 167: hyapp.activity.v1.AdminAchievementService.UpsertAchievementDefinition:input_type -> hyapp.activity.v1.UpsertAchievementDefinitionRequest - 133, // 168: hyapp.activity.v1.AdminLuckyGiftService.GetLuckyGiftConfig:input_type -> hyapp.activity.v1.GetLuckyGiftConfigRequest - 135, // 169: hyapp.activity.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:input_type -> hyapp.activity.v1.UpsertLuckyGiftConfigRequest - 137, // 170: hyapp.activity.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:input_type -> hyapp.activity.v1.ListLuckyGiftConfigsRequest - 139, // 171: hyapp.activity.v1.AdminLuckyGiftService.ListLuckyGiftDraws:input_type -> hyapp.activity.v1.ListLuckyGiftDrawsRequest - 2, // 172: hyapp.activity.v1.ActivityService.PingActivity:output_type -> hyapp.activity.v1.PingActivityResponse - 4, // 173: hyapp.activity.v1.ActivityService.GetActivityStatus:output_type -> hyapp.activity.v1.GetActivityStatusResponse - 7, // 174: hyapp.activity.v1.MessageInboxService.ListMessageTabs:output_type -> hyapp.activity.v1.ListMessageTabsResponse - 10, // 175: hyapp.activity.v1.MessageInboxService.ListInboxMessages:output_type -> hyapp.activity.v1.ListInboxMessagesResponse - 12, // 176: hyapp.activity.v1.MessageInboxService.MarkInboxMessageRead:output_type -> hyapp.activity.v1.MarkInboxMessageReadResponse - 14, // 177: hyapp.activity.v1.MessageInboxService.MarkInboxSectionRead:output_type -> hyapp.activity.v1.MarkInboxSectionReadResponse - 16, // 178: hyapp.activity.v1.MessageInboxService.DeleteInboxMessage:output_type -> hyapp.activity.v1.DeleteInboxMessageResponse - 18, // 179: hyapp.activity.v1.MessageInboxService.CreateInboxMessage:output_type -> hyapp.activity.v1.CreateInboxMessageResponse - 20, // 180: hyapp.activity.v1.MessageInboxService.CreateFanoutJob:output_type -> hyapp.activity.v1.CreateFanoutJobResponse - 22, // 181: hyapp.activity.v1.ActivityCronService.ProcessMessageFanoutBatch:output_type -> hyapp.activity.v1.CronBatchResponse - 22, // 182: hyapp.activity.v1.ActivityCronService.ProcessLevelRewardBatch:output_type -> hyapp.activity.v1.CronBatchResponse - 22, // 183: hyapp.activity.v1.ActivityCronService.ProcessAchievementRewardBatch:output_type -> hyapp.activity.v1.CronBatchResponse - 26, // 184: hyapp.activity.v1.TaskService.ListUserTasks:output_type -> hyapp.activity.v1.ListUserTasksResponse - 28, // 185: hyapp.activity.v1.TaskService.ClaimTaskReward:output_type -> hyapp.activity.v1.ClaimTaskRewardResponse - 30, // 186: hyapp.activity.v1.TaskService.ConsumeTaskEvent:output_type -> hyapp.activity.v1.ConsumeTaskEventResponse - 95, // 187: hyapp.activity.v1.GrowthLevelService.GetMyLevelOverview:output_type -> hyapp.activity.v1.GetMyLevelOverviewResponse - 97, // 188: hyapp.activity.v1.GrowthLevelService.GetLevelTrack:output_type -> hyapp.activity.v1.GetLevelTrackResponse - 100, // 189: hyapp.activity.v1.GrowthLevelService.ListLevelRewards:output_type -> hyapp.activity.v1.ListLevelRewardsResponse - 102, // 190: hyapp.activity.v1.GrowthLevelService.ConsumeLevelEvent:output_type -> hyapp.activity.v1.ConsumeLevelEventResponse - 115, // 191: hyapp.activity.v1.AchievementService.ListAchievements:output_type -> hyapp.activity.v1.ListAchievementsResponse - 117, // 192: hyapp.activity.v1.AchievementService.ConsumeAchievementEvent:output_type -> hyapp.activity.v1.ConsumeAchievementEventResponse - 120, // 193: hyapp.activity.v1.AchievementService.ListMyBadges:output_type -> hyapp.activity.v1.ListMyBadgesResponse - 122, // 194: hyapp.activity.v1.AchievementService.SetBadgeDisplay:output_type -> hyapp.activity.v1.SetBadgeDisplayResponse - 129, // 195: hyapp.activity.v1.LuckyGiftService.CheckLuckyGift:output_type -> hyapp.activity.v1.CheckLuckyGiftResponse - 132, // 196: hyapp.activity.v1.LuckyGiftService.ExecuteLuckyGiftDraw:output_type -> hyapp.activity.v1.ExecuteLuckyGiftDrawResponse - 33, // 197: hyapp.activity.v1.BroadcastService.EnsureBroadcastGroups:output_type -> hyapp.activity.v1.EnsureBroadcastGroupsResponse - 36, // 198: hyapp.activity.v1.BroadcastService.PublishRegionBroadcast:output_type -> hyapp.activity.v1.PublishBroadcastResponse - 36, // 199: hyapp.activity.v1.BroadcastService.PublishGlobalBroadcast:output_type -> hyapp.activity.v1.PublishBroadcastResponse - 38, // 200: hyapp.activity.v1.BroadcastService.RemoveRegionBroadcastMember:output_type -> hyapp.activity.v1.RemoveRegionBroadcastMemberResponse - 39, // 201: hyapp.activity.v1.BroadcastService.ProcessBroadcastOutboxBatch:output_type -> hyapp.activity.v1.ProcessBroadcastOutboxBatchResponse - 41, // 202: hyapp.activity.v1.RoomEventConsumerService.ConsumeRoomEvent:output_type -> hyapp.activity.v1.ConsumeRoomEventResponse - 44, // 203: hyapp.activity.v1.AdminTaskService.ListTaskDefinitions:output_type -> hyapp.activity.v1.ListTaskDefinitionsResponse - 46, // 204: hyapp.activity.v1.AdminTaskService.UpsertTaskDefinition:output_type -> hyapp.activity.v1.UpsertTaskDefinitionResponse - 48, // 205: hyapp.activity.v1.AdminTaskService.SetTaskDefinitionStatus:output_type -> hyapp.activity.v1.SetTaskDefinitionStatusResponse - 53, // 206: hyapp.activity.v1.RegistrationRewardService.GetRegistrationRewardEligibility:output_type -> hyapp.activity.v1.GetRegistrationRewardEligibilityResponse - 55, // 207: hyapp.activity.v1.RegistrationRewardService.IssueRegistrationReward:output_type -> hyapp.activity.v1.IssueRegistrationRewardResponse - 57, // 208: hyapp.activity.v1.AdminRegistrationRewardService.GetRegistrationRewardConfig:output_type -> hyapp.activity.v1.GetRegistrationRewardConfigResponse - 59, // 209: hyapp.activity.v1.AdminRegistrationRewardService.UpdateRegistrationRewardConfig:output_type -> hyapp.activity.v1.UpdateRegistrationRewardConfigResponse - 61, // 210: hyapp.activity.v1.AdminRegistrationRewardService.ListRegistrationRewardClaims:output_type -> hyapp.activity.v1.ListRegistrationRewardClaimsResponse - 67, // 211: hyapp.activity.v1.FirstRechargeRewardService.GetFirstRechargeRewardStatus:output_type -> hyapp.activity.v1.GetFirstRechargeRewardStatusResponse - 69, // 212: hyapp.activity.v1.FirstRechargeRewardService.ConsumeFirstRechargeReward:output_type -> hyapp.activity.v1.ConsumeFirstRechargeRewardResponse - 71, // 213: hyapp.activity.v1.AdminFirstRechargeRewardService.GetFirstRechargeRewardConfig:output_type -> hyapp.activity.v1.GetFirstRechargeRewardConfigResponse - 73, // 214: hyapp.activity.v1.AdminFirstRechargeRewardService.UpdateFirstRechargeRewardConfig:output_type -> hyapp.activity.v1.UpdateFirstRechargeRewardConfigResponse - 75, // 215: hyapp.activity.v1.AdminFirstRechargeRewardService.ListFirstRechargeRewardClaims:output_type -> hyapp.activity.v1.ListFirstRechargeRewardClaimsResponse - 80, // 216: hyapp.activity.v1.SevenDayCheckInService.GetSevenDayCheckInStatus:output_type -> hyapp.activity.v1.GetSevenDayCheckInStatusResponse - 82, // 217: hyapp.activity.v1.SevenDayCheckInService.SignSevenDayCheckIn:output_type -> hyapp.activity.v1.SignSevenDayCheckInResponse - 84, // 218: hyapp.activity.v1.AdminSevenDayCheckInService.GetSevenDayCheckInConfig:output_type -> hyapp.activity.v1.GetSevenDayCheckInConfigResponse - 86, // 219: hyapp.activity.v1.AdminSevenDayCheckInService.UpdateSevenDayCheckInConfig:output_type -> hyapp.activity.v1.UpdateSevenDayCheckInConfigResponse - 89, // 220: hyapp.activity.v1.AdminSevenDayCheckInService.ListSevenDayCheckInClaims:output_type -> hyapp.activity.v1.ListSevenDayCheckInClaimsResponse - 110, // 221: hyapp.activity.v1.AdminGrowthLevelService.ListLevelConfig:output_type -> hyapp.activity.v1.ListLevelConfigResponse - 104, // 222: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelTrack:output_type -> hyapp.activity.v1.UpsertLevelTrackResponse - 106, // 223: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelRule:output_type -> hyapp.activity.v1.UpsertLevelRuleResponse - 108, // 224: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelTier:output_type -> hyapp.activity.v1.UpsertLevelTierResponse - 115, // 225: hyapp.activity.v1.AdminAchievementService.ListAchievementDefinitions:output_type -> hyapp.activity.v1.ListAchievementsResponse - 124, // 226: hyapp.activity.v1.AdminAchievementService.UpsertAchievementDefinition:output_type -> hyapp.activity.v1.UpsertAchievementDefinitionResponse - 134, // 227: hyapp.activity.v1.AdminLuckyGiftService.GetLuckyGiftConfig:output_type -> hyapp.activity.v1.GetLuckyGiftConfigResponse - 136, // 228: hyapp.activity.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:output_type -> hyapp.activity.v1.UpsertLuckyGiftConfigResponse - 138, // 229: hyapp.activity.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:output_type -> hyapp.activity.v1.ListLuckyGiftConfigsResponse - 140, // 230: hyapp.activity.v1.AdminLuckyGiftService.ListLuckyGiftDraws:output_type -> hyapp.activity.v1.ListLuckyGiftDrawsResponse - 172, // [172:231] is the sub-list for method output_type - 113, // [113:172] is the sub-list for method input_type - 113, // [113:113] is the sub-list for extension type_name - 113, // [113:113] is the sub-list for extension extendee - 0, // [0:113] is the sub-list for field type_name + 0, // 113: hyapp.activity.v1.GetLuckyGiftDrawSummaryRequest.meta:type_name -> hyapp.activity.v1.RequestMeta + 141, // 114: hyapp.activity.v1.GetLuckyGiftDrawSummaryResponse.summary:type_name -> hyapp.activity.v1.LuckyGiftDrawSummary + 1, // 115: hyapp.activity.v1.ActivityService.PingActivity:input_type -> hyapp.activity.v1.PingActivityRequest + 3, // 116: hyapp.activity.v1.ActivityService.GetActivityStatus:input_type -> hyapp.activity.v1.GetActivityStatusRequest + 6, // 117: hyapp.activity.v1.MessageInboxService.ListMessageTabs:input_type -> hyapp.activity.v1.ListMessageTabsRequest + 9, // 118: hyapp.activity.v1.MessageInboxService.ListInboxMessages:input_type -> hyapp.activity.v1.ListInboxMessagesRequest + 11, // 119: hyapp.activity.v1.MessageInboxService.MarkInboxMessageRead:input_type -> hyapp.activity.v1.MarkInboxMessageReadRequest + 13, // 120: hyapp.activity.v1.MessageInboxService.MarkInboxSectionRead:input_type -> hyapp.activity.v1.MarkInboxSectionReadRequest + 15, // 121: hyapp.activity.v1.MessageInboxService.DeleteInboxMessage:input_type -> hyapp.activity.v1.DeleteInboxMessageRequest + 17, // 122: hyapp.activity.v1.MessageInboxService.CreateInboxMessage:input_type -> hyapp.activity.v1.CreateInboxMessageRequest + 19, // 123: hyapp.activity.v1.MessageInboxService.CreateFanoutJob:input_type -> hyapp.activity.v1.CreateFanoutJobRequest + 21, // 124: hyapp.activity.v1.ActivityCronService.ProcessMessageFanoutBatch:input_type -> hyapp.activity.v1.CronBatchRequest + 21, // 125: hyapp.activity.v1.ActivityCronService.ProcessLevelRewardBatch:input_type -> hyapp.activity.v1.CronBatchRequest + 21, // 126: hyapp.activity.v1.ActivityCronService.ProcessAchievementRewardBatch:input_type -> hyapp.activity.v1.CronBatchRequest + 25, // 127: hyapp.activity.v1.TaskService.ListUserTasks:input_type -> hyapp.activity.v1.ListUserTasksRequest + 27, // 128: hyapp.activity.v1.TaskService.ClaimTaskReward:input_type -> hyapp.activity.v1.ClaimTaskRewardRequest + 29, // 129: hyapp.activity.v1.TaskService.ConsumeTaskEvent:input_type -> hyapp.activity.v1.ConsumeTaskEventRequest + 94, // 130: hyapp.activity.v1.GrowthLevelService.GetMyLevelOverview:input_type -> hyapp.activity.v1.GetMyLevelOverviewRequest + 96, // 131: hyapp.activity.v1.GrowthLevelService.GetLevelTrack:input_type -> hyapp.activity.v1.GetLevelTrackRequest + 99, // 132: hyapp.activity.v1.GrowthLevelService.ListLevelRewards:input_type -> hyapp.activity.v1.ListLevelRewardsRequest + 101, // 133: hyapp.activity.v1.GrowthLevelService.ConsumeLevelEvent:input_type -> hyapp.activity.v1.ConsumeLevelEventRequest + 114, // 134: hyapp.activity.v1.AchievementService.ListAchievements:input_type -> hyapp.activity.v1.ListAchievementsRequest + 116, // 135: hyapp.activity.v1.AchievementService.ConsumeAchievementEvent:input_type -> hyapp.activity.v1.ConsumeAchievementEventRequest + 119, // 136: hyapp.activity.v1.AchievementService.ListMyBadges:input_type -> hyapp.activity.v1.ListMyBadgesRequest + 121, // 137: hyapp.activity.v1.AchievementService.SetBadgeDisplay:input_type -> hyapp.activity.v1.SetBadgeDisplayRequest + 128, // 138: hyapp.activity.v1.LuckyGiftService.CheckLuckyGift:input_type -> hyapp.activity.v1.CheckLuckyGiftRequest + 131, // 139: hyapp.activity.v1.LuckyGiftService.ExecuteLuckyGiftDraw:input_type -> hyapp.activity.v1.ExecuteLuckyGiftDrawRequest + 32, // 140: hyapp.activity.v1.BroadcastService.EnsureBroadcastGroups:input_type -> hyapp.activity.v1.EnsureBroadcastGroupsRequest + 34, // 141: hyapp.activity.v1.BroadcastService.PublishRegionBroadcast:input_type -> hyapp.activity.v1.PublishRegionBroadcastRequest + 35, // 142: hyapp.activity.v1.BroadcastService.PublishGlobalBroadcast:input_type -> hyapp.activity.v1.PublishGlobalBroadcastRequest + 37, // 143: hyapp.activity.v1.BroadcastService.RemoveRegionBroadcastMember:input_type -> hyapp.activity.v1.RemoveRegionBroadcastMemberRequest + 21, // 144: hyapp.activity.v1.BroadcastService.ProcessBroadcastOutboxBatch:input_type -> hyapp.activity.v1.CronBatchRequest + 40, // 145: hyapp.activity.v1.RoomEventConsumerService.ConsumeRoomEvent:input_type -> hyapp.activity.v1.ConsumeRoomEventRequest + 43, // 146: hyapp.activity.v1.AdminTaskService.ListTaskDefinitions:input_type -> hyapp.activity.v1.ListTaskDefinitionsRequest + 45, // 147: hyapp.activity.v1.AdminTaskService.UpsertTaskDefinition:input_type -> hyapp.activity.v1.UpsertTaskDefinitionRequest + 47, // 148: hyapp.activity.v1.AdminTaskService.SetTaskDefinitionStatus:input_type -> hyapp.activity.v1.SetTaskDefinitionStatusRequest + 52, // 149: hyapp.activity.v1.RegistrationRewardService.GetRegistrationRewardEligibility:input_type -> hyapp.activity.v1.GetRegistrationRewardEligibilityRequest + 54, // 150: hyapp.activity.v1.RegistrationRewardService.IssueRegistrationReward:input_type -> hyapp.activity.v1.IssueRegistrationRewardRequest + 56, // 151: hyapp.activity.v1.AdminRegistrationRewardService.GetRegistrationRewardConfig:input_type -> hyapp.activity.v1.GetRegistrationRewardConfigRequest + 58, // 152: hyapp.activity.v1.AdminRegistrationRewardService.UpdateRegistrationRewardConfig:input_type -> hyapp.activity.v1.UpdateRegistrationRewardConfigRequest + 60, // 153: hyapp.activity.v1.AdminRegistrationRewardService.ListRegistrationRewardClaims:input_type -> hyapp.activity.v1.ListRegistrationRewardClaimsRequest + 66, // 154: hyapp.activity.v1.FirstRechargeRewardService.GetFirstRechargeRewardStatus:input_type -> hyapp.activity.v1.GetFirstRechargeRewardStatusRequest + 68, // 155: hyapp.activity.v1.FirstRechargeRewardService.ConsumeFirstRechargeReward:input_type -> hyapp.activity.v1.ConsumeFirstRechargeRewardRequest + 70, // 156: hyapp.activity.v1.AdminFirstRechargeRewardService.GetFirstRechargeRewardConfig:input_type -> hyapp.activity.v1.GetFirstRechargeRewardConfigRequest + 72, // 157: hyapp.activity.v1.AdminFirstRechargeRewardService.UpdateFirstRechargeRewardConfig:input_type -> hyapp.activity.v1.UpdateFirstRechargeRewardConfigRequest + 74, // 158: hyapp.activity.v1.AdminFirstRechargeRewardService.ListFirstRechargeRewardClaims:input_type -> hyapp.activity.v1.ListFirstRechargeRewardClaimsRequest + 79, // 159: hyapp.activity.v1.SevenDayCheckInService.GetSevenDayCheckInStatus:input_type -> hyapp.activity.v1.GetSevenDayCheckInStatusRequest + 81, // 160: hyapp.activity.v1.SevenDayCheckInService.SignSevenDayCheckIn:input_type -> hyapp.activity.v1.SignSevenDayCheckInRequest + 83, // 161: hyapp.activity.v1.AdminSevenDayCheckInService.GetSevenDayCheckInConfig:input_type -> hyapp.activity.v1.GetSevenDayCheckInConfigRequest + 85, // 162: hyapp.activity.v1.AdminSevenDayCheckInService.UpdateSevenDayCheckInConfig:input_type -> hyapp.activity.v1.UpdateSevenDayCheckInConfigRequest + 88, // 163: hyapp.activity.v1.AdminSevenDayCheckInService.ListSevenDayCheckInClaims:input_type -> hyapp.activity.v1.ListSevenDayCheckInClaimsRequest + 109, // 164: hyapp.activity.v1.AdminGrowthLevelService.ListLevelConfig:input_type -> hyapp.activity.v1.ListLevelConfigRequest + 103, // 165: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelTrack:input_type -> hyapp.activity.v1.UpsertLevelTrackRequest + 105, // 166: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelRule:input_type -> hyapp.activity.v1.UpsertLevelRuleRequest + 107, // 167: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelTier:input_type -> hyapp.activity.v1.UpsertLevelTierRequest + 114, // 168: hyapp.activity.v1.AdminAchievementService.ListAchievementDefinitions:input_type -> hyapp.activity.v1.ListAchievementsRequest + 123, // 169: hyapp.activity.v1.AdminAchievementService.UpsertAchievementDefinition:input_type -> hyapp.activity.v1.UpsertAchievementDefinitionRequest + 133, // 170: hyapp.activity.v1.AdminLuckyGiftService.GetLuckyGiftConfig:input_type -> hyapp.activity.v1.GetLuckyGiftConfigRequest + 135, // 171: hyapp.activity.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:input_type -> hyapp.activity.v1.UpsertLuckyGiftConfigRequest + 137, // 172: hyapp.activity.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:input_type -> hyapp.activity.v1.ListLuckyGiftConfigsRequest + 139, // 173: hyapp.activity.v1.AdminLuckyGiftService.ListLuckyGiftDraws:input_type -> hyapp.activity.v1.ListLuckyGiftDrawsRequest + 142, // 174: hyapp.activity.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:input_type -> hyapp.activity.v1.GetLuckyGiftDrawSummaryRequest + 2, // 175: hyapp.activity.v1.ActivityService.PingActivity:output_type -> hyapp.activity.v1.PingActivityResponse + 4, // 176: hyapp.activity.v1.ActivityService.GetActivityStatus:output_type -> hyapp.activity.v1.GetActivityStatusResponse + 7, // 177: hyapp.activity.v1.MessageInboxService.ListMessageTabs:output_type -> hyapp.activity.v1.ListMessageTabsResponse + 10, // 178: hyapp.activity.v1.MessageInboxService.ListInboxMessages:output_type -> hyapp.activity.v1.ListInboxMessagesResponse + 12, // 179: hyapp.activity.v1.MessageInboxService.MarkInboxMessageRead:output_type -> hyapp.activity.v1.MarkInboxMessageReadResponse + 14, // 180: hyapp.activity.v1.MessageInboxService.MarkInboxSectionRead:output_type -> hyapp.activity.v1.MarkInboxSectionReadResponse + 16, // 181: hyapp.activity.v1.MessageInboxService.DeleteInboxMessage:output_type -> hyapp.activity.v1.DeleteInboxMessageResponse + 18, // 182: hyapp.activity.v1.MessageInboxService.CreateInboxMessage:output_type -> hyapp.activity.v1.CreateInboxMessageResponse + 20, // 183: hyapp.activity.v1.MessageInboxService.CreateFanoutJob:output_type -> hyapp.activity.v1.CreateFanoutJobResponse + 22, // 184: hyapp.activity.v1.ActivityCronService.ProcessMessageFanoutBatch:output_type -> hyapp.activity.v1.CronBatchResponse + 22, // 185: hyapp.activity.v1.ActivityCronService.ProcessLevelRewardBatch:output_type -> hyapp.activity.v1.CronBatchResponse + 22, // 186: hyapp.activity.v1.ActivityCronService.ProcessAchievementRewardBatch:output_type -> hyapp.activity.v1.CronBatchResponse + 26, // 187: hyapp.activity.v1.TaskService.ListUserTasks:output_type -> hyapp.activity.v1.ListUserTasksResponse + 28, // 188: hyapp.activity.v1.TaskService.ClaimTaskReward:output_type -> hyapp.activity.v1.ClaimTaskRewardResponse + 30, // 189: hyapp.activity.v1.TaskService.ConsumeTaskEvent:output_type -> hyapp.activity.v1.ConsumeTaskEventResponse + 95, // 190: hyapp.activity.v1.GrowthLevelService.GetMyLevelOverview:output_type -> hyapp.activity.v1.GetMyLevelOverviewResponse + 97, // 191: hyapp.activity.v1.GrowthLevelService.GetLevelTrack:output_type -> hyapp.activity.v1.GetLevelTrackResponse + 100, // 192: hyapp.activity.v1.GrowthLevelService.ListLevelRewards:output_type -> hyapp.activity.v1.ListLevelRewardsResponse + 102, // 193: hyapp.activity.v1.GrowthLevelService.ConsumeLevelEvent:output_type -> hyapp.activity.v1.ConsumeLevelEventResponse + 115, // 194: hyapp.activity.v1.AchievementService.ListAchievements:output_type -> hyapp.activity.v1.ListAchievementsResponse + 117, // 195: hyapp.activity.v1.AchievementService.ConsumeAchievementEvent:output_type -> hyapp.activity.v1.ConsumeAchievementEventResponse + 120, // 196: hyapp.activity.v1.AchievementService.ListMyBadges:output_type -> hyapp.activity.v1.ListMyBadgesResponse + 122, // 197: hyapp.activity.v1.AchievementService.SetBadgeDisplay:output_type -> hyapp.activity.v1.SetBadgeDisplayResponse + 129, // 198: hyapp.activity.v1.LuckyGiftService.CheckLuckyGift:output_type -> hyapp.activity.v1.CheckLuckyGiftResponse + 132, // 199: hyapp.activity.v1.LuckyGiftService.ExecuteLuckyGiftDraw:output_type -> hyapp.activity.v1.ExecuteLuckyGiftDrawResponse + 33, // 200: hyapp.activity.v1.BroadcastService.EnsureBroadcastGroups:output_type -> hyapp.activity.v1.EnsureBroadcastGroupsResponse + 36, // 201: hyapp.activity.v1.BroadcastService.PublishRegionBroadcast:output_type -> hyapp.activity.v1.PublishBroadcastResponse + 36, // 202: hyapp.activity.v1.BroadcastService.PublishGlobalBroadcast:output_type -> hyapp.activity.v1.PublishBroadcastResponse + 38, // 203: hyapp.activity.v1.BroadcastService.RemoveRegionBroadcastMember:output_type -> hyapp.activity.v1.RemoveRegionBroadcastMemberResponse + 39, // 204: hyapp.activity.v1.BroadcastService.ProcessBroadcastOutboxBatch:output_type -> hyapp.activity.v1.ProcessBroadcastOutboxBatchResponse + 41, // 205: hyapp.activity.v1.RoomEventConsumerService.ConsumeRoomEvent:output_type -> hyapp.activity.v1.ConsumeRoomEventResponse + 44, // 206: hyapp.activity.v1.AdminTaskService.ListTaskDefinitions:output_type -> hyapp.activity.v1.ListTaskDefinitionsResponse + 46, // 207: hyapp.activity.v1.AdminTaskService.UpsertTaskDefinition:output_type -> hyapp.activity.v1.UpsertTaskDefinitionResponse + 48, // 208: hyapp.activity.v1.AdminTaskService.SetTaskDefinitionStatus:output_type -> hyapp.activity.v1.SetTaskDefinitionStatusResponse + 53, // 209: hyapp.activity.v1.RegistrationRewardService.GetRegistrationRewardEligibility:output_type -> hyapp.activity.v1.GetRegistrationRewardEligibilityResponse + 55, // 210: hyapp.activity.v1.RegistrationRewardService.IssueRegistrationReward:output_type -> hyapp.activity.v1.IssueRegistrationRewardResponse + 57, // 211: hyapp.activity.v1.AdminRegistrationRewardService.GetRegistrationRewardConfig:output_type -> hyapp.activity.v1.GetRegistrationRewardConfigResponse + 59, // 212: hyapp.activity.v1.AdminRegistrationRewardService.UpdateRegistrationRewardConfig:output_type -> hyapp.activity.v1.UpdateRegistrationRewardConfigResponse + 61, // 213: hyapp.activity.v1.AdminRegistrationRewardService.ListRegistrationRewardClaims:output_type -> hyapp.activity.v1.ListRegistrationRewardClaimsResponse + 67, // 214: hyapp.activity.v1.FirstRechargeRewardService.GetFirstRechargeRewardStatus:output_type -> hyapp.activity.v1.GetFirstRechargeRewardStatusResponse + 69, // 215: hyapp.activity.v1.FirstRechargeRewardService.ConsumeFirstRechargeReward:output_type -> hyapp.activity.v1.ConsumeFirstRechargeRewardResponse + 71, // 216: hyapp.activity.v1.AdminFirstRechargeRewardService.GetFirstRechargeRewardConfig:output_type -> hyapp.activity.v1.GetFirstRechargeRewardConfigResponse + 73, // 217: hyapp.activity.v1.AdminFirstRechargeRewardService.UpdateFirstRechargeRewardConfig:output_type -> hyapp.activity.v1.UpdateFirstRechargeRewardConfigResponse + 75, // 218: hyapp.activity.v1.AdminFirstRechargeRewardService.ListFirstRechargeRewardClaims:output_type -> hyapp.activity.v1.ListFirstRechargeRewardClaimsResponse + 80, // 219: hyapp.activity.v1.SevenDayCheckInService.GetSevenDayCheckInStatus:output_type -> hyapp.activity.v1.GetSevenDayCheckInStatusResponse + 82, // 220: hyapp.activity.v1.SevenDayCheckInService.SignSevenDayCheckIn:output_type -> hyapp.activity.v1.SignSevenDayCheckInResponse + 84, // 221: hyapp.activity.v1.AdminSevenDayCheckInService.GetSevenDayCheckInConfig:output_type -> hyapp.activity.v1.GetSevenDayCheckInConfigResponse + 86, // 222: hyapp.activity.v1.AdminSevenDayCheckInService.UpdateSevenDayCheckInConfig:output_type -> hyapp.activity.v1.UpdateSevenDayCheckInConfigResponse + 89, // 223: hyapp.activity.v1.AdminSevenDayCheckInService.ListSevenDayCheckInClaims:output_type -> hyapp.activity.v1.ListSevenDayCheckInClaimsResponse + 110, // 224: hyapp.activity.v1.AdminGrowthLevelService.ListLevelConfig:output_type -> hyapp.activity.v1.ListLevelConfigResponse + 104, // 225: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelTrack:output_type -> hyapp.activity.v1.UpsertLevelTrackResponse + 106, // 226: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelRule:output_type -> hyapp.activity.v1.UpsertLevelRuleResponse + 108, // 227: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelTier:output_type -> hyapp.activity.v1.UpsertLevelTierResponse + 115, // 228: hyapp.activity.v1.AdminAchievementService.ListAchievementDefinitions:output_type -> hyapp.activity.v1.ListAchievementsResponse + 124, // 229: hyapp.activity.v1.AdminAchievementService.UpsertAchievementDefinition:output_type -> hyapp.activity.v1.UpsertAchievementDefinitionResponse + 134, // 230: hyapp.activity.v1.AdminLuckyGiftService.GetLuckyGiftConfig:output_type -> hyapp.activity.v1.GetLuckyGiftConfigResponse + 136, // 231: hyapp.activity.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:output_type -> hyapp.activity.v1.UpsertLuckyGiftConfigResponse + 138, // 232: hyapp.activity.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:output_type -> hyapp.activity.v1.ListLuckyGiftConfigsResponse + 140, // 233: hyapp.activity.v1.AdminLuckyGiftService.ListLuckyGiftDraws:output_type -> hyapp.activity.v1.ListLuckyGiftDrawsResponse + 143, // 234: hyapp.activity.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:output_type -> hyapp.activity.v1.GetLuckyGiftDrawSummaryResponse + 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() } @@ -12948,7 +13249,7 @@ func file_proto_activity_v1_activity_proto_init() { 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)), NumEnums: 0, - NumMessages: 141, + NumMessages: 144, NumExtensions: 0, NumServices: 19, }, diff --git a/api/proto/activity/v1/activity.proto b/api/proto/activity/v1/activity.proto index 284dc8e5..11bd0c65 100644 --- a/api/proto/activity/v1/activity.proto +++ b/api/proto/activity/v1/activity.proto @@ -1272,6 +1272,35 @@ message ListLuckyGiftDrawsResponse { int64 total = 2; } +message LuckyGiftDrawSummary { + string pool_id = 1; + int64 total_draws = 2; + int64 unique_users = 3; + int64 unique_rooms = 4; + int64 total_spent_coins = 5; + int64 total_reward_coins = 6; + int64 base_reward_coins = 7; + int64 room_atmosphere_reward_coins = 8; + int64 activity_subsidy_coins = 9; + int64 actual_rtp_ppm = 10; + int64 pending_draws = 11; + int64 granted_draws = 12; + int64 failed_draws = 13; +} + +message GetLuckyGiftDrawSummaryRequest { + RequestMeta meta = 1; + string gift_id = 2; + int64 user_id = 3; + string room_id = 4; + string status = 5; + string pool_id = 6; +} + +message GetLuckyGiftDrawSummaryResponse { + LuckyGiftDrawSummary summary = 1; +} + // ActivityService 只暴露同步查询,不把事件消费伪装成 RPC。 service ActivityService { rpc PingActivity(PingActivityRequest) returns (PingActivityResponse); @@ -1405,4 +1434,5 @@ service AdminLuckyGiftService { rpc UpsertLuckyGiftConfig(UpsertLuckyGiftConfigRequest) returns (UpsertLuckyGiftConfigResponse); rpc ListLuckyGiftConfigs(ListLuckyGiftConfigsRequest) returns (ListLuckyGiftConfigsResponse); rpc ListLuckyGiftDraws(ListLuckyGiftDrawsRequest) returns (ListLuckyGiftDrawsResponse); + rpc GetLuckyGiftDrawSummary(GetLuckyGiftDrawSummaryRequest) returns (GetLuckyGiftDrawSummaryResponse); } diff --git a/api/proto/activity/v1/activity_grpc.pb.go b/api/proto/activity/v1/activity_grpc.pb.go index ed60e0b7..2d2f118f 100644 --- a/api/proto/activity/v1/activity_grpc.pb.go +++ b/api/proto/activity/v1/activity_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v5.26.1 +// - protoc v7.35.0 // source: proto/activity/v1/activity.proto package activityv1 @@ -3342,10 +3342,11 @@ var AdminAchievementService_ServiceDesc = grpc.ServiceDesc{ } const ( - AdminLuckyGiftService_GetLuckyGiftConfig_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/GetLuckyGiftConfig" - AdminLuckyGiftService_UpsertLuckyGiftConfig_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/UpsertLuckyGiftConfig" - AdminLuckyGiftService_ListLuckyGiftConfigs_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/ListLuckyGiftConfigs" - AdminLuckyGiftService_ListLuckyGiftDraws_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/ListLuckyGiftDraws" + AdminLuckyGiftService_GetLuckyGiftConfig_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/GetLuckyGiftConfig" + AdminLuckyGiftService_UpsertLuckyGiftConfig_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/UpsertLuckyGiftConfig" + AdminLuckyGiftService_ListLuckyGiftConfigs_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/ListLuckyGiftConfigs" + AdminLuckyGiftService_ListLuckyGiftDraws_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/ListLuckyGiftDraws" + AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/GetLuckyGiftDrawSummary" ) // AdminLuckyGiftServiceClient is the client API for AdminLuckyGiftService service. @@ -3358,6 +3359,7 @@ type AdminLuckyGiftServiceClient interface { UpsertLuckyGiftConfig(ctx context.Context, in *UpsertLuckyGiftConfigRequest, opts ...grpc.CallOption) (*UpsertLuckyGiftConfigResponse, error) ListLuckyGiftConfigs(ctx context.Context, in *ListLuckyGiftConfigsRequest, opts ...grpc.CallOption) (*ListLuckyGiftConfigsResponse, error) ListLuckyGiftDraws(ctx context.Context, in *ListLuckyGiftDrawsRequest, opts ...grpc.CallOption) (*ListLuckyGiftDrawsResponse, error) + GetLuckyGiftDrawSummary(ctx context.Context, in *GetLuckyGiftDrawSummaryRequest, opts ...grpc.CallOption) (*GetLuckyGiftDrawSummaryResponse, error) } type adminLuckyGiftServiceClient struct { @@ -3408,6 +3410,16 @@ func (c *adminLuckyGiftServiceClient) ListLuckyGiftDraws(ctx context.Context, in return out, nil } +func (c *adminLuckyGiftServiceClient) GetLuckyGiftDrawSummary(ctx context.Context, in *GetLuckyGiftDrawSummaryRequest, opts ...grpc.CallOption) (*GetLuckyGiftDrawSummaryResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetLuckyGiftDrawSummaryResponse) + err := c.cc.Invoke(ctx, AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // AdminLuckyGiftServiceServer is the server API for AdminLuckyGiftService service. // All implementations must embed UnimplementedAdminLuckyGiftServiceServer // for forward compatibility. @@ -3418,6 +3430,7 @@ type AdminLuckyGiftServiceServer interface { UpsertLuckyGiftConfig(context.Context, *UpsertLuckyGiftConfigRequest) (*UpsertLuckyGiftConfigResponse, error) ListLuckyGiftConfigs(context.Context, *ListLuckyGiftConfigsRequest) (*ListLuckyGiftConfigsResponse, error) ListLuckyGiftDraws(context.Context, *ListLuckyGiftDrawsRequest) (*ListLuckyGiftDrawsResponse, error) + GetLuckyGiftDrawSummary(context.Context, *GetLuckyGiftDrawSummaryRequest) (*GetLuckyGiftDrawSummaryResponse, error) mustEmbedUnimplementedAdminLuckyGiftServiceServer() } @@ -3440,6 +3453,9 @@ func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftConfigs(context.Con func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftDraws(context.Context, *ListLuckyGiftDrawsRequest) (*ListLuckyGiftDrawsResponse, error) { return nil, status.Error(codes.Unimplemented, "method ListLuckyGiftDraws not implemented") } +func (UnimplementedAdminLuckyGiftServiceServer) GetLuckyGiftDrawSummary(context.Context, *GetLuckyGiftDrawSummaryRequest) (*GetLuckyGiftDrawSummaryResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetLuckyGiftDrawSummary not implemented") +} func (UnimplementedAdminLuckyGiftServiceServer) mustEmbedUnimplementedAdminLuckyGiftServiceServer() {} func (UnimplementedAdminLuckyGiftServiceServer) testEmbeddedByValue() {} @@ -3533,6 +3549,24 @@ func _AdminLuckyGiftService_ListLuckyGiftDraws_Handler(srv interface{}, ctx cont return interceptor(ctx, in, info, handler) } +func _AdminLuckyGiftService_GetLuckyGiftDrawSummary_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetLuckyGiftDrawSummaryRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AdminLuckyGiftServiceServer).GetLuckyGiftDrawSummary(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AdminLuckyGiftServiceServer).GetLuckyGiftDrawSummary(ctx, req.(*GetLuckyGiftDrawSummaryRequest)) + } + return interceptor(ctx, in, info, handler) +} + // AdminLuckyGiftService_ServiceDesc is the grpc.ServiceDesc for AdminLuckyGiftService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -3556,6 +3590,10 @@ var AdminLuckyGiftService_ServiceDesc = grpc.ServiceDesc{ MethodName: "ListLuckyGiftDraws", Handler: _AdminLuckyGiftService_ListLuckyGiftDraws_Handler, }, + { + MethodName: "GetLuckyGiftDrawSummary", + Handler: _AdminLuckyGiftService_GetLuckyGiftDrawSummary_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "proto/activity/v1/activity.proto", diff --git a/api/proto/events/room/v1/events.pb.go b/api/proto/events/room/v1/events.pb.go index 5276fc0b..64e15c77 100644 --- a/api/proto/events/room/v1/events.pb.go +++ b/api/proto/events/room/v1/events.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.26.1 +// protoc v7.35.0 // source: proto/events/room/v1/events.proto package roomeventsv1 diff --git a/api/proto/game/v1/game.pb.go b/api/proto/game/v1/game.pb.go index 22482aa1..94195959 100644 --- a/api/proto/game/v1/game.pb.go +++ b/api/proto/game/v1/game.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.26.1 +// protoc v7.35.0 // source: proto/game/v1/game.proto package gamev1 diff --git a/api/proto/game/v1/game_grpc.pb.go b/api/proto/game/v1/game_grpc.pb.go index 95ef7cdb..72e76e0c 100644 --- a/api/proto/game/v1/game_grpc.pb.go +++ b/api/proto/game/v1/game_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v5.26.1 +// - protoc v7.35.0 // source: proto/game/v1/game.proto package gamev1 diff --git a/api/proto/room/v1/room.pb.go b/api/proto/room/v1/room.pb.go index 85724011..fc723bc9 100644 --- a/api/proto/room/v1/room.pb.go +++ b/api/proto/room/v1/room.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.26.1 +// protoc v7.35.0 // source: proto/room/v1/room.proto package roomv1 diff --git a/api/proto/room/v1/room_grpc.pb.go b/api/proto/room/v1/room_grpc.pb.go index f0d9296a..e6f14feb 100644 --- a/api/proto/room/v1/room_grpc.pb.go +++ b/api/proto/room/v1/room_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v5.26.1 +// - protoc v7.35.0 // source: proto/room/v1/room.proto package roomv1 diff --git a/api/proto/user/v1/auth.pb.go b/api/proto/user/v1/auth.pb.go index 916e7da1..1408daef 100644 --- a/api/proto/user/v1/auth.pb.go +++ b/api/proto/user/v1/auth.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.26.1 +// protoc v7.35.0 // source: proto/user/v1/auth.proto package userv1 diff --git a/api/proto/user/v1/auth_grpc.pb.go b/api/proto/user/v1/auth_grpc.pb.go index 0a163cf6..aae9b30d 100644 --- a/api/proto/user/v1/auth_grpc.pb.go +++ b/api/proto/user/v1/auth_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v5.26.1 +// - protoc v7.35.0 // source: proto/user/v1/auth.proto package userv1 diff --git a/api/proto/user/v1/host.pb.go b/api/proto/user/v1/host.pb.go index 29f7c51a..4c9e0852 100644 --- a/api/proto/user/v1/host.pb.go +++ b/api/proto/user/v1/host.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.26.1 +// protoc v7.35.0 // source: proto/user/v1/host.proto package userv1 diff --git a/api/proto/user/v1/host_grpc.pb.go b/api/proto/user/v1/host_grpc.pb.go index 69fea088..a46483e0 100644 --- a/api/proto/user/v1/host_grpc.pb.go +++ b/api/proto/user/v1/host_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v5.26.1 +// - protoc v7.35.0 // source: proto/user/v1/host.proto package userv1 diff --git a/api/proto/user/v1/user.pb.go b/api/proto/user/v1/user.pb.go index 09b19f76..11a333c0 100644 --- a/api/proto/user/v1/user.pb.go +++ b/api/proto/user/v1/user.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.26.1 +// protoc v7.35.0 // source: proto/user/v1/user.proto package userv1 diff --git a/api/proto/user/v1/user_grpc.pb.go b/api/proto/user/v1/user_grpc.pb.go index 69db19a5..2d4602d4 100644 --- a/api/proto/user/v1/user_grpc.pb.go +++ b/api/proto/user/v1/user_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v5.26.1 +// - protoc v7.35.0 // source: proto/user/v1/user.proto package userv1 diff --git a/api/proto/wallet/v1/wallet.pb.go b/api/proto/wallet/v1/wallet.pb.go index 8c1e1280..01bfd65a 100644 --- a/api/proto/wallet/v1/wallet.pb.go +++ b/api/proto/wallet/v1/wallet.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.26.1 +// protoc v7.35.0 // source: proto/wallet/v1/wallet.proto package walletv1 diff --git a/api/proto/wallet/v1/wallet_grpc.pb.go b/api/proto/wallet/v1/wallet_grpc.pb.go index 88d38eed..e77e6eb1 100644 --- a/api/proto/wallet/v1/wallet_grpc.pb.go +++ b/api/proto/wallet/v1/wallet_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v5.26.1 +// - protoc v7.35.0 // source: proto/wallet/v1/wallet.proto package walletv1 diff --git a/docs/flutter对接/Google Play充值Flutter对接.md b/docs/flutter对接/Google Play充值Flutter对接.md new file mode 100644 index 00000000..ca7c3c78 --- /dev/null +++ b/docs/flutter对接/Google Play充值Flutter对接.md @@ -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: +``` + +Android 真机联调要求: + +- App 使用正式包名和签名,包名必须与 Play Console App 一致。 +- 测试账号加入 Play Console license testing 或内部测试轨道。 +- Play Console 中的一次性商品必须激活,并覆盖测试账号所在地区。 + +## 3. 获取充值商品列表 + +`GET /api/v1/wallet/recharge/products` + +登录接口,需要 `Authorization: Bearer `。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 +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 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> _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-", + "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 补单队列。 diff --git a/docs/统计服务技术方案.md b/docs/统计服务技术方案.md new file mode 100644 index 00000000..2f5b4fb9 --- /dev/null +++ b/docs/统计服务技术方案.md @@ -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=&end_ms=&country_id= +``` + +返回范围: + +- 总览:新增、活跃、付费、充值渠道、礼物、幸运礼物、游戏流水/返奖/退款/利润。 +- 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。 diff --git a/server/admin/internal/integration/activityclient/client.go b/server/admin/internal/integration/activityclient/client.go index 0b416fec..b1dfe2d7 100644 --- a/server/admin/internal/integration/activityclient/client.go +++ b/server/admin/internal/integration/activityclient/client.go @@ -28,6 +28,7 @@ type Client interface { UpsertLuckyGiftConfig(ctx context.Context, req *activityv1.UpsertLuckyGiftConfigRequest) (*activityv1.UpsertLuckyGiftConfigResponse, error) ListLuckyGiftConfigs(ctx context.Context, req *activityv1.ListLuckyGiftConfigsRequest) (*activityv1.ListLuckyGiftConfigsResponse, error) ListLuckyGiftDraws(ctx context.Context, req *activityv1.ListLuckyGiftDrawsRequest) (*activityv1.ListLuckyGiftDrawsResponse, error) + GetLuckyGiftDrawSummary(ctx context.Context, req *activityv1.GetLuckyGiftDrawSummaryRequest) (*activityv1.GetLuckyGiftDrawSummaryResponse, error) RemoveRegionBroadcastMember(ctx context.Context, req *activityv1.RemoveRegionBroadcastMemberRequest) (*activityv1.RemoveRegionBroadcastMemberResponse, error) ListLevelConfig(ctx context.Context, req *activityv1.ListLevelConfigRequest) (*activityv1.ListLevelConfigResponse, error) UpsertLevelTrack(ctx context.Context, req *activityv1.UpsertLevelTrackRequest) (*activityv1.UpsertLevelTrackResponse, error) @@ -131,6 +132,10 @@ func (c *GRPCClient) ListLuckyGiftDraws(ctx context.Context, req *activityv1.Lis return c.luckyGiftClient.ListLuckyGiftDraws(ctx, req) } +func (c *GRPCClient) GetLuckyGiftDrawSummary(ctx context.Context, req *activityv1.GetLuckyGiftDrawSummaryRequest) (*activityv1.GetLuckyGiftDrawSummaryResponse, error) { + return c.luckyGiftClient.GetLuckyGiftDrawSummary(ctx, req) +} + func (c *GRPCClient) RemoveRegionBroadcastMember(ctx context.Context, req *activityv1.RemoveRegionBroadcastMemberRequest) (*activityv1.RemoveRegionBroadcastMemberResponse, error) { return c.broadcastClient.RemoveRegionBroadcastMember(ctx, req) } diff --git a/server/admin/internal/model/models.go b/server/admin/internal/model/models.go index f3e331ce..d15fd2e5 100644 --- a/server/admin/internal/model/models.go +++ b/server/admin/internal/model/models.go @@ -132,6 +132,21 @@ func (AppVersion) TableName() string { return "admin_app_versions" } +type AppExploreTab struct { + ID uint `gorm:"primaryKey" json:"id"` + AppCode string `gorm:"size:32;uniqueIndex:uk_admin_app_explore_tabs_tab;index:idx_admin_app_explore_tabs_app_sort,not null;default:lalu" json:"appCode"` + Tab string `gorm:"size:80;uniqueIndex:uk_admin_app_explore_tabs_tab,not null" json:"tab"` + H5URL string `gorm:"column:h5_url;size:2048;not null" json:"h5Url"` + Enabled bool `gorm:"index:idx_admin_app_explore_tabs_app_sort,not null;default:true" json:"enabled"` + SortOrder int `gorm:"index:idx_admin_app_explore_tabs_app_sort,not null;default:0" json:"sortOrder"` + CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"` + UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"` +} + +func (AppExploreTab) TableName() string { + return "admin_app_explore_tabs" +} + type RefreshToken struct { ID uint `gorm:"primaryKey" json:"id"` UserID uint `gorm:"index;not null" json:"userId"` diff --git a/server/admin/internal/modules/appconfig/handler.go b/server/admin/internal/modules/appconfig/handler.go index b191ee78..2a5551d2 100644 --- a/server/admin/internal/modules/appconfig/handler.go +++ b/server/admin/internal/modules/appconfig/handler.go @@ -47,6 +47,55 @@ func (h *Handler) UpdateH5Links(c *gin.Context) { response.OK(c, gin.H{"items": items, "total": len(items)}) } +func (h *Handler) CreateH5Link(c *gin.Context) { + var request h5LinkPayload + if err := c.ShouldBindJSON(&request); err != nil { + response.BadRequest(c, "参数不正确") + return + } + item, err := h.service.CreateH5Link(request) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLog(c, h.audit, "create-app-h5-link", "admin_app_configs", "success", fmt.Sprintf("h5_key=%s", item.Key)) + response.Created(c, item) +} + +func (h *Handler) UpdateH5Link(c *gin.Context) { + key := strings.TrimSpace(c.Param("key")) + if key == "" { + response.BadRequest(c, "h5 config key is invalid") + return + } + var request h5LinkPayload + if err := c.ShouldBindJSON(&request); err != nil { + response.BadRequest(c, "参数不正确") + return + } + item, err := h.service.UpdateH5Link(key, request) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLog(c, h.audit, "update-app-h5-link", "admin_app_configs", "success", fmt.Sprintf("h5_key=%s", item.Key)) + response.OK(c, item) +} + +func (h *Handler) DeleteH5Link(c *gin.Context) { + key := strings.TrimSpace(c.Param("key")) + if key == "" { + response.BadRequest(c, "h5 config key is invalid") + return + } + if err := h.service.DeleteH5Link(key); err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLog(c, h.audit, "delete-app-h5-link", "admin_app_configs", "success", fmt.Sprintf("h5_key=%s", key)) + response.OK(c, gin.H{"deleted": true}) +} + func (h *Handler) ListBanners(c *gin.Context) { options := shared.ListOptions(c) items, err := h.service.ListBanners(appctx.FromContext(c.Request.Context()), repository.AppBannerListOptions{ @@ -170,6 +219,75 @@ func (h *Handler) DeleteAppVersion(c *gin.Context) { response.OK(c, gin.H{"deleted": true}) } +func (h *Handler) ListExploreTabs(c *gin.Context) { + var enabled *bool + switch strings.ToLower(strings.TrimSpace(c.Query("enabled"))) { + case "true", "1", "enabled": + value := true + enabled = &value + case "false", "0", "disabled": + value := false + enabled = &value + } + + items, err := h.service.ListExploreTabs(appctx.FromContext(c.Request.Context()), repository.AppExploreTabListOptions{ + Keyword: strings.TrimSpace(c.Query("keyword")), + Enabled: enabled, + }) + if err != nil { + response.ServerError(c, "获取 Explore 配置失败") + return + } + response.OK(c, gin.H{"items": items, "total": len(items)}) +} + +func (h *Handler) CreateExploreTab(c *gin.Context) { + var request exploreTabRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.BadRequest(c, "参数不正确") + return + } + item, err := h.service.CreateExploreTab(appctx.FromContext(c.Request.Context()), request) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLog(c, h.audit, "create-app-explore-tab", "admin_app_explore_tabs", "success", fmt.Sprintf("explore_tab_id=%d", item.ID)) + response.Created(c, item) +} + +func (h *Handler) UpdateExploreTab(c *gin.Context) { + id, ok := exploreTabID(c) + if !ok { + return + } + var request exploreTabRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.BadRequest(c, "参数不正确") + return + } + item, err := h.service.UpdateExploreTab(appctx.FromContext(c.Request.Context()), id, request) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLog(c, h.audit, "update-app-explore-tab", "admin_app_explore_tabs", "success", fmt.Sprintf("explore_tab_id=%d", item.ID)) + response.OK(c, item) +} + +func (h *Handler) DeleteExploreTab(c *gin.Context) { + id, ok := exploreTabID(c) + if !ok { + return + } + if err := h.service.DeleteExploreTab(appctx.FromContext(c.Request.Context()), id); err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLog(c, h.audit, "delete-app-explore-tab", "admin_app_explore_tabs", "success", fmt.Sprintf("explore_tab_id=%d", id)) + response.OK(c, gin.H{"deleted": true}) +} + func bannerID(c *gin.Context) (uint, bool) { value, err := strconv.ParseUint(c.Param("banner_id"), 10, 64) if err != nil || value == 0 { @@ -188,6 +306,15 @@ func appVersionID(c *gin.Context) (uint, bool) { return uint(value), true } +func exploreTabID(c *gin.Context) (uint, bool) { + value, err := strconv.ParseUint(c.Param("tab_id"), 10, 64) + if err != nil || value == 0 { + response.BadRequest(c, "explore tab id is invalid") + return 0, false + } + return uint(value), true +} + func parseOptionalInt64(values ...string) int64 { for _, value := range values { value = strings.TrimSpace(value) diff --git a/server/admin/internal/modules/appconfig/request.go b/server/admin/internal/modules/appconfig/request.go index c6559b3c..7da6eacd 100644 --- a/server/admin/internal/modules/appconfig/request.go +++ b/server/admin/internal/modules/appconfig/request.go @@ -5,8 +5,9 @@ type updateH5LinksRequest struct { } type h5LinkPayload struct { - Key string `json:"key" binding:"required"` - URL string `json:"url"` + Key string `json:"key" binding:"required"` + Label string `json:"label" binding:"required"` + URL string `json:"url" binding:"required"` } type bannerRequest struct { @@ -33,3 +34,10 @@ type appVersionRequest struct { DownloadURL string `json:"downloadUrl" binding:"required"` Description string `json:"description"` } + +type exploreTabRequest struct { + Tab string `json:"tab" binding:"required"` + H5URL string `json:"h5Url" binding:"required"` + Enabled bool `json:"enabled"` + SortOrder int `json:"sortOrder"` +} diff --git a/server/admin/internal/modules/appconfig/routes.go b/server/admin/internal/modules/appconfig/routes.go index 8426fc8c..b36595cb 100644 --- a/server/admin/internal/modules/appconfig/routes.go +++ b/server/admin/internal/modules/appconfig/routes.go @@ -12,7 +12,15 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { } protected.GET("/admin/app-config/h5-links", middleware.RequirePermission("app-config:view"), h.ListH5Links) + protected.POST("/admin/app-config/h5-links", middleware.RequirePermission("app-config:update"), h.CreateH5Link) protected.PUT("/admin/app-config/h5-links", middleware.RequirePermission("app-config:update"), h.UpdateH5Links) + protected.PUT("/admin/app-config/h5-links/:key", middleware.RequirePermission("app-config:update"), h.UpdateH5Link) + protected.DELETE("/admin/app-config/h5-links/:key", middleware.RequirePermission("app-config:update"), h.DeleteH5Link) + + protected.GET("/admin/app-config/explore-tabs", middleware.RequirePermission("app-config:view"), h.ListExploreTabs) + protected.POST("/admin/app-config/explore-tabs", middleware.RequirePermission("app-config:update"), h.CreateExploreTab) + protected.PUT("/admin/app-config/explore-tabs/:tab_id", middleware.RequirePermission("app-config:update"), h.UpdateExploreTab) + protected.DELETE("/admin/app-config/explore-tabs/:tab_id", middleware.RequirePermission("app-config:update"), h.DeleteExploreTab) protected.GET("/admin/app-config/banners", middleware.RequirePermission("app-config:view"), h.ListBanners) protected.POST("/admin/app-config/banners", middleware.RequirePermission("app-config:update"), h.CreateBanner) diff --git a/server/admin/internal/modules/appconfig/service.go b/server/admin/internal/modules/appconfig/service.go index 1cd7b6b0..401a7386 100644 --- a/server/admin/internal/modules/appconfig/service.go +++ b/server/admin/internal/modules/appconfig/service.go @@ -25,23 +25,10 @@ const ( bannerStatusExpired = "expired" ) -var h5LinkDefinitions = []H5LinkDefinition{ - {Key: "host-center", Label: "Host Center"}, - {Key: "bd-center", Label: "BD Center"}, - {Key: "bd-leader-center", Label: "BD Leader Center"}, - {Key: "agency-center", Label: "Agency Center"}, - {Key: "invite-user", Label: "Invite User"}, -} - type AppConfigService struct { store *repository.Store } -type H5LinkDefinition struct { - Key string `json:"key"` - Label string `json:"label"` -} - type H5Link struct { Key string `json:"key"` Label string `json:"label"` @@ -82,6 +69,17 @@ type AppVersion struct { UpdatedAtMs int64 `json:"updatedAtMs"` } +type ExploreTab struct { + ID uint `json:"id"` + AppCode string `json:"appCode"` + Tab string `json:"tab"` + H5URL string `json:"h5Url"` + Enabled bool `json:"enabled"` + SortOrder int `json:"sortOrder"` + CreatedAtMs int64 `json:"createdAtMs"` + UpdatedAtMs int64 `json:"updatedAtMs"` +} + func NewService(store *repository.Store) *AppConfigService { return &AppConfigService{store: store} } @@ -92,22 +90,9 @@ func (s *AppConfigService) ListH5Links() ([]H5Link, error) { return nil, err } - configByKey := make(map[string]model.AppConfig, len(configs)) + items := make([]H5Link, 0, len(configs)) for _, config := range configs { - configByKey[config.Key] = config - } - - items := make([]H5Link, 0, len(h5LinkDefinitions)) - for _, definition := range h5LinkDefinitions { - item := H5Link{ - Key: definition.Key, - Label: definition.Label, - } - if config, ok := configByKey[definition.Key]; ok { - item.URL = config.Value - item.UpdatedAtMs = config.UpdatedAtMS - } - items = append(items, item) + items = append(items, h5LinkFromModel(config)) } return items, nil } @@ -117,29 +102,19 @@ func (s *AppConfigService) UpdateH5Links(request updateH5LinksRequest) ([]H5Link return nil, errors.New("items are required") } - allowed := h5LinkDefinitionSet() items := make([]model.AppConfig, 0, len(request.Items)) seen := make(map[string]struct{}, len(request.Items)) for _, item := range request.Items { - key := strings.TrimSpace(item.Key) - if _, ok := allowed[key]; !ok { - return nil, fmt.Errorf("invalid h5 link key: %s", key) + config, err := h5LinkModelFromPayload(item) + if err != nil { + return nil, err } + key := config.Key if _, ok := seen[key]; ok { return nil, fmt.Errorf("duplicate h5 link key: %s", key) } seen[key] = struct{}{} - - link := strings.TrimSpace(item.URL) - if err := validateH5URL(link); err != nil { - return nil, err - } - items = append(items, model.AppConfig{ - Group: h5LinkGroup, - Key: key, - Value: link, - Description: allowed[key], - }) + items = append(items, config) } if err := s.store.UpsertAppConfigs(items); err != nil { @@ -148,6 +123,39 @@ func (s *AppConfigService) UpdateH5Links(request updateH5LinksRequest) ([]H5Link return s.ListH5Links() } +func (s *AppConfigService) CreateH5Link(req h5LinkPayload) (H5Link, error) { + item, err := h5LinkModelFromPayload(req) + if err != nil { + return H5Link{}, err + } + if err := s.store.CreateAppConfig(&item); err != nil { + return H5Link{}, err + } + return h5LinkFromModel(item), nil +} + +func (s *AppConfigService) UpdateH5Link(key string, req h5LinkPayload) (H5Link, error) { + item, err := s.store.GetAppConfig(h5LinkGroup, key) + if err != nil { + return H5Link{}, err + } + req.Key = item.Key + updated, err := h5LinkModelFromPayload(req) + if err != nil { + return H5Link{}, err + } + item.Value = updated.Value + item.Description = updated.Description + if err := s.store.UpdateAppConfig(&item); err != nil { + return H5Link{}, err + } + return h5LinkFromModel(item), nil +} + +func (s *AppConfigService) DeleteH5Link(key string) error { + return s.store.DeleteAppConfig(h5LinkGroup, key) +} + func (s *AppConfigService) ListBanners(appCode string, options repository.AppBannerListOptions) ([]AppBanner, error) { options.AppCode = appctx.Normalize(appCode) options.Status = normalizeBannerStatus(options.Status) @@ -265,12 +273,52 @@ func (s *AppConfigService) DeleteAppVersion(appCode string, id uint) error { return s.store.DeleteAppVersion(appctx.Normalize(appCode), id) } -func h5LinkDefinitionSet() map[string]string { - out := make(map[string]string, len(h5LinkDefinitions)) - for _, definition := range h5LinkDefinitions { - out[definition.Key] = definition.Label +func (s *AppConfigService) ListExploreTabs(appCode string, options repository.AppExploreTabListOptions) ([]ExploreTab, error) { + options.AppCode = appctx.Normalize(appCode) + options.Keyword = strings.TrimSpace(options.Keyword) + items, err := s.store.ListAppExploreTabs(options) + if err != nil { + return nil, err } - return out + out := make([]ExploreTab, 0, len(items)) + for _, item := range items { + out = append(out, exploreTabFromModel(item)) + } + return out, nil +} + +func (s *AppConfigService) CreateExploreTab(appCode string, req exploreTabRequest) (ExploreTab, error) { + item, err := exploreTabModelFromRequest(appctx.Normalize(appCode), req) + if err != nil { + return ExploreTab{}, err + } + if err := s.store.CreateAppExploreTab(&item); err != nil { + return ExploreTab{}, err + } + return exploreTabFromModel(item), nil +} + +func (s *AppConfigService) UpdateExploreTab(appCode string, id uint, req exploreTabRequest) (ExploreTab, error) { + item, err := s.store.GetAppExploreTab(appctx.Normalize(appCode), id) + if err != nil { + return ExploreTab{}, err + } + updated, err := exploreTabModelFromRequest(item.AppCode, req) + if err != nil { + return ExploreTab{}, err + } + item.Tab = updated.Tab + item.H5URL = updated.H5URL + item.Enabled = updated.Enabled + item.SortOrder = updated.SortOrder + if err := s.store.UpdateAppExploreTab(&item); err != nil { + return ExploreTab{}, err + } + return exploreTabFromModel(item), nil +} + +func (s *AppConfigService) DeleteExploreTab(appCode string, id uint) error { + return s.store.DeleteAppExploreTab(appctx.Normalize(appCode), id) } func validateH5URL(value string) error { @@ -288,6 +336,60 @@ func validateH5URL(value string) error { return nil } +func h5LinkModelFromPayload(req h5LinkPayload) (model.AppConfig, error) { + key := strings.TrimSpace(req.Key) + label := strings.TrimSpace(req.Label) + url := strings.TrimSpace(req.URL) + if err := validateH5Key(key); err != nil { + return model.AppConfig{}, err + } + if label == "" || utf8.RuneCountInString(label) > 80 { + return model.AppConfig{}, errors.New("h5 config label is invalid") + } + if url == "" { + return model.AppConfig{}, errors.New("h5 link is required") + } + if err := validateH5URL(url); err != nil { + return model.AppConfig{}, err + } + return model.AppConfig{ + Group: h5LinkGroup, + Key: key, + Value: url, + Description: label, + }, nil +} + +func validateH5Key(value string) error { + if value == "" || len(value) > 80 { + return errors.New("h5 config key is invalid") + } + for _, char := range value { + switch { + case char >= 'a' && char <= 'z': + case char >= 'A' && char <= 'Z': + case char >= '0' && char <= '9': + case char == '-' || char == '_' || char == '.' || char == ':': + default: + return errors.New("h5 config key is invalid") + } + } + return nil +} + +func h5LinkFromModel(config model.AppConfig) H5Link { + label := strings.TrimSpace(config.Description) + if label == "" { + label = config.Key + } + return H5Link{ + Key: config.Key, + Label: label, + URL: config.Value, + UpdatedAtMs: config.UpdatedAtMS, + } +} + func bannerModelFromRequest(appCode string, req bannerRequest) (model.AppBanner, error) { item := model.AppBanner{ AppCode: appctx.Normalize(appCode), @@ -436,6 +538,39 @@ func appVersionFromModel(item model.AppVersion) AppVersion { } } +func exploreTabModelFromRequest(appCode string, req exploreTabRequest) (model.AppExploreTab, error) { + item := model.AppExploreTab{ + AppCode: appctx.Normalize(appCode), + Tab: strings.TrimSpace(req.Tab), + H5URL: strings.TrimSpace(req.H5URL), + Enabled: req.Enabled, + SortOrder: req.SortOrder, + } + if item.Tab == "" || utf8.RuneCountInString(item.Tab) > 40 { + return model.AppExploreTab{}, errors.New("explore tab is invalid") + } + if item.H5URL == "" || len(item.H5URL) > 2048 { + return model.AppExploreTab{}, errors.New("explore h5 url is invalid") + } + if err := validateH5URL(item.H5URL); err != nil { + return model.AppExploreTab{}, err + } + return item, nil +} + +func exploreTabFromModel(item model.AppExploreTab) ExploreTab { + return ExploreTab{ + ID: item.ID, + AppCode: item.AppCode, + Tab: item.Tab, + H5URL: item.H5URL, + Enabled: item.Enabled, + SortOrder: item.SortOrder, + CreatedAtMs: item.CreatedAtMS, + UpdatedAtMs: item.UpdatedAtMS, + } +} + func normalizeBannerType(value string) string { return strings.ToLower(strings.TrimSpace(value)) } diff --git a/server/admin/internal/modules/appconfig/service_test.go b/server/admin/internal/modules/appconfig/service_test.go index 7adb835c..e0c72846 100644 --- a/server/admin/internal/modules/appconfig/service_test.go +++ b/server/admin/internal/modules/appconfig/service_test.go @@ -5,6 +5,31 @@ import ( "time" ) +func TestH5LinkModelFromPayloadValidatesDynamicConfig(t *testing.T) { + item, err := h5LinkModelFromPayload(h5LinkPayload{ + Key: "host-center.v2", + Label: "Host Center", + URL: "https://h5.example.com/host", + }) + if err != nil { + t.Fatalf("h5 config should be valid: %v", err) + } + if item.Group != h5LinkGroup || item.Key != "host-center.v2" || item.Description != "Host Center" || item.Value != "https://h5.example.com/host" { + t.Fatalf("h5 config model mismatch: %+v", item) + } +} + +func TestH5LinkModelFromPayloadRejectsInvalidKey(t *testing.T) { + _, err := h5LinkModelFromPayload(h5LinkPayload{ + Key: "host center", + Label: "Host Center", + URL: "https://h5.example.com/host", + }) + if err == nil { + t.Fatal("expected invalid key to fail") + } +} + func TestBannerModelFromRequestRequiresRoomSmallImageForRoomScope(t *testing.T) { _, err := bannerModelFromRequest("lalu", bannerRequest{ CoverURL: "https://cdn.example.com/banner.png", @@ -65,3 +90,29 @@ func TestBannerModelFromRequestExpiresEndedActiveBanner(t *testing.T) { t.Fatalf("non-room banner must not retain room small image: %+v", item) } } + +func TestExploreTabModelFromRequestValidatesRequiredFields(t *testing.T) { + item, err := exploreTabModelFromRequest("lalu", exploreTabRequest{ + Enabled: true, + H5URL: "https://h5.example.com/explore/live", + SortOrder: 3, + Tab: "Live", + }) + if err != nil { + t.Fatalf("explore tab should be valid: %v", err) + } + if item.AppCode != "lalu" || item.Tab != "Live" || item.H5URL != "https://h5.example.com/explore/live" || !item.Enabled || item.SortOrder != 3 { + t.Fatalf("explore tab model mismatch: %+v", item) + } +} + +func TestExploreTabModelFromRequestRejectsWhitespaceURL(t *testing.T) { + _, err := exploreTabModelFromRequest("lalu", exploreTabRequest{ + Enabled: true, + H5URL: "https://h5.example.com/explore live", + Tab: "Live", + }) + if err == nil { + t.Fatal("expected whitespace h5 url to fail") + } +} diff --git a/server/admin/internal/modules/luckygift/handler.go b/server/admin/internal/modules/luckygift/handler.go index 80ab1d1f..83ff3b37 100644 --- a/server/admin/internal/modules/luckygift/handler.go +++ b/server/admin/internal/modules/luckygift/handler.go @@ -103,6 +103,22 @@ type drawDTO struct { CreatedAtMS int64 `json:"created_at_ms"` } +type drawSummaryDTO struct { + PoolID string `json:"pool_id"` + TotalDraws int64 `json:"total_draws"` + UniqueUsers int64 `json:"unique_users"` + UniqueRooms int64 `json:"unique_rooms"` + TotalSpentCoins int64 `json:"total_spent_coins"` + TotalRewardCoins int64 `json:"total_reward_coins"` + BaseRewardCoins int64 `json:"base_reward_coins"` + RoomAtmosphereRewardCoins int64 `json:"room_atmosphere_reward_coins"` + ActivitySubsidyCoins int64 `json:"activity_subsidy_coins"` + ActualRTPPPM int64 `json:"actual_rtp_ppm"` + PendingDraws int64 `json:"pending_draws"` + GrantedDraws int64 `json:"granted_draws"` + FailedDraws int64 `json:"failed_draws"` +} + func (h *Handler) GetLuckyGiftConfig(c *gin.Context) { resp, err := h.activity.GetLuckyGiftConfig(c.Request.Context(), &activityv1.GetLuckyGiftConfigRequest{ Meta: h.meta(c), @@ -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()}) } +func (h *Handler) GetLuckyGiftDrawSummary(c *gin.Context) { + resp, err := h.activity.GetLuckyGiftDrawSummary(c.Request.Context(), &activityv1.GetLuckyGiftDrawSummaryRequest{ + Meta: h.meta(c), + PoolId: strings.TrimSpace(c.Query("pool_id")), + GiftId: strings.TrimSpace(c.Query("gift_id")), + UserId: parseOptionalInt64(c.Query("user_id")), + RoomId: strings.TrimSpace(c.Query("room_id")), + Status: strings.TrimSpace(c.Query("status")), + }) + if err != nil { + response.ServerError(c, "获取幸运礼物抽奖汇总失败") + return + } + response.OK(c, drawSummaryFromProto(resp.GetSummary())) +} + func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta { return &activityv1.RequestMeta{ RequestId: middleware.CurrentRequestID(c), @@ -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 { value = strings.TrimSpace(value) if value == "" { diff --git a/server/admin/internal/modules/luckygift/routes.go b/server/admin/internal/modules/luckygift/routes.go index cb00cc35..2587bcf2 100644 --- a/server/admin/internal/modules/luckygift/routes.go +++ b/server/admin/internal/modules/luckygift/routes.go @@ -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.GET("/admin/activity/lucky-gifts/configs", middleware.RequirePermission("lucky-gift:view"), h.ListLuckyGiftConfigs) protected.GET("/admin/activity/lucky-gifts/draws", middleware.RequirePermission("lucky-gift:view"), h.ListLuckyGiftDraws) + protected.GET("/admin/activity/lucky-gifts/draw-summary", middleware.RequirePermission("lucky-gift:view"), h.GetLuckyGiftDrawSummary) } diff --git a/server/admin/internal/modules/resource/dto.go b/server/admin/internal/modules/resource/dto.go index cfc9ca14..c9081556 100644 --- a/server/admin/internal/modules/resource/dto.go +++ b/server/admin/internal/modules/resource/dto.go @@ -17,6 +17,7 @@ type resourceDTO struct { PriceType string `json:"priceType"` CoinPrice int64 `json:"coinPrice"` GiftPointAmount int64 `json:"giftPointAmount"` + BadgeForm string `json:"badgeForm,omitempty"` UsageScopes []string `json:"usageScopes"` AssetURL string `json:"assetUrl"` PreviewURL string `json:"previewUrl"` @@ -178,6 +179,7 @@ func resourceFromProto(item *walletv1.Resource) resourceDTO { PriceType: item.GetPriceType(), CoinPrice: item.GetCoinPrice(), GiftPointAmount: item.GetGiftPointAmount(), + BadgeForm: badgeFormForResource(item.GetResourceType(), item.GetMetadataJson()), UsageScopes: item.GetUsageScopes(), AssetURL: item.GetAssetUrl(), PreviewURL: item.GetPreviewUrl(), diff --git a/server/admin/internal/modules/resource/handler.go b/server/admin/internal/modules/resource/handler.go index cf83a95a..d7377153 100644 --- a/server/admin/internal/modules/resource/handler.go +++ b/server/admin/internal/modules/resource/handler.go @@ -78,6 +78,10 @@ func (h *Handler) CreateResource(c *gin.Context) { response.BadRequest(c, err.Error()) return } + if err := validateResourceBadgeForm(req); err != nil { + response.BadRequest(c, err.Error()) + return + } resp, err := h.wallet.CreateResource(c.Request.Context(), req.createProto(c)) if err != nil { response.BadRequest(c, err.Error()) @@ -178,6 +182,10 @@ func (h *Handler) UpdateResource(c *gin.Context) { response.BadRequest(c, err.Error()) return } + if err := validateResourceBadgeForm(req); err != nil { + response.BadRequest(c, err.Error()) + return + } protoReq := req.updateProto(c, resourceID) resp, err := h.wallet.UpdateResource(c.Request.Context(), protoReq) if err != nil { diff --git a/server/admin/internal/modules/resource/operator_test.go b/server/admin/internal/modules/resource/operator_test.go index fd9710c2..0b893f7a 100644 --- a/server/admin/internal/modules/resource/operator_test.go +++ b/server/admin/internal/modules/resource/operator_test.go @@ -70,3 +70,29 @@ func TestEmojiPackMetadataKeepsLimitedRegions(t *testing.T) { t.Fatalf("unexpected region ids: %+v", payload.RegionIDs) } } + +func TestBadgeResourceMetadataUsesSelectedForm(t *testing.T) { + metadata := resourceMetadataJSON(resourceTypeBadge, "long") + payload := badgeMetadataPayload{} + if err := json.Unmarshal([]byte(metadata), &payload); err != nil { + t.Fatalf("badge metadata json mismatch: %v", err) + } + if payload.BadgeForm != badgeFormStrip || payload.DefaultSlot != "profile_strip" { + t.Fatalf("long badge metadata mismatch: %+v", payload) + } + if got := badgeFormFromMetadata(resourceMetadataJSON(resourceTypeBadge, "short")); got != badgeFormTile { + t.Fatalf("short badge form mismatch: %s", got) + } +} + +func TestValidateBadgeResourceRequiresBadgeForm(t *testing.T) { + if err := validateResourceBadgeForm(resourceRequest{ResourceType: resourceTypeBadge}); err == nil { + t.Fatalf("badge form should be required") + } + if err := validateResourceBadgeForm(resourceRequest{ResourceType: resourceTypeBadge, BadgeForm: badgeFormTile}); err != nil { + t.Fatalf("valid badge form rejected: %v", err) + } + if err := validateResourceBadgeForm(resourceRequest{ResourceType: "gift", BadgeForm: ""}); err != nil { + t.Fatalf("non-badge resource should not require badge form: %v", err) + } +} diff --git a/server/admin/internal/modules/resource/request.go b/server/admin/internal/modules/resource/request.go index 1e6000af..47ca33b2 100644 --- a/server/admin/internal/modules/resource/request.go +++ b/server/admin/internal/modules/resource/request.go @@ -18,6 +18,7 @@ const dayMillis int64 = 24 * 60 * 60 * 1000 const ( resourceTypeCoin = "coin" + resourceTypeBadge = "badge" resourceTypeEmojiPack = "emoji_pack" ) @@ -26,6 +27,11 @@ const ( resourcePriceTypeFree = "free" ) +const ( + badgeFormStrip = "strip" + badgeFormTile = "tile" +) + const ( defaultEmojiPackCategory = "默认" emojiPackPricingFree = "free" @@ -41,6 +47,7 @@ type resourceRequest struct { PriceType string `json:"priceType"` CoinPrice int64 `json:"coinPrice"` GiftPointAmount int64 `json:"giftPointAmount"` + BadgeForm string `json:"badgeForm"` ManagerGrantEnabled *bool `json:"managerGrantEnabled"` AssetURL string `json:"assetUrl"` PreviewURL string `json:"previewUrl"` @@ -65,6 +72,11 @@ type emojiPackMetadataPayload struct { RegionScope string `json:"region_scope"` } +type badgeMetadataPayload struct { + BadgeForm string `json:"badge_form"` + DefaultSlot string `json:"default_slot,omitempty"` +} + type resourceGroupRequest struct { GroupCode string `json:"groupCode"` Name string `json:"name"` @@ -130,6 +142,7 @@ func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceReq resourceType := normalizeResourceType(r.ResourceType) walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount) priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice) + metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm) return &walletv1.CreateResourceRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), @@ -149,7 +162,7 @@ func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceReq AssetUrl: strings.TrimSpace(r.AssetURL), PreviewUrl: strings.TrimSpace(r.PreviewURL), AnimationUrl: strings.TrimSpace(r.AnimationURL), - MetadataJson: "{}", + MetadataJson: metadataJSON, SortOrder: r.SortOrder, OperatorUserId: actorID(c), } @@ -159,6 +172,7 @@ func (r resourceRequest) updateProto(c *gin.Context, resourceID int64) *walletv1 resourceType := normalizeResourceType(r.ResourceType) walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount) priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice) + metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm) return &walletv1.UpdateResourceRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), @@ -179,7 +193,7 @@ func (r resourceRequest) updateProto(c *gin.Context, resourceID int64) *walletv1 AssetUrl: strings.TrimSpace(r.AssetURL), PreviewUrl: strings.TrimSpace(r.PreviewURL), AnimationUrl: strings.TrimSpace(r.AnimationURL), - MetadataJson: "{}", + MetadataJson: metadataJSON, SortOrder: r.SortOrder, OperatorUserId: actorID(c), } @@ -379,6 +393,16 @@ func validateResourcePricing(req resourceRequest) error { return nil } +func validateResourceBadgeForm(req resourceRequest) error { + if normalizeResourceType(req.ResourceType) != resourceTypeBadge { + return nil + } + if normalizeBadgeForm(req.BadgeForm) == "" { + return fmt.Errorf("请选择徽章属性") + } + return nil +} + func resourceGrantStrategy(resourceType string) string { switch resourceType { case resourceTypeCoin: @@ -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) { normalizedRegionIDs := normalizeRegionIDs(regionIDs) metadata := emojiPackMetadataPayload{ diff --git a/server/admin/internal/repository/app_config_repository.go b/server/admin/internal/repository/app_config_repository.go index 738ddcbc..54ceb22f 100644 --- a/server/admin/internal/repository/app_config_repository.go +++ b/server/admin/internal/repository/app_config_repository.go @@ -15,12 +15,36 @@ type AppVersionListOptions struct { Keyword string } +type AppExploreTabListOptions struct { + AppCode string + Keyword string + Enabled *bool +} + func (s *Store) ListAppConfigs(group string) ([]model.AppConfig, error) { var items []model.AppConfig err := s.db.Where("`group` = ?", strings.TrimSpace(group)).Order("`key` ASC").Find(&items).Error return items, err } +func (s *Store) GetAppConfig(group string, key string) (model.AppConfig, error) { + var item model.AppConfig + err := s.db.Where("`group` = ? AND `key` = ?", strings.TrimSpace(group), strings.TrimSpace(key)).First(&item).Error + return item, err +} + +func (s *Store) CreateAppConfig(item *model.AppConfig) error { + return s.db.Create(item).Error +} + +func (s *Store) UpdateAppConfig(item *model.AppConfig) error { + return s.db.Save(item).Error +} + +func (s *Store) DeleteAppConfig(group string, key string) error { + return s.db.Where("`group` = ? AND `key` = ?", strings.TrimSpace(group), strings.TrimSpace(key)).Delete(&model.AppConfig{}).Error +} + func (s *Store) UpsertAppConfigs(items []model.AppConfig) error { if len(items) == 0 { return nil @@ -77,3 +101,36 @@ func (s *Store) UpdateAppVersion(item *model.AppVersion) error { func (s *Store) DeleteAppVersion(appCode string, id uint) error { return s.db.Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).Delete(&model.AppVersion{}).Error } + +func (s *Store) ListAppExploreTabs(options AppExploreTabListOptions) ([]model.AppExploreTab, error) { + db := s.db.Where("app_code = ?", strings.TrimSpace(options.AppCode)) + if options.Enabled != nil { + db = db.Where("enabled = ?", *options.Enabled) + } + if keyword := strings.TrimSpace(options.Keyword); keyword != "" { + like := "%" + keyword + "%" + db = db.Where("tab LIKE ? OR h5_url LIKE ?", like, like) + } + + var items []model.AppExploreTab + err := db.Order("sort_order ASC, id DESC").Find(&items).Error + return items, err +} + +func (s *Store) GetAppExploreTab(appCode string, id uint) (model.AppExploreTab, error) { + var item model.AppExploreTab + err := s.db.Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).First(&item).Error + return item, err +} + +func (s *Store) CreateAppExploreTab(item *model.AppExploreTab) error { + return s.db.Create(item).Error +} + +func (s *Store) UpdateAppExploreTab(item *model.AppExploreTab) error { + return s.db.Save(item).Error +} + +func (s *Store) DeleteAppExploreTab(appCode string, id uint) error { + return s.db.Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).Delete(&model.AppExploreTab{}).Error +} diff --git a/server/admin/internal/repository/repository.go b/server/admin/internal/repository/repository.go index 7c5b3a96..f4ecf0ca 100644 --- a/server/admin/internal/repository/repository.go +++ b/server/admin/internal/repository/repository.go @@ -65,6 +65,7 @@ func (s *Store) AutoMigrate() error { &model.AppConfig{}, &model.AppBanner{}, &model.AppVersion{}, + &model.AppExploreTab{}, &model.RefreshToken{}, &model.LoginLog{}, &model.OperationLog{}, diff --git a/server/admin/internal/repository/seed.go b/server/admin/internal/repository/seed.go index 7fdd73fd..02334caf 100644 --- a/server/admin/internal/repository/seed.go +++ b/server/admin/internal/repository/seed.go @@ -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: &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: "内购配置", Code: "payment-recharge-products", Path: "/app-config/recharge-products", Icon: "wallet", PermissionCode: "payment-product: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: "Explore配置", Code: "app-config-explore", Path: "/app-config/explore", Icon: "explore", PermissionCode: "app-config:view", Sort: 68, 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-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}, diff --git a/server/admin/migrations/022_app_explore_config.sql b/server/admin/migrations/022_app_explore_config.sql new file mode 100644 index 00000000..fa3b217a --- /dev/null +++ b/server/admin/migrations/022_app_explore_config.sql @@ -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; diff --git a/services/activity-service/configs/config.docker.yaml b/services/activity-service/configs/config.docker.yaml index 868fea2f..4abfc739 100644 --- a/services/activity-service/configs/config.docker.yaml +++ b/services/activity-service/configs/config.docker.yaml @@ -12,7 +12,7 @@ health_http_addr: ":13106" mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC" user_service_addr: "user-service:13005" wallet_service_addr: "wallet-service:13004" -mysql_auto_migrate: false +mysql_auto_migrate: true first_recharge_reward_worker: enabled: false red_packet_broadcast_worker: diff --git a/services/activity-service/configs/config.yaml b/services/activity-service/configs/config.yaml index f66d7fa0..18d095e3 100644 --- a/services/activity-service/configs/config.yaml +++ b/services/activity-service/configs/config.yaml @@ -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" user_service_addr: "127.0.0.1:13005" wallet_service_addr: "127.0.0.1:13004" -mysql_auto_migrate: false +mysql_auto_migrate: true first_recharge_reward_worker: enabled: false red_packet_broadcast_worker: diff --git a/services/activity-service/internal/app/app.go b/services/activity-service/internal/app/app.go index c7d7d71f..ec787130 100644 --- a/services/activity-service/internal/app/app.go +++ b/services/activity-service/internal/app/app.go @@ -71,6 +71,12 @@ func New(cfg config.Config) (*App, error) { if err != nil { 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) if err != nil { diff --git a/services/activity-service/internal/domain/luckygift/lucky_gift.go b/services/activity-service/internal/domain/luckygift/lucky_gift.go index d730501a..ca6a8adc 100644 --- a/services/activity-service/internal/domain/luckygift/lucky_gift.go +++ b/services/activity-service/internal/domain/luckygift/lucky_gift.go @@ -127,6 +127,22 @@ type DrawResult struct { 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 { PoolID string } diff --git a/services/activity-service/internal/service/luckygift/service.go b/services/activity-service/internal/service/luckygift/service.go index 04e76a96..de7106d2 100644 --- a/services/activity-service/internal/service/luckygift/service.go +++ b/services/activity-service/internal/service/luckygift/service.go @@ -18,6 +18,7 @@ type Repository interface { CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error) ListLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error) + GetLuckyGiftDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) } // Service 承载幸运礼物查询、抽奖命令和后台规则配置。 @@ -108,10 +109,7 @@ func (s *Service) ListDraws(ctx context.Context, query domain.DrawQuery) ([]doma if err := s.requireRepository(); err != nil { return nil, 0, err } - query.PoolID = normalizePoolID(query.PoolID) - query.GiftID = strings.TrimSpace(query.GiftID) - query.RoomID = strings.TrimSpace(query.RoomID) - query.Status = strings.TrimSpace(query.Status) + query = normalizeDrawQuery(query) if query.Page <= 0 { query.Page = 1 } @@ -124,9 +122,24 @@ func (s *Service) ListDraws(ctx context.Context, query domain.DrawQuery) ([]doma 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 { if s == nil || s.repository == nil { return xerr.New(xerr.Unavailable, "lucky gift repository is not configured") } 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 +} diff --git a/services/activity-service/internal/service/luckygift/service_test.go b/services/activity-service/internal/service/luckygift/service_test.go index 953c0c9b..407a7ecb 100644 --- a/services/activity-service/internal/service/luckygift/service_test.go +++ b/services/activity-service/internal/service/luckygift/service_test.go @@ -68,3 +68,7 @@ func (r *fakeLuckyGiftRepository) ExecuteLuckyGiftDraw(context.Context, domain.D func (r *fakeLuckyGiftRepository) ListLuckyGiftDraws(context.Context, domain.DrawQuery) ([]domain.DrawResult, int64, error) { return nil, 0, nil } + +func (r *fakeLuckyGiftRepository) GetLuckyGiftDrawSummary(context.Context, domain.DrawQuery) (domain.DrawSummary, error) { + return domain.DrawSummary{}, nil +} diff --git a/services/activity-service/internal/storage/mysql/lucky_gift_repository.go b/services/activity-service/internal/storage/mysql/lucky_gift_repository.go index d1449814..bf6a5fc3 100644 --- a/services/activity-service/internal/storage/mysql/lucky_gift_repository.go +++ b/services/activity-service/internal/storage/mysql/lucky_gift_repository.go @@ -594,30 +594,7 @@ func (r *Repository) ListLuckyGiftDraws(ctx context.Context, query domain.DrawQu if r == nil || r.db == nil { return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") } - appCode := appcode.FromContext(ctx) - 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 ") + whereSQL, args := luckyDrawWhereClause(appcode.FromContext(ctx), query) var total int64 if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM lucky_draw_records WHERE `+whereSQL, args...).Scan(&total); err != nil { return nil, 0, err @@ -652,6 +629,74 @@ func (r *Repository) ListLuckyGiftDraws(ctx context.Context, query domain.DrawQu 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 { QueryRowContext(context.Context, string, ...any) *sql.Row } diff --git a/services/activity-service/internal/storage/mysql/repository.go b/services/activity-service/internal/storage/mysql/repository.go index d00c29af..66017dcf 100644 --- a/services/activity-service/internal/storage/mysql/repository.go +++ b/services/activity-service/internal/storage/mysql/repository.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "fmt" "strings" _ "github.com/go-sql-driver/mysql" @@ -36,6 +37,17 @@ func Open(ctx context.Context, dsn string) (*Repository, error) { 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 连接池。 func (r *Repository) Close() error { if r == nil || r.db == nil { @@ -54,6 +66,79 @@ func (r *Repository) Ping(ctx context.Context) error { 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 活动配置表读取同步查询投影。 func (r *Repository) GetActivity(ctx context.Context, activityID string) (activitydomain.Activity, error) { if r == nil || r.db == nil { diff --git a/services/activity-service/internal/storage/mysql/repository_migration_test.go b/services/activity-service/internal/storage/mysql/repository_migration_test.go new file mode 100644 index 00000000..76e8b8b4 --- /dev/null +++ b/services/activity-service/internal/storage/mysql/repository_migration_test.go @@ -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) + } +} diff --git a/services/activity-service/internal/transport/grpc/lucky_gift_server.go b/services/activity-service/internal/transport/grpc/lucky_gift_server.go index 34648ae4..5c998644 100644 --- a/services/activity-service/internal/transport/grpc/lucky_gift_server.go +++ b/services/activity-service/internal/transport/grpc/lucky_gift_server.go @@ -129,6 +129,21 @@ func (s *AdminLuckyGiftServer) ListLuckyGiftDraws(ctx context.Context, req *acti 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 { if config == nil { return domain.Config{} @@ -270,3 +285,21 @@ func luckyDrawResultToProto(result domain.DrawResult) *activityv1.LuckyGiftDrawR 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, + } +} diff --git a/services/gateway-service/configs/config.docker.yaml b/services/gateway-service/configs/config.docker.yaml index d0c5c314..9e7c1b9e 100644 --- a/services/gateway-service/configs/config.docker.yaml +++ b/services/gateway-service/configs/config.docker.yaml @@ -52,7 +52,7 @@ grpc_client: retry_backoff_multiplier: 2 retryable_status_codes: ["UNAVAILABLE"] app_config: - # H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configs,gateway 只读下发给 App。 + # App 运行时配置由后台 APP配置写入 hyapp_admin,gateway 只读下发给 App。 mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC" leaderboard: # 活动用户榜单只读 wallet_transactions 的送礼事实;时间窗口统一按 UTC 计算。 diff --git a/services/gateway-service/configs/config.tencent.example.yaml b/services/gateway-service/configs/config.tencent.example.yaml index 2b45543c..a76cb149 100644 --- a/services/gateway-service/configs/config.tencent.example.yaml +++ b/services/gateway-service/configs/config.tencent.example.yaml @@ -49,7 +49,7 @@ grpc_client: retry_backoff_multiplier: 2 retryable_status_codes: ["UNAVAILABLE"] app_config: - # H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configs,gateway 只读下发给 App。 + # App 运行时配置由后台 APP配置写入 hyapp_admin,gateway 只读下发给 App。 mysql_dsn: "TENCENT_MYSQL_USER:TENCENT_MYSQL_PASSWORD@tcp(TENCENT_MYSQL_HOST:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC" leaderboard: # 活动用户榜单只读钱包送礼事实;线上建议配置只读账号。 diff --git a/services/gateway-service/configs/config.yaml b/services/gateway-service/configs/config.yaml index 70ee9b8b..367f1df4 100644 --- a/services/gateway-service/configs/config.yaml +++ b/services/gateway-service/configs/config.yaml @@ -52,7 +52,7 @@ grpc_client: retry_backoff_multiplier: 2 retryable_status_codes: ["UNAVAILABLE"] app_config: - # H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configs,gateway 只读下发给 App。 + # App 运行时配置由后台 APP配置写入 hyapp_admin,gateway 只读下发给 App。 mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC" leaderboard: # 活动用户榜单只读 wallet_transactions 的送礼事实;时间窗口统一按 UTC 计算。 diff --git a/services/gateway-service/internal/appconfig/mysql.go b/services/gateway-service/internal/appconfig/mysql.go index 4d816af9..91a45cad 100644 --- a/services/gateway-service/internal/appconfig/mysql.go +++ b/services/gateway-service/internal/appconfig/mysql.go @@ -13,7 +13,12 @@ import ( 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 = ` 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 @@ -39,6 +44,17 @@ type H5Link struct { 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 的公开筛选条件。 type BannerQuery struct { AppCode string @@ -89,24 +105,12 @@ type Version struct { // Reader 是 HTTP 层读取 H5 配置的最小依赖。 type Reader interface { ListH5Links(ctx context.Context) ([]H5Link, error) + ListExploreTabs(ctx context.Context, appCode string) ([]ExploreTab, error) ListBanners(ctx context.Context, query BannerQuery) ([]Banner, error) LatestVersion(ctx context.Context, query VersionQuery) (Version, error) } -type h5LinkDefinition struct { - 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 配置。 +// MySQLReader 从 hyapp_admin 读取后台 App 配置。 type MySQLReader struct { db *sql.DB } @@ -130,7 +134,7 @@ func (r *MySQLReader) Close() error { return r.db.Close() } -// ListH5Links 返回固定 H5 入口集合;未配置的入口保留空 URL,方便客户端按 key 稳定渲染。 +// ListH5Links 返回后台动态维护的 H5 入口集合。 func (r *MySQLReader) ListH5Links(ctx context.Context) ([]H5Link, error) { if r == nil || r.db == nil { 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() - configs := make(map[string]H5Link, len(h5LinkDefinitions)) + items := make([]H5Link, 0) for rows.Next() { - var key string - var url string - var updatedAtMS int64 - if err := rows.Scan(&key, &url, &updatedAtMS); err != nil { + var item H5Link + if err := rows.Scan(&item.Key, &item.Label, &item.URL, &item.UpdatedAtMs); err != nil { return nil, err } - - key = strings.TrimSpace(key) - link := H5Link{ - Key: key, - URL: strings.TrimSpace(url), + item.Key = strings.TrimSpace(item.Key) + item.Label = strings.TrimSpace(item.Label) + if item.Label == "" { + item.Label = item.Key } - link.UpdatedAtMs = updatedAtMS - configs[key] = link + item.URL = strings.TrimSpace(item.URL) + items = append(items, item) } if err := rows.Err(); err != nil { return nil, err } + return items, nil +} - items := make([]H5Link, 0, len(h5LinkDefinitions)) - for _, definition := range h5LinkDefinitions { - item := H5Link{ - Key: definition.Key, - Label: definition.Label, - } - if config, ok := configs[definition.Key]; ok { - item.URL = config.URL - item.UpdatedAtMs = config.UpdatedAtMs +// ListExploreTabs 返回当前 App 启用的 Explore H5 tabs;后台关闭的 tab 不下发给客户端。 +func (r *MySQLReader) ListExploreTabs(ctx context.Context, appCode string) ([]ExploreTab, error) { + if r == nil || r.db == nil { + return nil, errors.New("app config reader is not configured") + } + + rows, err := r.db.QueryContext(ctx, listExploreTabsSQL, normalizeAppCode(appCode)) + if err != nil { + 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) } - + if err := rows.Err(); err != nil { + return nil, err + } return items, nil } @@ -261,10 +274,11 @@ func (r *MySQLReader) LatestVersion(ctx context.Context, query VersionQuery) (Ve // StaticReader 给测试和临时环境提供内存版 H5 配置读取。 type StaticReader struct { - Links []H5Link - Banners []Banner - Version Version - Err error + Links []H5Link + ExploreTabs []ExploreTab + Banners []Banner + Version Version + Err error } // ListH5Links 返回预置 H5 配置。 @@ -278,6 +292,17 @@ func (r StaticReader) ListH5Links(context.Context) ([]H5Link, error) { 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 配置。 func (r StaticReader) ListBanners(context.Context, BannerQuery) ([]Banner, error) { if r.Err != nil { diff --git a/services/gateway-service/internal/config/config.go b/services/gateway-service/internal/config/config.go index be801ff8..b3514a7e 100644 --- a/services/gateway-service/internal/config/config.go +++ b/services/gateway-service/internal/config/config.go @@ -110,7 +110,7 @@ type LoginRiskFastGuardConfig struct { // AppConfigConfig 描述 gateway 读取后台 App 运行时配置的只读数据源。 type AppConfigConfig struct { - // MySQLDSN 指向后台管理库 hyapp_admin;gateway 只读 admin_app_configs。 + // MySQLDSN 指向后台管理库 hyapp_admin;gateway 只读 App 运行时配置表。 MySQLDSN string `yaml:"mysql_dsn"` } diff --git a/services/gateway-service/internal/transport/http/appapi/app_config_handler.go b/services/gateway-service/internal/transport/http/appapi/app_config_handler.go index 7097e7a8..2817a3e9 100644 --- a/services/gateway-service/internal/transport/http/appapi/app_config_handler.go +++ b/services/gateway-service/internal/transport/http/appapi/app_config_handler.go @@ -67,11 +67,12 @@ func (h *Handler) getAppBootstrap(writer http.ResponseWriter, request *http.Requ {Key: "me", Title: "我的", Enabled: true}, }, ConfigVersions: map[string]string{ - "countries": "v1", - "banners": "v1", - "h5_links": "v1", - "resources": "v1", - "gifts": "v1", + "countries": "v1", + "banners": "v1", + "h5_links": "v1", + "explore_tabs": "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)}) } +// 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。 func (h *Handler) listAppBanners(writer http.ResponseWriter, request *http.Request) { if h.appConfigReader == nil { diff --git a/services/gateway-service/internal/transport/http/appapi/handler.go b/services/gateway-service/internal/transport/http/appapi/handler.go index 58d0c9d4..0f6fec54 100644 --- a/services/gateway-service/internal/transport/http/appapi/handler.go +++ b/services/gateway-service/internal/transport/http/appapi/handler.go @@ -12,6 +12,7 @@ import ( // ConfigReader 是 gateway 对后台 App 配置的只读依赖。 type ConfigReader interface { 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) 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) } +func (h *Handler) ListExploreTabs(writer http.ResponseWriter, request *http.Request) { + h.listExploreTabs(writer, request) +} + func (h *Handler) ListAppBanners(writer http.ResponseWriter, request *http.Request) { h.listAppBanners(writer, request) } diff --git a/services/gateway-service/internal/transport/http/httproutes/router.go b/services/gateway-service/internal/transport/http/httproutes/router.go index ac09269a..6c7c8085 100644 --- a/services/gateway-service/internal/transport/http/httproutes/router.go +++ b/services/gateway-service/internal/transport/http/httproutes/router.go @@ -42,6 +42,7 @@ type AppHandlers struct { ListLoginRiskBlockedCountries http.HandlerFunc GetAppBootstrap http.HandlerFunc ListH5Links http.HandlerFunc + ListExploreTabs http.HandlerFunc ListAppBanners http.HandlerFunc GetAppVersion 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("/app/bootstrap", "", h.GetAppBootstrap) 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/version", http.MethodGet, h.GetAppVersion) r.public("/resource-groups/{group_id}", "", h.GetResourceGroup) diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 999028f8..2f713947 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -2217,6 +2217,53 @@ func TestListH5LinksRequiresConfigReader(t *testing.T) { 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) { router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/app/bootstrap", nil) diff --git a/services/gateway-service/internal/transport/http/router.go b/services/gateway-service/internal/transport/http/router.go index 0b3e16cc..75f4ff4f 100644 --- a/services/gateway-service/internal/transport/http/router.go +++ b/services/gateway-service/internal/transport/http/router.go @@ -128,6 +128,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler { ListLoginRiskBlockedCountries: userAPI.ListLoginRiskBlockedCountries, GetAppBootstrap: appAPI.GetAppBootstrap, ListH5Links: appAPI.ListH5Links, + ListExploreTabs: appAPI.ListExploreTabs, ListAppBanners: appAPI.ListAppBanners, GetAppVersion: appAPI.GetAppVersion, GetResourceGroup: resourceAPI.GetResourceGroup,