完善接口

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

View File

@ -21,10 +21,23 @@
- `notice-service` 负责从各 owner service 的 outbox/事件源读取事实并投递私有通知、腾讯云 IM 单聊、后续 push/inbox钱包、活动和房间不能直接耦合私有通知投递实现。
- `user-service` 负责用户主数据,不应该成为房间高频流程的同步中心。
- `cron-service` 只负责后台调度、任务运行记录和通过内部 gRPC 触发 owner service 批处理;不能直接拥有房间、账务、用户或活动业务状态。
- `statistics-service` 只负责消费 owner service 已发布到 RocketMQ 的业务事实并写本服务聚合表;它不是业务事实 owner不能直连 room/wallet/user/game 等 owner 数据库补数或查询明细。
- `server/admin` 是后台管理服务独立 module负责管理员、RBAC、菜单、审计和后台 HTTP API访问 app 后端只能通过 `hyapp.local/api` 生成的 protobuf client 或明确的集成接口。
- `api` 是独立 Go modulemodule path 为 `hyapp.local/api`,只放 protobuf contract 和生成代码,不放服务实现或业务工具包。
- `wallet_outbox``room_outbox` 只能由各自 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

View File

@ -15,6 +15,7 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
- `activity-service` 当前承接每日任务、活动/系统消息基础能力和房间事件 consumer可通过 room outbox direct gRPC 或 RocketMQ `hyapp_room_outbox` 独立 consumer group 消费房间事实。
- `cron-service` 当前负责后台调度、任务运行记录,以及通过内部 gRPC 触发 owner service 批处理;它不直接拥有房间、账务、用户或活动业务状态。
- `notice-service` 当前消费 `wallet_outbox` 的余额变更事件和 `room_outbox` 的踢人事件,异步投递腾讯云 IM 单聊私有通知;房间群消息和群成员控制仍由 `room-service` 的房间 IM bridge 处理MQ 模式下通过独立 consumer group 解耦。
- `statistics-service` 当前消费 user/wallet/room/game outbox 的 RocketMQ 事实,写入本服务 MySQL 聚合表,并通过内部 HTTP 查询 API 支撑后台数据大屏;它不拥有注册、账务、房间或游戏业务事实。
- 腾讯云 IM 承担客户端长连接、群消息、公屏、单聊、离线/漫游和全球接入能力;本仓库不再部署 `im-service`
- 多 App 共用同一套后端服务,入口由 `gateway-service` 通过包名或 `app_code` 解析租户;所有业务库表按 `app_code` 隔离,设计细节见 `docs/多App租户架构.md`
@ -23,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=<UTC ms>&end_ms=<UTC ms>&country_id=<optional>
```
`start_ms`/`end_ms` 统一使用 `[start_ms, end_ms)`。返回值只来自聚合表;`ARPU = recharge_usd_minor / active_users``ARPPU = recharge_usd_minor / paid_users`,当前 `up_value_usd_minor` 与 ARPPU 同口径。产品若要改变 UP 值定义,必须先改字段名、契约和边界测试。
## Run Locally
Use Docker for local and test environments:
@ -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 等本地配置只能用开发值,线上必须走安全配置源。

View File

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

View File

@ -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);
}

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v5.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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,6 +28,7 @@ type Client interface {
UpsertLuckyGiftConfig(ctx context.Context, req *activityv1.UpsertLuckyGiftConfigRequest) (*activityv1.UpsertLuckyGiftConfigResponse, error)
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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 == "" {

View File

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

View File

@ -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(),

View File

@ -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 {

View File

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

View File

@ -18,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{

View File

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

View File

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

View File

@ -224,8 +224,9 @@ func (s *Store) seedMenus() error {
{ParentID: &roomsID, Title: "房间配置", Code: "room-config", Path: "/rooms/config", Icon: "settings", PermissionCode: "room-config:view", Sort: 67, Visible: true},
{ParentID: &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},

View File

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

View File

@ -12,7 +12,7 @@ health_http_addr: ":13106"
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC"
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:

View File

@ -12,7 +12,7 @@ health_http_addr: ":13106"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC"
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:

View File

@ -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 {

View File

@ -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
}

View File

@ -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
}

View File

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

View File

@ -594,30 +594,7 @@ func (r *Repository) ListLuckyGiftDraws(ctx context.Context, query domain.DrawQu
if r == nil || r.db == nil {
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
}

View File

@ -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 {

View File

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

View File

@ -129,6 +129,21 @@ func (s *AdminLuckyGiftServer) ListLuckyGiftDraws(ctx context.Context, req *acti
return resp, nil
}
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,
}
}

View File

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

View File

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

View File

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

View File

@ -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 {

View File

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

View File

@ -67,11 +67,12 @@ func (h *Handler) getAppBootstrap(writer http.ResponseWriter, request *http.Requ
{Key: "me", Title: "我的", Enabled: true},
},
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 {

View File

@ -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)
}

View File

@ -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)

View File

@ -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)

View File

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