Compare commits
20 Commits
36da9df0c7
...
ad4a755998
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad4a755998 | ||
|
|
245412ae95 | ||
|
|
5000076365 | ||
|
|
9040b4b161 | ||
|
|
ccd1967242 | ||
|
|
1b3cf31a7d | ||
|
|
0bfe1cf57d | ||
|
|
597b1cf587 | ||
|
|
2df7d8b59e | ||
|
|
0824c58d4f | ||
|
|
17d777610c | ||
|
|
d1a7facf10 | ||
|
|
33ddebd8de | ||
|
|
a5a53b390f | ||
|
|
4af7844d1b | ||
|
|
8dc76890a6 | ||
|
|
5e0e02cd2a | ||
|
|
9d4dd79a86 | ||
|
|
21dd835c75 | ||
|
|
61f4e31b6a |
18
AGENTS.md
18
AGENTS.md
@ -21,14 +21,31 @@
|
||||
- `notice-service` 负责从各 owner service 的 outbox/事件源读取事实并投递私有通知、腾讯云 IM 单聊、后续 push/inbox;钱包、活动和房间不能直接耦合私有通知投递实现。
|
||||
- `user-service` 负责用户主数据,不应该成为房间高频流程的同步中心。
|
||||
- `cron-service` 只负责后台调度、任务运行记录和通过内部 gRPC 触发 owner service 批处理;不能直接拥有房间、账务、用户或活动业务状态。
|
||||
- `statistics-service` 只负责消费 owner service 已发布到 RocketMQ 的业务事实并写本服务聚合表;它不是业务事实 owner,不能直连 room/wallet/user/game 等 owner 数据库补数或查询明细。
|
||||
- `server/admin` 是后台管理服务独立 module,负责管理员、RBAC、菜单、审计和后台 HTTP API;访问 app 后端只能通过 `hyapp.local/api` 生成的 protobuf client 或明确的集成接口。
|
||||
- `api` 是独立 Go module,module path 为 `hyapp.local/api`,只放 protobuf contract 和生成代码,不放服务实现或业务工具包。
|
||||
- `wallet_outbox`、`room_outbox`、`user_outbox` 和 `game_outbox` 只能由各自 owner service 直接读取并发布到 RocketMQ;其他服务不能配置 owner 数据库 DSN 或直连 owner 数据库扫描 outbox。
|
||||
- 下游服务消费 owner outbox 必须使用自己的 RocketMQ consumer group 和本服务幂等表;消费位点属于消息系统和本地幂等事实,不能共享 owner service 的 outbox 状态字段。
|
||||
- 数据大屏查询入口在 `server/admin`,由 admin 调 `statistics-service` 内部查询 API;`gateway-service` 是 App HTTP 入口,除非有明确 App 产品需求,否则不要把后台统计大屏暴露到 gateway。
|
||||
|
||||
## Statistics Rules
|
||||
|
||||
- 硬性规则:统计查询不能查全表,不能回查 owner service 明细表,不能在 HTTP handler 临时扫业务流水;所有统计展示一律查询 `statistics-service` 的聚合表。
|
||||
- `statistics-service` 的事实输入只来自 RocketMQ:`WalletRechargeRecorded`、`GameOrderSettled`、`RoomGiftSent`、`RoomUserJoined`、`UserRegistered` 等事件必须先由 owner service outbox 持久化,再由 owner service 发布。
|
||||
- 充值口径以 `WalletRechargeRecorded` 为准;渠道分桶只能使用事件里的 `recharge_type`,当前缺渠道时归入币商充值,不能从支付表或用户资料反推。
|
||||
- 游戏口径以 `GameOrderSettled` 为准;`debit/bet` 计投注流水,`credit/payout` 计返奖,`refund/reverse` 计退款,利润按 `投注 - 返奖 - 退款` 计算。
|
||||
- 活跃口径以事实事件去重用户:进房来自 `RoomUserJoined`,游戏活跃来自 `GameOrderSettled`,统计服务用本地唯一键收敛日活,不能依赖 Redis presence。
|
||||
- 留存口径以 `UserRegistered` cohort 和 `stat_user_day_activity` 活跃表计算;注册、活跃、充值、游戏和礼物事件都按 UTC `stat_day` 聚合。
|
||||
- ARPU 直接由充值聚合除以活跃用户;ARPPU/UP 值直接由充值聚合除以付费用户。产品若要调整 UP 值定义,必须先改明确字段名和边界测试。
|
||||
- 国家维度只能来自事件字段,例如用户注册 region、房间 `visible_region_id`、充值目标区域或游戏事件区域;不能用服务所在地、IP 或本地时区推断。
|
||||
|
||||
## Persistence Rules
|
||||
|
||||
- `room-service` 生产路径使用 MySQL repository 和 Redis directory。
|
||||
- `memory` repository/directory 只用于单元测试或明确的本地隔离测试。
|
||||
- MySQL 是房间恢复的持久来源:`rooms`、`room_snapshots`、`room_command_log`、`room_outbox`。
|
||||
- `wallet-service` 的 `wallet_outbox` 是账务事实的持久来源;它负责补偿发布到 RocketMQ,下游不得把钱包库当作事件查询库。
|
||||
- `statistics-service` 的 MySQL 聚合表是数据大屏唯一查询来源:`statistics_event_consumption` 记录幂等事实,`stat_app_day_country`、`stat_game_day_country`、`stat_user_day_activity`、`stat_user_registration`、`stat_recharge_day_payers` 和 `stat_lucky_gift_day_payers` 保存统计读模型。
|
||||
- Redis 只保存当前路由/lease 等热状态,不能作为唯一恢复来源。
|
||||
- Redis lease 更新必须保持原子性,不能改回非原子的 GET + SET 接管逻辑。
|
||||
|
||||
@ -65,6 +82,7 @@
|
||||
- `13007`: cron gRPC
|
||||
- `13008`: game gRPC
|
||||
- `13009`: notice gRPC
|
||||
- `13010`: statistics query HTTP
|
||||
- `23306`: local Docker MySQL host port
|
||||
- `13379`: local Docker Redis host port
|
||||
|
||||
|
||||
23
Makefile
23
Makefile
@ -1,10 +1,13 @@
|
||||
COMPOSE_SERVICES := mysql redis gateway-service room-service wallet-service user-service activity-service cron-service game-service notice-service
|
||||
SERVICE_ALIASES := gs gateway rs room ws wallet us user as activity cs cron game games ns notice mysql redis gateway-service room-service wallet-service user-service activity-service cron-service game-service notice-service
|
||||
COMPOSE_SERVICES := mysql redis rocketmq-namesrv rocketmq-broker gateway-service room-service wallet-service user-service activity-service cron-service game-service notice-service statistics-service
|
||||
GO_RUN_SERVICES := gateway-service room-service wallet-service user-service activity-service cron-service game-service notice-service statistics-service
|
||||
SERVICE_ALIASES := gs gateway rs room ws wallet us user as activity cs cron game games ns notice stats statistics mq namesrv broker mysql redis rocketmq-namesrv rocketmq-broker gateway-service room-service wallet-service user-service activity-service cron-service game-service notice-service statistics-service
|
||||
SERVICE_TOKEN := $(firstword $(filter $(SERVICE_ALIASES),$(MAKECMDGOALS)))
|
||||
SERVICE_ID := $(strip $(or $(SERVICE),$(if $(filter gs gateway,$(SERVICE_TOKEN)),gateway-service,$(if $(filter rs room,$(SERVICE_TOKEN)),room-service,$(if $(filter ws wallet,$(SERVICE_TOKEN)),wallet-service,$(if $(filter us user,$(SERVICE_TOKEN)),user-service,$(if $(filter as activity,$(SERVICE_TOKEN)),activity-service,$(if $(filter cs cron,$(SERVICE_TOKEN)),cron-service,$(if $(filter game games,$(SERVICE_TOKEN)),game-service,$(if $(filter ns notice,$(SERVICE_TOKEN)),notice-service,$(SERVICE_TOKEN)))))))))))
|
||||
SERVICE_ID := $(strip $(or $(SERVICE),$(if $(filter gs gateway,$(SERVICE_TOKEN)),gateway-service,$(if $(filter rs room,$(SERVICE_TOKEN)),room-service,$(if $(filter ws wallet,$(SERVICE_TOKEN)),wallet-service,$(if $(filter us user,$(SERVICE_TOKEN)),user-service,$(if $(filter as activity,$(SERVICE_TOKEN)),activity-service,$(if $(filter cs cron,$(SERVICE_TOKEN)),cron-service,$(if $(filter game games,$(SERVICE_TOKEN)),game-service,$(if $(filter ns notice,$(SERVICE_TOKEN)),notice-service,$(if $(filter stats statistics,$(SERVICE_TOKEN)),statistics-service,$(if $(filter namesrv,$(SERVICE_TOKEN)),rocketmq-namesrv,$(if $(filter mq broker,$(SERVICE_TOKEN)),rocketmq-broker,$(SERVICE_TOKEN))))))))))))))
|
||||
ADMIN_CONFIG ?= configs/config.yaml
|
||||
DEV_RUN_SERVICES ?= all
|
||||
DEV_RUN_SERVICE_LIST := $(strip $(if $(filter mysql redis,$(SERVICE_ID)),,$(if $(SERVICE_ID),$(SERVICE_ID),$(DEV_RUN_SERVICES))))
|
||||
|
||||
.PHONY: proto vet test build admin admin-up admin-deps admin-bootstrap admin-test admin-build up db-init rebuild restart stop logs ps addr down up-service check-service resolve-compose-conflicts $(SERVICE_ALIASES)
|
||||
.PHONY: proto vet test build run admin admin-up admin-deps admin-bootstrap admin-test admin-build up db-init rebuild restart stop logs ps addr down up-service check-service resolve-compose-conflicts $(SERVICE_ALIASES)
|
||||
|
||||
# `proto` 负责把独立 api module 内的 .proto 重新生成成 Go 代码。
|
||||
proto:
|
||||
@ -40,6 +43,16 @@ build:
|
||||
go build ./...
|
||||
go build ./server/admin/...
|
||||
|
||||
# `run` 拉起 MySQL/Redis 后在本机用 go run 启动所有 Go 服务,并在源码或配置变更时热重启。
|
||||
# 可用 `make run rs`、`make run SERVICE=room-service` 或 `make run DEV_RUN_SERVICES=user-service,gateway-service` 只跑子集。
|
||||
run:
|
||||
./scripts/resolve-compose-container-conflicts.sh
|
||||
docker compose up -d mysql redis
|
||||
./scripts/apply-local-mysql-initdb.sh
|
||||
docker compose stop $(GO_RUN_SERVICES) >/dev/null 2>&1 || true
|
||||
./scripts/print-compose-addresses.sh
|
||||
TZ=UTC go run ./cmd/dev-run -services "$(DEV_RUN_SERVICE_LIST)"
|
||||
|
||||
# `admin` 在本机直接启动后台管理后端;依赖默认从 server/admin/configs/config.yaml 读取。
|
||||
# 启动前自动杀掉占用 13100 端口的旧进程,避免 bind: address already in use。
|
||||
ADMIN_PORT ?= 13100
|
||||
@ -78,7 +91,7 @@ up:
|
||||
@if [ -z "$(SERVICE_ID)" ]; then \
|
||||
docker compose up -d mysql redis; \
|
||||
./scripts/apply-local-mysql-initdb.sh; \
|
||||
docker compose up --build -d gateway-service room-service wallet-service user-service activity-service cron-service game-service notice-service; \
|
||||
docker compose up --build -d gateway-service room-service wallet-service user-service activity-service cron-service game-service notice-service statistics-service; \
|
||||
elif [ "$(SERVICE_ID)" = "mysql" ] || [ "$(SERVICE_ID)" = "redis" ]; then \
|
||||
docker compose up -d $(SERVICE_ID); \
|
||||
else \
|
||||
|
||||
63
README.md
63
README.md
@ -15,6 +15,7 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
|
||||
- `activity-service` 当前承接每日任务、活动/系统消息基础能力和房间事件 consumer;可通过 room outbox direct gRPC 或 RocketMQ `hyapp_room_outbox` 独立 consumer group 消费房间事实。
|
||||
- `cron-service` 当前负责后台调度、任务运行记录,以及通过内部 gRPC 触发 owner service 批处理;它不直接拥有房间、账务、用户或活动业务状态。
|
||||
- `notice-service` 当前消费 `wallet_outbox` 的余额变更事件和 `room_outbox` 的踢人事件,异步投递腾讯云 IM 单聊私有通知;房间群消息和群成员控制仍由 `room-service` 的房间 IM bridge 处理,MQ 模式下通过独立 consumer group 解耦。
|
||||
- `statistics-service` 当前消费 user/wallet/room/game outbox 的 RocketMQ 事实,写入本服务 MySQL 聚合表,并通过内部 HTTP 查询 API 支撑后台数据大屏;它不拥有注册、账务、房间或游戏业务事实。
|
||||
- 腾讯云 IM 承担客户端长连接、群消息、公屏、单聊、离线/漫游和全球接入能力;本仓库不再部署 `im-service`。
|
||||
- 多 App 共用同一套后端服务,入口由 `gateway-service` 通过包名或 `app_code` 解析租户;所有业务库表按 `app_code` 隔离,设计细节见 `docs/多App租户架构.md`。
|
||||
|
||||
@ -23,7 +24,10 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
|
||||
- 客户端命令面:`gateway-service` 暴露 HTTP JSON。
|
||||
- 客户端实时面:腾讯云 IM SDK,客户端用 gateway 签发的 UserSig 登录。
|
||||
- 服务内部调用:gRPC + protobuf,契约在独立 Go module `api`,业务代码通过 `hyapp.local/api/proto/...` 引用生成包。
|
||||
- 房间外事件:protobuf outbox 先落 MySQL;生产可由 RocketMQ fanout 到 activity、notice、IM bridge 和后续 push/inbox,消费端按 `event_id` 幂等。
|
||||
- 房间外事件:protobuf/JSON outbox 先落 MySQL;生产可由 RocketMQ fanout 到 activity、notice、IM bridge、statistics 和后续 push/inbox,消费端按 `event_id` 幂等。
|
||||
- outbox 读取权只属于 owner service。`room-service` 读取 `room_outbox`、`wallet-service` 读取 `wallet_outbox`、`user-service` 读取 `user_outbox`、`game-service` 读取 `game_outbox` 并发布 RocketMQ;其他服务不能直连 owner 数据库扫描 outbox。
|
||||
- 下游消费 owner outbox 必须使用本服务独立 RocketMQ consumer group,并在本服务库内用幂等表或唯一键记录已消费 `event_id`;消费位点不能写回或复用 owner service 的 outbox 状态。
|
||||
- 后台统计查询面:`server/admin` 调 `statistics-service` 内部 HTTP API;`gateway-service` 是 App 入口,除非有明确 App 产品需求,否则不暴露后台数据大屏统计。
|
||||
|
||||
## Time Model
|
||||
|
||||
@ -49,9 +53,40 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
|
||||
| `cron-service` | `13007` | gRPC |
|
||||
| `game-service` | `13008` | gRPC |
|
||||
| `notice-service` | `13009` | gRPC |
|
||||
| `statistics-service` | `13010` | internal query HTTP |
|
||||
| `statistics-service` | `13110` | health HTTP |
|
||||
| MySQL | `23306 -> 3306` | local Docker only |
|
||||
| Redis | `13379 -> 6379` | local Docker only |
|
||||
|
||||
## Statistics
|
||||
|
||||
统计系统的硬边界是“不查全表,不回查 owner 明细库”。数据大屏只能查询 `statistics-service` 的聚合表;任何新指标都必须先定义事件事实、聚合表字段和 UTC 时间范围,再接入查询接口。
|
||||
|
||||
事实来源:
|
||||
|
||||
- `user-service`: `UserRegistered`,用于新增用户和留存 cohort。
|
||||
- `wallet-service`: `WalletRechargeRecorded`,用于总充值、首充、付费用户、币商/MifaPay/Google 渠道分桶、ARPU、ARPPU 和 UP 值。
|
||||
- `room-service`: `RoomUserJoined` 用于进房活跃,`RoomGiftSent` 用于礼物消耗、幸运礼物流水和幸运礼物付费用户;国家维度来自事件中的 `visible_region_id`。
|
||||
- `game-service`: `GameOrderSettled`,`debit/bet` 计投注流水,`credit/payout` 计返奖,`refund/reverse` 计退款;游戏利润为 `投注 - 返奖 - 退款`。
|
||||
|
||||
聚合读模型:
|
||||
|
||||
- `statistics_event_consumption`: 本服务消费幂等表,按 `app_code + source + event_id` 去重。
|
||||
- `stat_app_day_country`: App/国家/UTC 日总览,保存新增、活跃、付费、充值渠道、礼物、幸运礼物、游戏总览。
|
||||
- `stat_user_day_activity`: 用户 UTC 日活跃事实,用于 DAU 和留存 join。
|
||||
- `stat_user_registration`: 注册 cohort,用于次留、7 留和 30 留。
|
||||
- `stat_recharge_day_payers`: 充值付费用户去重,用于付费人数、ARPPU 和 UP 值。
|
||||
- `stat_lucky_gift_day_payers`: 幸运礼物付费用户去重。
|
||||
- `stat_game_day_country` / `stat_game_day_players`: 游戏维度流水、返奖、退款、玩家数和排行。
|
||||
|
||||
查询契约:
|
||||
|
||||
```text
|
||||
GET /internal/v1/statistics/overview?app_code=lalu&start_ms=<UTC ms>&end_ms=<UTC ms>&country_id=<optional>
|
||||
```
|
||||
|
||||
`start_ms`/`end_ms` 统一使用 `[start_ms, end_ms)`。返回值只来自聚合表;`ARPU = recharge_usd_minor / active_users`,`ARPPU = recharge_usd_minor / paid_users`,当前 `up_value_usd_minor` 与 ARPPU 同口径。产品若要改变 UP 值定义,必须先改字段名、契约和边界测试。
|
||||
|
||||
## Run Locally
|
||||
|
||||
Use Docker for local and test environments:
|
||||
@ -132,7 +167,15 @@ docs/flutter对接/Flutter App 通知与钱包余额对接.md
|
||||
|
||||
## RocketMQ
|
||||
|
||||
本地默认不依赖 RocketMQ,`room-service` 使用 `outbox_worker.publish_mode=direct` 直连腾讯云 IM publisher 和 activity gRPC publisher。线上推荐启用腾讯云 RocketMQ,并把 `room-service` 配成 `publish_mode=mq`,让 room outbox 只发布到消息总线,下游服务各自 consumer group 消费。
|
||||
本地默认不依赖 RocketMQ,`room-service` 可使用 `outbox_worker.publish_mode=direct` 直连腾讯云 IM publisher 和 activity gRPC publisher。线上推荐启用腾讯云 RocketMQ,并把 owner outbox 统一发布到消息总线,下游服务各自 consumer group 消费。
|
||||
|
||||
规则:
|
||||
|
||||
- `room_outbox` 只由 `room-service` 读取和发布;`wallet_outbox` 只由 `wallet-service` 读取和发布;`user_outbox` 只由 `user-service` 读取和发布;`game_outbox` 只由 `game-service` 读取和发布。
|
||||
- `statistics-service`、`activity-service`、`notice-service`、`cron-service` 等非 owner service 不能配置 owner 数据库 DSN 或扫描其他服务 outbox 表。
|
||||
- 每个业务消费者使用独立 consumer group。即使消费同一个 topic,不同业务也不能共用 group,否则会互相抢消息。
|
||||
- 每个消费者在自己的库内维护幂等事实,例如按 `app_code + event_id` 或业务唯一键建唯一约束;RocketMQ 的消费位点只解决投递进度,不替代业务幂等。
|
||||
- owner service 的 MySQL outbox 仍是可靠事实源。RocketMQ 发布失败由 owner outbox worker 补偿重试;下游处理失败由各自 consumer group 重投和本地幂等表收敛。
|
||||
|
||||
当前 topic 和 consumer group:
|
||||
|
||||
@ -141,9 +184,18 @@ docs/flutter对接/Flutter App 通知与钱包余额对接.md
|
||||
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-activity-room-outbox` | activity-service 消费 `RoomGiftSent`、`RoomTreasureCountdownStarted` 等房间事实 |
|
||||
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-notice-room-outbox` | notice-service 消费 `RoomUserKicked` 等私有通知事实 |
|
||||
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-room-im-bridge` | room-service IM bridge 消费房间群系统消息和群成员控制事实 |
|
||||
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-user-mictime-room-outbox` | user-service 消费 `RoomMicChanged` 并更新用户麦时读模型 |
|
||||
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-statistics-room-outbox` | statistics-service 消费 `RoomGiftSent`、`RoomUserJoined` 并更新聚合表 |
|
||||
| `hyapp_user_outbox` | `hyapp-user-outbox-producer` | `hyapp-statistics-user-outbox` | statistics-service 消费 `UserRegistered` 并更新注册 cohort |
|
||||
| `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-user-invite-wallet-outbox` | user-service 消费 `WalletRechargeRecorded` 并更新邀请有效充值 |
|
||||
| `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-activity-first-recharge-wallet-outbox` | activity-service 消费 `WalletRechargeRecorded` 并处理首充奖励 |
|
||||
| `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-activity-red-packet-wallet-outbox` | activity-service 消费 `WalletRedPacketCreated` 并生成区域飘屏 outbox |
|
||||
| `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-notice-wallet-outbox` | notice-service 消费余额、账务等私有通知事实 |
|
||||
| `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-statistics-wallet-outbox` | statistics-service 消费 `WalletRechargeRecorded` 并更新充值聚合 |
|
||||
| `hyapp_game_outbox` | `hyapp-game-outbox-producer` | `hyapp-statistics-game-outbox` | statistics-service 消费 `GameOrderSettled` 并更新游戏聚合 |
|
||||
| `hyapp_room_treasure_open` | `hyapp-room-treasure-open-producer` | `hyapp-room-treasure-open` | 宝箱倒计时到点唤醒 room-service 开箱命令 |
|
||||
|
||||
RocketMQ 不拥有房间状态。`room_outbox` 仍是 MySQL 内的可靠事实源;MQ 发布失败时 outbox worker 退避重试,到达重试上限后进入死信。宝箱延迟消息只负责唤醒,到点后 room-service 会重新校验 `box_id`、等级、`open_at_ms`、状态和 UTC 重置边界。
|
||||
RocketMQ 不拥有业务状态。`room_outbox`、`wallet_outbox`、`user_outbox`、`game_outbox` 仍是各 owner service MySQL 内的可靠事实源;MQ 发布失败时 outbox worker 退避重试,到达重试上限后进入死信。宝箱延迟消息只负责唤醒,到点后 room-service 会重新校验 `box_id`、等级、`open_at_ms`、状态和 UTC 重置边界。
|
||||
|
||||
## Storage Model
|
||||
|
||||
@ -156,6 +208,7 @@ MySQL 是本地和线上运行的持久化底座:
|
||||
- `cron-service`: `hyapp_cron`,保存后台任务定义、运行记录和调度锁;实际业务批处理仍由各 owner service 执行。
|
||||
- `game-service`: `hyapp_game`,保存游戏平台、房间游戏会话和游戏交易映射。
|
||||
- `notice-service`: `hyapp_notice`,保存外部通知投递位点、重试锁和死信状态;业务事实仍留在 owner service outbox。
|
||||
- `statistics-service`: `hyapp_statistics`,保存事件消费幂等和数据大屏聚合读模型;禁止把 owner service 明细表当作统计查询来源。
|
||||
- `hyapp_admin`: 后台管理后端独立库,只保存后台账号、权限和审计;不要把后台审计耦合进 App 业务库。
|
||||
|
||||
`room-service` uses MySQL as the room recovery durable source:
|
||||
@ -205,6 +258,7 @@ services/activity-service/ activity foundation and room event consumer boundary
|
||||
services/cron-service/ background scheduling and owner-service batch trigger
|
||||
services/game-service/ game session and wallet integration
|
||||
services/notice-service/ async user notice delivery workers
|
||||
services/statistics-service/ data dashboard event consumers and aggregate query API
|
||||
```
|
||||
|
||||
## Current Limits
|
||||
@ -212,7 +266,8 @@ services/notice-service/ async user notice delivery workers
|
||||
- 腾讯云 IM 回调入口已经落在 gateway;线上仍必须在腾讯云 IM 控制台启用 `Group.CallbackBeforeApplyJoinGroup` 和 `Group.CallbackBeforeSendMsg`,并配置公网 callback URL 与鉴权 token。
|
||||
- 前端对同一次用户写动作的 HTTP 重试必须复用同一个 `command_id`;`X-Request-ID` 不需要也不应该由前端传入。
|
||||
- `wallet-service` App 运行路径使用 MySQL 扣费事务;本地需要先通过 compose initdb 建好 `hyapp_wallet`。
|
||||
- `notice-service` 本地默认不启用腾讯云 IM worker;线上启用钱包通知、房间通知 MySQL worker 或 RocketMQ room outbox consumer 时,必须同时启用 `tencent_im.enabled=true`。
|
||||
- `notice-service` 本地默认不启用腾讯云 IM worker;线上启用钱包通知或房间通知 RocketMQ consumer 时,必须同时启用 `tencent_im.enabled=true`,且不能直连 wallet/room 数据库扫描 outbox。
|
||||
- `activity-service` 已承接每日任务、消息/活动基础能力和 room outbox consumer;本地可走 direct gRPC,线上推荐走 RocketMQ `hyapp_room_outbox`。
|
||||
- `room-service` App 已启动 `room_outbox` 补偿 worker;`direct` 模式表示直连 publisher 完成,`mq` 模式表示事件已发布到 RocketMQ,下游成功由各服务消费位点保证。
|
||||
- `statistics-service` 只保证从已发布 MQ 事件生成聚合表;如果历史库没有对应 outbox 事实,例如早期用户没有 `UserRegistered`、本地没有 `game_outbox`,统计结果会按当前事实为 0,不允许临时扫业务表补数。
|
||||
- JWT、腾讯云 IM secret 等本地配置只能用开发值,线上必须走安全配置源。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -531,6 +531,122 @@ message ListRegistrationRewardClaimsResponse {
|
||||
int64 total = 2;
|
||||
}
|
||||
|
||||
// FirstRechargeRewardTier 是首冲奖励的一个金币充值档位;max_coin_amount=0 表示无上限。
|
||||
message FirstRechargeRewardTier {
|
||||
int64 tier_id = 1;
|
||||
string tier_code = 2;
|
||||
string tier_name = 3;
|
||||
int64 min_coin_amount = 4;
|
||||
int64 max_coin_amount = 5;
|
||||
int64 resource_group_id = 6;
|
||||
string status = 7;
|
||||
int32 sort_order = 8;
|
||||
int64 created_at_ms = 9;
|
||||
int64 updated_at_ms = 10;
|
||||
}
|
||||
|
||||
// FirstRechargeRewardConfig 是当前 App 的首冲奖励配置;档位内容在 activity-service 内保持版本一致。
|
||||
message FirstRechargeRewardConfig {
|
||||
string app_code = 1;
|
||||
bool enabled = 2;
|
||||
repeated FirstRechargeRewardTier tiers = 3;
|
||||
int64 updated_by_admin_id = 4;
|
||||
int64 created_at_ms = 5;
|
||||
int64 updated_at_ms = 6;
|
||||
}
|
||||
|
||||
// FirstRechargeRewardClaim 是某个用户首笔充值触发后的发奖事实;user_id 唯一保证一生只处理一次。
|
||||
message FirstRechargeRewardClaim {
|
||||
string claim_id = 1;
|
||||
string event_id = 2;
|
||||
string transaction_id = 3;
|
||||
string command_id = 4;
|
||||
int64 user_id = 5;
|
||||
int64 tier_id = 6;
|
||||
string tier_code = 7;
|
||||
int64 resource_group_id = 8;
|
||||
int64 recharge_coin_amount = 9;
|
||||
int64 recharge_sequence = 10;
|
||||
string recharge_type = 11;
|
||||
string status = 12;
|
||||
string wallet_command_id = 13;
|
||||
string wallet_grant_id = 14;
|
||||
string failure_reason = 15;
|
||||
int64 recharged_at_ms = 16;
|
||||
int64 granted_at_ms = 17;
|
||||
int64 created_at_ms = 18;
|
||||
int64 updated_at_ms = 19;
|
||||
}
|
||||
|
||||
// FirstRechargeRewardStatus 是 App 首冲奖励面板可直接展示的当前状态。
|
||||
message FirstRechargeRewardStatus {
|
||||
bool enabled = 1;
|
||||
repeated FirstRechargeRewardTier tiers = 2;
|
||||
bool rewarded = 3;
|
||||
FirstRechargeRewardClaim claim = 4;
|
||||
int64 server_time_ms = 5;
|
||||
}
|
||||
|
||||
message GetFirstRechargeRewardStatusRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 user_id = 2;
|
||||
}
|
||||
|
||||
message GetFirstRechargeRewardStatusResponse {
|
||||
FirstRechargeRewardStatus status = 1;
|
||||
}
|
||||
|
||||
// ConsumeFirstRechargeRewardRequest 是 wallet 充值事实消费入口;只处理 recharge_sequence=1 的成功充值。
|
||||
message ConsumeFirstRechargeRewardRequest {
|
||||
RequestMeta meta = 1;
|
||||
string event_id = 2;
|
||||
string transaction_id = 3;
|
||||
string command_id = 4;
|
||||
int64 user_id = 5;
|
||||
int64 recharge_coin_amount = 6;
|
||||
int64 recharge_sequence = 7;
|
||||
string recharge_type = 8;
|
||||
int64 occurred_at_ms = 9;
|
||||
}
|
||||
|
||||
message ConsumeFirstRechargeRewardResponse {
|
||||
FirstRechargeRewardClaim claim = 1;
|
||||
bool consumed = 2;
|
||||
string reason = 3;
|
||||
}
|
||||
|
||||
message GetFirstRechargeRewardConfigRequest {
|
||||
RequestMeta meta = 1;
|
||||
}
|
||||
|
||||
message GetFirstRechargeRewardConfigResponse {
|
||||
FirstRechargeRewardConfig config = 1;
|
||||
}
|
||||
|
||||
message UpdateFirstRechargeRewardConfigRequest {
|
||||
RequestMeta meta = 1;
|
||||
bool enabled = 2;
|
||||
repeated FirstRechargeRewardTier tiers = 3;
|
||||
int64 operator_admin_id = 4;
|
||||
}
|
||||
|
||||
message UpdateFirstRechargeRewardConfigResponse {
|
||||
FirstRechargeRewardConfig config = 1;
|
||||
}
|
||||
|
||||
message ListFirstRechargeRewardClaimsRequest {
|
||||
RequestMeta meta = 1;
|
||||
string status = 2;
|
||||
int64 user_id = 3;
|
||||
int32 page = 4;
|
||||
int32 page_size = 5;
|
||||
}
|
||||
|
||||
message ListFirstRechargeRewardClaimsResponse {
|
||||
repeated FirstRechargeRewardClaim claims = 1;
|
||||
int64 total = 2;
|
||||
}
|
||||
|
||||
// SevenDayCheckInReward 是七日签到配置中某一天的资源组奖励。
|
||||
message SevenDayCheckInReward {
|
||||
int32 day_index = 1;
|
||||
@ -1006,6 +1122,7 @@ message LuckyGiftMeta {
|
||||
string gift_id = 7;
|
||||
int64 coin_spent = 8;
|
||||
int64 paid_at_ms = 9;
|
||||
string pool_id = 10;
|
||||
}
|
||||
|
||||
message LuckyGiftTier {
|
||||
@ -1015,6 +1132,7 @@ message LuckyGiftTier {
|
||||
int64 weight = 4;
|
||||
bool high_water_only = 5;
|
||||
bool enabled = 6;
|
||||
int64 multiplier_ppm = 7;
|
||||
}
|
||||
|
||||
message LuckyGiftConfig {
|
||||
@ -1056,6 +1174,8 @@ message LuckyGiftConfig {
|
||||
int64 updated_by_admin_id = 36;
|
||||
int64 created_at_ms = 37;
|
||||
int64 updated_at_ms = 38;
|
||||
string pool_id = 39;
|
||||
repeated int64 multiplier_ppms = 40;
|
||||
}
|
||||
|
||||
message CheckLuckyGiftRequest {
|
||||
@ -1063,6 +1183,7 @@ message CheckLuckyGiftRequest {
|
||||
int64 user_id = 2;
|
||||
string room_id = 3;
|
||||
string gift_id = 4;
|
||||
string pool_id = 5;
|
||||
}
|
||||
|
||||
message CheckLuckyGiftResponse {
|
||||
@ -1073,6 +1194,7 @@ message CheckLuckyGiftResponse {
|
||||
int64 rule_version = 5;
|
||||
int64 target_rtp_ppm = 6;
|
||||
string experience_pool = 7;
|
||||
string pool_id = 8;
|
||||
}
|
||||
|
||||
message LuckyGiftDrawResult {
|
||||
@ -1095,6 +1217,7 @@ message LuckyGiftDrawResult {
|
||||
bool stage_feedback = 17;
|
||||
bool high_multiplier = 18;
|
||||
int64 created_at_ms = 19;
|
||||
string pool_id = 20;
|
||||
}
|
||||
|
||||
message ExecuteLuckyGiftDrawRequest {
|
||||
@ -1108,6 +1231,7 @@ message ExecuteLuckyGiftDrawResponse {
|
||||
message GetLuckyGiftConfigRequest {
|
||||
RequestMeta meta = 1;
|
||||
string gift_id = 2;
|
||||
string pool_id = 3;
|
||||
}
|
||||
|
||||
message GetLuckyGiftConfigResponse {
|
||||
@ -1124,6 +1248,14 @@ message UpsertLuckyGiftConfigResponse {
|
||||
LuckyGiftConfig config = 1;
|
||||
}
|
||||
|
||||
message ListLuckyGiftConfigsRequest {
|
||||
RequestMeta meta = 1;
|
||||
}
|
||||
|
||||
message ListLuckyGiftConfigsResponse {
|
||||
repeated LuckyGiftConfig configs = 1;
|
||||
}
|
||||
|
||||
message ListLuckyGiftDrawsRequest {
|
||||
RequestMeta meta = 1;
|
||||
string gift_id = 2;
|
||||
@ -1132,6 +1264,7 @@ message ListLuckyGiftDrawsRequest {
|
||||
string status = 5;
|
||||
int32 page = 6;
|
||||
int32 page_size = 7;
|
||||
string pool_id = 8;
|
||||
}
|
||||
|
||||
message ListLuckyGiftDrawsResponse {
|
||||
@ -1139,6 +1272,35 @@ message ListLuckyGiftDrawsResponse {
|
||||
int64 total = 2;
|
||||
}
|
||||
|
||||
message LuckyGiftDrawSummary {
|
||||
string pool_id = 1;
|
||||
int64 total_draws = 2;
|
||||
int64 unique_users = 3;
|
||||
int64 unique_rooms = 4;
|
||||
int64 total_spent_coins = 5;
|
||||
int64 total_reward_coins = 6;
|
||||
int64 base_reward_coins = 7;
|
||||
int64 room_atmosphere_reward_coins = 8;
|
||||
int64 activity_subsidy_coins = 9;
|
||||
int64 actual_rtp_ppm = 10;
|
||||
int64 pending_draws = 11;
|
||||
int64 granted_draws = 12;
|
||||
int64 failed_draws = 13;
|
||||
}
|
||||
|
||||
message GetLuckyGiftDrawSummaryRequest {
|
||||
RequestMeta meta = 1;
|
||||
string gift_id = 2;
|
||||
int64 user_id = 3;
|
||||
string room_id = 4;
|
||||
string status = 5;
|
||||
string pool_id = 6;
|
||||
}
|
||||
|
||||
message GetLuckyGiftDrawSummaryResponse {
|
||||
LuckyGiftDrawSummary summary = 1;
|
||||
}
|
||||
|
||||
// ActivityService 只暴露同步查询,不把事件消费伪装成 RPC。
|
||||
service ActivityService {
|
||||
rpc PingActivity(PingActivityRequest) returns (PingActivityResponse);
|
||||
@ -1226,6 +1388,19 @@ service AdminRegistrationRewardService {
|
||||
rpc ListRegistrationRewardClaims(ListRegistrationRewardClaimsRequest) returns (ListRegistrationRewardClaimsResponse);
|
||||
}
|
||||
|
||||
// FirstRechargeRewardService 拥有 App 首冲奖励查询和 wallet 充值事实消费入口。
|
||||
service FirstRechargeRewardService {
|
||||
rpc GetFirstRechargeRewardStatus(GetFirstRechargeRewardStatusRequest) returns (GetFirstRechargeRewardStatusResponse);
|
||||
rpc ConsumeFirstRechargeReward(ConsumeFirstRechargeRewardRequest) returns (ConsumeFirstRechargeRewardResponse);
|
||||
}
|
||||
|
||||
// AdminFirstRechargeRewardService 是后台活动管理访问首冲奖励配置和发放记录的唯一入口。
|
||||
service AdminFirstRechargeRewardService {
|
||||
rpc GetFirstRechargeRewardConfig(GetFirstRechargeRewardConfigRequest) returns (GetFirstRechargeRewardConfigResponse);
|
||||
rpc UpdateFirstRechargeRewardConfig(UpdateFirstRechargeRewardConfigRequest) returns (UpdateFirstRechargeRewardConfigResponse);
|
||||
rpc ListFirstRechargeRewardClaims(ListFirstRechargeRewardClaimsRequest) returns (ListFirstRechargeRewardClaimsResponse);
|
||||
}
|
||||
|
||||
// SevenDayCheckInService 拥有 App 七日签到查询和签到命令。
|
||||
service SevenDayCheckInService {
|
||||
rpc GetSevenDayCheckInStatus(GetSevenDayCheckInStatusRequest) returns (GetSevenDayCheckInStatusResponse);
|
||||
@ -1257,5 +1432,7 @@ service AdminAchievementService {
|
||||
service AdminLuckyGiftService {
|
||||
rpc GetLuckyGiftConfig(GetLuckyGiftConfigRequest) returns (GetLuckyGiftConfigResponse);
|
||||
rpc UpsertLuckyGiftConfig(UpsertLuckyGiftConfigRequest) returns (UpsertLuckyGiftConfigResponse);
|
||||
rpc ListLuckyGiftConfigs(ListLuckyGiftConfigsRequest) returns (ListLuckyGiftConfigsResponse);
|
||||
rpc ListLuckyGiftDraws(ListLuckyGiftDrawsRequest) returns (ListLuckyGiftDrawsResponse);
|
||||
rpc GetLuckyGiftDrawSummary(GetLuckyGiftDrawSummaryRequest) returns (GetLuckyGiftDrawSummaryResponse);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - protoc v5.28.3
|
||||
// - protoc v7.35.0
|
||||
// source: proto/activity/v1/activity.proto
|
||||
|
||||
package activityv1
|
||||
@ -2319,6 +2319,334 @@ var AdminRegistrationRewardService_ServiceDesc = grpc.ServiceDesc{
|
||||
Metadata: "proto/activity/v1/activity.proto",
|
||||
}
|
||||
|
||||
const (
|
||||
FirstRechargeRewardService_GetFirstRechargeRewardStatus_FullMethodName = "/hyapp.activity.v1.FirstRechargeRewardService/GetFirstRechargeRewardStatus"
|
||||
FirstRechargeRewardService_ConsumeFirstRechargeReward_FullMethodName = "/hyapp.activity.v1.FirstRechargeRewardService/ConsumeFirstRechargeReward"
|
||||
)
|
||||
|
||||
// FirstRechargeRewardServiceClient is the client API for FirstRechargeRewardService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
//
|
||||
// FirstRechargeRewardService 拥有 App 首冲奖励查询和 wallet 充值事实消费入口。
|
||||
type FirstRechargeRewardServiceClient interface {
|
||||
GetFirstRechargeRewardStatus(ctx context.Context, in *GetFirstRechargeRewardStatusRequest, opts ...grpc.CallOption) (*GetFirstRechargeRewardStatusResponse, error)
|
||||
ConsumeFirstRechargeReward(ctx context.Context, in *ConsumeFirstRechargeRewardRequest, opts ...grpc.CallOption) (*ConsumeFirstRechargeRewardResponse, error)
|
||||
}
|
||||
|
||||
type firstRechargeRewardServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewFirstRechargeRewardServiceClient(cc grpc.ClientConnInterface) FirstRechargeRewardServiceClient {
|
||||
return &firstRechargeRewardServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *firstRechargeRewardServiceClient) GetFirstRechargeRewardStatus(ctx context.Context, in *GetFirstRechargeRewardStatusRequest, opts ...grpc.CallOption) (*GetFirstRechargeRewardStatusResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetFirstRechargeRewardStatusResponse)
|
||||
err := c.cc.Invoke(ctx, FirstRechargeRewardService_GetFirstRechargeRewardStatus_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *firstRechargeRewardServiceClient) ConsumeFirstRechargeReward(ctx context.Context, in *ConsumeFirstRechargeRewardRequest, opts ...grpc.CallOption) (*ConsumeFirstRechargeRewardResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ConsumeFirstRechargeRewardResponse)
|
||||
err := c.cc.Invoke(ctx, FirstRechargeRewardService_ConsumeFirstRechargeReward_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FirstRechargeRewardServiceServer is the server API for FirstRechargeRewardService service.
|
||||
// All implementations must embed UnimplementedFirstRechargeRewardServiceServer
|
||||
// for forward compatibility.
|
||||
//
|
||||
// FirstRechargeRewardService 拥有 App 首冲奖励查询和 wallet 充值事实消费入口。
|
||||
type FirstRechargeRewardServiceServer interface {
|
||||
GetFirstRechargeRewardStatus(context.Context, *GetFirstRechargeRewardStatusRequest) (*GetFirstRechargeRewardStatusResponse, error)
|
||||
ConsumeFirstRechargeReward(context.Context, *ConsumeFirstRechargeRewardRequest) (*ConsumeFirstRechargeRewardResponse, error)
|
||||
mustEmbedUnimplementedFirstRechargeRewardServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedFirstRechargeRewardServiceServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedFirstRechargeRewardServiceServer struct{}
|
||||
|
||||
func (UnimplementedFirstRechargeRewardServiceServer) GetFirstRechargeRewardStatus(context.Context, *GetFirstRechargeRewardStatusRequest) (*GetFirstRechargeRewardStatusResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetFirstRechargeRewardStatus not implemented")
|
||||
}
|
||||
func (UnimplementedFirstRechargeRewardServiceServer) ConsumeFirstRechargeReward(context.Context, *ConsumeFirstRechargeRewardRequest) (*ConsumeFirstRechargeRewardResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ConsumeFirstRechargeReward not implemented")
|
||||
}
|
||||
func (UnimplementedFirstRechargeRewardServiceServer) mustEmbedUnimplementedFirstRechargeRewardServiceServer() {
|
||||
}
|
||||
func (UnimplementedFirstRechargeRewardServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeFirstRechargeRewardServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to FirstRechargeRewardServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeFirstRechargeRewardServiceServer interface {
|
||||
mustEmbedUnimplementedFirstRechargeRewardServiceServer()
|
||||
}
|
||||
|
||||
func RegisterFirstRechargeRewardServiceServer(s grpc.ServiceRegistrar, srv FirstRechargeRewardServiceServer) {
|
||||
// If the following call panics, it indicates UnimplementedFirstRechargeRewardServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&FirstRechargeRewardService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _FirstRechargeRewardService_GetFirstRechargeRewardStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetFirstRechargeRewardStatusRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(FirstRechargeRewardServiceServer).GetFirstRechargeRewardStatus(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: FirstRechargeRewardService_GetFirstRechargeRewardStatus_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(FirstRechargeRewardServiceServer).GetFirstRechargeRewardStatus(ctx, req.(*GetFirstRechargeRewardStatusRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _FirstRechargeRewardService_ConsumeFirstRechargeReward_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ConsumeFirstRechargeRewardRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(FirstRechargeRewardServiceServer).ConsumeFirstRechargeReward(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: FirstRechargeRewardService_ConsumeFirstRechargeReward_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(FirstRechargeRewardServiceServer).ConsumeFirstRechargeReward(ctx, req.(*ConsumeFirstRechargeRewardRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// FirstRechargeRewardService_ServiceDesc is the grpc.ServiceDesc for FirstRechargeRewardService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var FirstRechargeRewardService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "hyapp.activity.v1.FirstRechargeRewardService",
|
||||
HandlerType: (*FirstRechargeRewardServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "GetFirstRechargeRewardStatus",
|
||||
Handler: _FirstRechargeRewardService_GetFirstRechargeRewardStatus_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ConsumeFirstRechargeReward",
|
||||
Handler: _FirstRechargeRewardService_ConsumeFirstRechargeReward_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "proto/activity/v1/activity.proto",
|
||||
}
|
||||
|
||||
const (
|
||||
AdminFirstRechargeRewardService_GetFirstRechargeRewardConfig_FullMethodName = "/hyapp.activity.v1.AdminFirstRechargeRewardService/GetFirstRechargeRewardConfig"
|
||||
AdminFirstRechargeRewardService_UpdateFirstRechargeRewardConfig_FullMethodName = "/hyapp.activity.v1.AdminFirstRechargeRewardService/UpdateFirstRechargeRewardConfig"
|
||||
AdminFirstRechargeRewardService_ListFirstRechargeRewardClaims_FullMethodName = "/hyapp.activity.v1.AdminFirstRechargeRewardService/ListFirstRechargeRewardClaims"
|
||||
)
|
||||
|
||||
// AdminFirstRechargeRewardServiceClient is the client API for AdminFirstRechargeRewardService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
//
|
||||
// AdminFirstRechargeRewardService 是后台活动管理访问首冲奖励配置和发放记录的唯一入口。
|
||||
type AdminFirstRechargeRewardServiceClient interface {
|
||||
GetFirstRechargeRewardConfig(ctx context.Context, in *GetFirstRechargeRewardConfigRequest, opts ...grpc.CallOption) (*GetFirstRechargeRewardConfigResponse, error)
|
||||
UpdateFirstRechargeRewardConfig(ctx context.Context, in *UpdateFirstRechargeRewardConfigRequest, opts ...grpc.CallOption) (*UpdateFirstRechargeRewardConfigResponse, error)
|
||||
ListFirstRechargeRewardClaims(ctx context.Context, in *ListFirstRechargeRewardClaimsRequest, opts ...grpc.CallOption) (*ListFirstRechargeRewardClaimsResponse, error)
|
||||
}
|
||||
|
||||
type adminFirstRechargeRewardServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewAdminFirstRechargeRewardServiceClient(cc grpc.ClientConnInterface) AdminFirstRechargeRewardServiceClient {
|
||||
return &adminFirstRechargeRewardServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *adminFirstRechargeRewardServiceClient) GetFirstRechargeRewardConfig(ctx context.Context, in *GetFirstRechargeRewardConfigRequest, opts ...grpc.CallOption) (*GetFirstRechargeRewardConfigResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetFirstRechargeRewardConfigResponse)
|
||||
err := c.cc.Invoke(ctx, AdminFirstRechargeRewardService_GetFirstRechargeRewardConfig_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *adminFirstRechargeRewardServiceClient) UpdateFirstRechargeRewardConfig(ctx context.Context, in *UpdateFirstRechargeRewardConfigRequest, opts ...grpc.CallOption) (*UpdateFirstRechargeRewardConfigResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(UpdateFirstRechargeRewardConfigResponse)
|
||||
err := c.cc.Invoke(ctx, AdminFirstRechargeRewardService_UpdateFirstRechargeRewardConfig_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *adminFirstRechargeRewardServiceClient) ListFirstRechargeRewardClaims(ctx context.Context, in *ListFirstRechargeRewardClaimsRequest, opts ...grpc.CallOption) (*ListFirstRechargeRewardClaimsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListFirstRechargeRewardClaimsResponse)
|
||||
err := c.cc.Invoke(ctx, AdminFirstRechargeRewardService_ListFirstRechargeRewardClaims_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AdminFirstRechargeRewardServiceServer is the server API for AdminFirstRechargeRewardService service.
|
||||
// All implementations must embed UnimplementedAdminFirstRechargeRewardServiceServer
|
||||
// for forward compatibility.
|
||||
//
|
||||
// AdminFirstRechargeRewardService 是后台活动管理访问首冲奖励配置和发放记录的唯一入口。
|
||||
type AdminFirstRechargeRewardServiceServer interface {
|
||||
GetFirstRechargeRewardConfig(context.Context, *GetFirstRechargeRewardConfigRequest) (*GetFirstRechargeRewardConfigResponse, error)
|
||||
UpdateFirstRechargeRewardConfig(context.Context, *UpdateFirstRechargeRewardConfigRequest) (*UpdateFirstRechargeRewardConfigResponse, error)
|
||||
ListFirstRechargeRewardClaims(context.Context, *ListFirstRechargeRewardClaimsRequest) (*ListFirstRechargeRewardClaimsResponse, error)
|
||||
mustEmbedUnimplementedAdminFirstRechargeRewardServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedAdminFirstRechargeRewardServiceServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedAdminFirstRechargeRewardServiceServer struct{}
|
||||
|
||||
func (UnimplementedAdminFirstRechargeRewardServiceServer) GetFirstRechargeRewardConfig(context.Context, *GetFirstRechargeRewardConfigRequest) (*GetFirstRechargeRewardConfigResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetFirstRechargeRewardConfig not implemented")
|
||||
}
|
||||
func (UnimplementedAdminFirstRechargeRewardServiceServer) UpdateFirstRechargeRewardConfig(context.Context, *UpdateFirstRechargeRewardConfigRequest) (*UpdateFirstRechargeRewardConfigResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method UpdateFirstRechargeRewardConfig not implemented")
|
||||
}
|
||||
func (UnimplementedAdminFirstRechargeRewardServiceServer) ListFirstRechargeRewardClaims(context.Context, *ListFirstRechargeRewardClaimsRequest) (*ListFirstRechargeRewardClaimsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListFirstRechargeRewardClaims not implemented")
|
||||
}
|
||||
func (UnimplementedAdminFirstRechargeRewardServiceServer) mustEmbedUnimplementedAdminFirstRechargeRewardServiceServer() {
|
||||
}
|
||||
func (UnimplementedAdminFirstRechargeRewardServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeAdminFirstRechargeRewardServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to AdminFirstRechargeRewardServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeAdminFirstRechargeRewardServiceServer interface {
|
||||
mustEmbedUnimplementedAdminFirstRechargeRewardServiceServer()
|
||||
}
|
||||
|
||||
func RegisterAdminFirstRechargeRewardServiceServer(s grpc.ServiceRegistrar, srv AdminFirstRechargeRewardServiceServer) {
|
||||
// If the following call panics, it indicates UnimplementedAdminFirstRechargeRewardServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&AdminFirstRechargeRewardService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _AdminFirstRechargeRewardService_GetFirstRechargeRewardConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetFirstRechargeRewardConfigRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AdminFirstRechargeRewardServiceServer).GetFirstRechargeRewardConfig(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AdminFirstRechargeRewardService_GetFirstRechargeRewardConfig_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AdminFirstRechargeRewardServiceServer).GetFirstRechargeRewardConfig(ctx, req.(*GetFirstRechargeRewardConfigRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AdminFirstRechargeRewardService_UpdateFirstRechargeRewardConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UpdateFirstRechargeRewardConfigRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AdminFirstRechargeRewardServiceServer).UpdateFirstRechargeRewardConfig(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AdminFirstRechargeRewardService_UpdateFirstRechargeRewardConfig_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AdminFirstRechargeRewardServiceServer).UpdateFirstRechargeRewardConfig(ctx, req.(*UpdateFirstRechargeRewardConfigRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AdminFirstRechargeRewardService_ListFirstRechargeRewardClaims_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListFirstRechargeRewardClaimsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AdminFirstRechargeRewardServiceServer).ListFirstRechargeRewardClaims(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AdminFirstRechargeRewardService_ListFirstRechargeRewardClaims_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AdminFirstRechargeRewardServiceServer).ListFirstRechargeRewardClaims(ctx, req.(*ListFirstRechargeRewardClaimsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// AdminFirstRechargeRewardService_ServiceDesc is the grpc.ServiceDesc for AdminFirstRechargeRewardService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var AdminFirstRechargeRewardService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "hyapp.activity.v1.AdminFirstRechargeRewardService",
|
||||
HandlerType: (*AdminFirstRechargeRewardServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "GetFirstRechargeRewardConfig",
|
||||
Handler: _AdminFirstRechargeRewardService_GetFirstRechargeRewardConfig_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UpdateFirstRechargeRewardConfig",
|
||||
Handler: _AdminFirstRechargeRewardService_UpdateFirstRechargeRewardConfig_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListFirstRechargeRewardClaims",
|
||||
Handler: _AdminFirstRechargeRewardService_ListFirstRechargeRewardClaims_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "proto/activity/v1/activity.proto",
|
||||
}
|
||||
|
||||
const (
|
||||
SevenDayCheckInService_GetSevenDayCheckInStatus_FullMethodName = "/hyapp.activity.v1.SevenDayCheckInService/GetSevenDayCheckInStatus"
|
||||
SevenDayCheckInService_SignSevenDayCheckIn_FullMethodName = "/hyapp.activity.v1.SevenDayCheckInService/SignSevenDayCheckIn"
|
||||
@ -3014,9 +3342,11 @@ var AdminAchievementService_ServiceDesc = grpc.ServiceDesc{
|
||||
}
|
||||
|
||||
const (
|
||||
AdminLuckyGiftService_GetLuckyGiftConfig_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/GetLuckyGiftConfig"
|
||||
AdminLuckyGiftService_UpsertLuckyGiftConfig_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/UpsertLuckyGiftConfig"
|
||||
AdminLuckyGiftService_ListLuckyGiftDraws_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/ListLuckyGiftDraws"
|
||||
AdminLuckyGiftService_GetLuckyGiftConfig_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/GetLuckyGiftConfig"
|
||||
AdminLuckyGiftService_UpsertLuckyGiftConfig_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/UpsertLuckyGiftConfig"
|
||||
AdminLuckyGiftService_ListLuckyGiftConfigs_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/ListLuckyGiftConfigs"
|
||||
AdminLuckyGiftService_ListLuckyGiftDraws_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/ListLuckyGiftDraws"
|
||||
AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName = "/hyapp.activity.v1.AdminLuckyGiftService/GetLuckyGiftDrawSummary"
|
||||
)
|
||||
|
||||
// AdminLuckyGiftServiceClient is the client API for AdminLuckyGiftService service.
|
||||
@ -3027,7 +3357,9 @@ const (
|
||||
type AdminLuckyGiftServiceClient interface {
|
||||
GetLuckyGiftConfig(ctx context.Context, in *GetLuckyGiftConfigRequest, opts ...grpc.CallOption) (*GetLuckyGiftConfigResponse, error)
|
||||
UpsertLuckyGiftConfig(ctx context.Context, in *UpsertLuckyGiftConfigRequest, opts ...grpc.CallOption) (*UpsertLuckyGiftConfigResponse, error)
|
||||
ListLuckyGiftConfigs(ctx context.Context, in *ListLuckyGiftConfigsRequest, opts ...grpc.CallOption) (*ListLuckyGiftConfigsResponse, error)
|
||||
ListLuckyGiftDraws(ctx context.Context, in *ListLuckyGiftDrawsRequest, opts ...grpc.CallOption) (*ListLuckyGiftDrawsResponse, error)
|
||||
GetLuckyGiftDrawSummary(ctx context.Context, in *GetLuckyGiftDrawSummaryRequest, opts ...grpc.CallOption) (*GetLuckyGiftDrawSummaryResponse, error)
|
||||
}
|
||||
|
||||
type adminLuckyGiftServiceClient struct {
|
||||
@ -3058,6 +3390,16 @@ func (c *adminLuckyGiftServiceClient) UpsertLuckyGiftConfig(ctx context.Context,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *adminLuckyGiftServiceClient) ListLuckyGiftConfigs(ctx context.Context, in *ListLuckyGiftConfigsRequest, opts ...grpc.CallOption) (*ListLuckyGiftConfigsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListLuckyGiftConfigsResponse)
|
||||
err := c.cc.Invoke(ctx, AdminLuckyGiftService_ListLuckyGiftConfigs_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *adminLuckyGiftServiceClient) ListLuckyGiftDraws(ctx context.Context, in *ListLuckyGiftDrawsRequest, opts ...grpc.CallOption) (*ListLuckyGiftDrawsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListLuckyGiftDrawsResponse)
|
||||
@ -3068,6 +3410,16 @@ func (c *adminLuckyGiftServiceClient) ListLuckyGiftDraws(ctx context.Context, in
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *adminLuckyGiftServiceClient) GetLuckyGiftDrawSummary(ctx context.Context, in *GetLuckyGiftDrawSummaryRequest, opts ...grpc.CallOption) (*GetLuckyGiftDrawSummaryResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetLuckyGiftDrawSummaryResponse)
|
||||
err := c.cc.Invoke(ctx, AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AdminLuckyGiftServiceServer is the server API for AdminLuckyGiftService service.
|
||||
// All implementations must embed UnimplementedAdminLuckyGiftServiceServer
|
||||
// for forward compatibility.
|
||||
@ -3076,7 +3428,9 @@ func (c *adminLuckyGiftServiceClient) ListLuckyGiftDraws(ctx context.Context, in
|
||||
type AdminLuckyGiftServiceServer interface {
|
||||
GetLuckyGiftConfig(context.Context, *GetLuckyGiftConfigRequest) (*GetLuckyGiftConfigResponse, error)
|
||||
UpsertLuckyGiftConfig(context.Context, *UpsertLuckyGiftConfigRequest) (*UpsertLuckyGiftConfigResponse, error)
|
||||
ListLuckyGiftConfigs(context.Context, *ListLuckyGiftConfigsRequest) (*ListLuckyGiftConfigsResponse, error)
|
||||
ListLuckyGiftDraws(context.Context, *ListLuckyGiftDrawsRequest) (*ListLuckyGiftDrawsResponse, error)
|
||||
GetLuckyGiftDrawSummary(context.Context, *GetLuckyGiftDrawSummaryRequest) (*GetLuckyGiftDrawSummaryResponse, error)
|
||||
mustEmbedUnimplementedAdminLuckyGiftServiceServer()
|
||||
}
|
||||
|
||||
@ -3093,9 +3447,15 @@ func (UnimplementedAdminLuckyGiftServiceServer) GetLuckyGiftConfig(context.Conte
|
||||
func (UnimplementedAdminLuckyGiftServiceServer) UpsertLuckyGiftConfig(context.Context, *UpsertLuckyGiftConfigRequest) (*UpsertLuckyGiftConfigResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method UpsertLuckyGiftConfig not implemented")
|
||||
}
|
||||
func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftConfigs(context.Context, *ListLuckyGiftConfigsRequest) (*ListLuckyGiftConfigsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListLuckyGiftConfigs not implemented")
|
||||
}
|
||||
func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftDraws(context.Context, *ListLuckyGiftDrawsRequest) (*ListLuckyGiftDrawsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListLuckyGiftDraws not implemented")
|
||||
}
|
||||
func (UnimplementedAdminLuckyGiftServiceServer) GetLuckyGiftDrawSummary(context.Context, *GetLuckyGiftDrawSummaryRequest) (*GetLuckyGiftDrawSummaryResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetLuckyGiftDrawSummary not implemented")
|
||||
}
|
||||
func (UnimplementedAdminLuckyGiftServiceServer) mustEmbedUnimplementedAdminLuckyGiftServiceServer() {}
|
||||
func (UnimplementedAdminLuckyGiftServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
@ -3153,6 +3513,24 @@ func _AdminLuckyGiftService_UpsertLuckyGiftConfig_Handler(srv interface{}, ctx c
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AdminLuckyGiftService_ListLuckyGiftConfigs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListLuckyGiftConfigsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AdminLuckyGiftServiceServer).ListLuckyGiftConfigs(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AdminLuckyGiftService_ListLuckyGiftConfigs_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AdminLuckyGiftServiceServer).ListLuckyGiftConfigs(ctx, req.(*ListLuckyGiftConfigsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AdminLuckyGiftService_ListLuckyGiftDraws_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListLuckyGiftDrawsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -3171,6 +3549,24 @@ func _AdminLuckyGiftService_ListLuckyGiftDraws_Handler(srv interface{}, ctx cont
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AdminLuckyGiftService_GetLuckyGiftDrawSummary_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetLuckyGiftDrawSummaryRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AdminLuckyGiftServiceServer).GetLuckyGiftDrawSummary(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AdminLuckyGiftServiceServer).GetLuckyGiftDrawSummary(ctx, req.(*GetLuckyGiftDrawSummaryRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// AdminLuckyGiftService_ServiceDesc is the grpc.ServiceDesc for AdminLuckyGiftService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@ -3186,10 +3582,18 @@ var AdminLuckyGiftService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "UpsertLuckyGiftConfig",
|
||||
Handler: _AdminLuckyGiftService_UpsertLuckyGiftConfig_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListLuckyGiftConfigs",
|
||||
Handler: _AdminLuckyGiftService_ListLuckyGiftConfigs_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListLuckyGiftDraws",
|
||||
Handler: _AdminLuckyGiftService_ListLuckyGiftDraws_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetLuckyGiftDrawSummary",
|
||||
Handler: _AdminLuckyGiftService_GetLuckyGiftDrawSummary_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "proto/activity/v1/activity.proto",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc (unknown)
|
||||
// protoc v7.35.0
|
||||
// source: proto/events/room/v1/events.proto
|
||||
|
||||
package roomeventsv1
|
||||
@ -280,11 +280,13 @@ func (x *RoomProfileUpdated) GetSeatCount() int32 {
|
||||
|
||||
// RoomUserJoined 表达用户业务态进房成功。
|
||||
type RoomUserJoined struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||
Role string `protobuf:"bytes,2,opt,name=role,proto3" json:"role,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||
Role string `protobuf:"bytes,2,opt,name=role,proto3" json:"role,omitempty"`
|
||||
// visible_region_id 是用户进房时所在房间的国家/区域桶,统计服务不能反查 room-service。
|
||||
VisibleRegionId int64 `protobuf:"varint,3,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *RoomUserJoined) Reset() {
|
||||
@ -331,6 +333,13 @@ func (x *RoomUserJoined) GetRole() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RoomUserJoined) GetVisibleRegionId() int64 {
|
||||
if x != nil {
|
||||
return x.VisibleRegionId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// RoomUserLeft 表达用户离房成功。
|
||||
type RoomUserLeft struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@ -971,6 +980,7 @@ type RoomGiftSent struct {
|
||||
BillingReceiptId string `protobuf:"bytes,6,opt,name=billing_receipt_id,json=billingReceiptId,proto3" json:"billing_receipt_id,omitempty"`
|
||||
VisibleRegionId int64 `protobuf:"varint,7,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"`
|
||||
CommandId string `protobuf:"bytes,8,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
|
||||
PoolId string `protobuf:"bytes,9,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -1061,6 +1071,13 @@ func (x *RoomGiftSent) GetCommandId() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RoomGiftSent) GetPoolId() string {
|
||||
if x != nil {
|
||||
return x.PoolId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RoomHeatChanged 表达热度变化结果。
|
||||
type RoomHeatChanged struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@ -1749,10 +1766,11 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
|
||||
"roomAvatar\x12)\n" +
|
||||
"\x10room_description\x18\x04 \x01(\tR\x0froomDescription\x12\x1d\n" +
|
||||
"\n" +
|
||||
"seat_count\x18\x05 \x01(\x05R\tseatCount\"=\n" +
|
||||
"seat_count\x18\x05 \x01(\x05R\tseatCount\"i\n" +
|
||||
"\x0eRoomUserJoined\x12\x17\n" +
|
||||
"\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x12\n" +
|
||||
"\x04role\x18\x02 \x01(\tR\x04role\"'\n" +
|
||||
"\x04role\x18\x02 \x01(\tR\x04role\x12*\n" +
|
||||
"\x11visible_region_id\x18\x03 \x01(\x03R\x0fvisibleRegionId\"'\n" +
|
||||
"\fRoomUserLeft\x12\x17\n" +
|
||||
"\auser_id\x18\x01 \x01(\x03R\x06userId\"H\n" +
|
||||
"\n" +
|
||||
@ -1797,7 +1815,7 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
|
||||
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\\\n" +
|
||||
"\x10RoomUserUnbanned\x12\"\n" +
|
||||
"\ractor_user_id\x18\x01 \x01(\x03R\vactorUserId\x12$\n" +
|
||||
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\xaa\x02\n" +
|
||||
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\xc3\x02\n" +
|
||||
"\fRoomGiftSent\x12$\n" +
|
||||
"\x0esender_user_id\x18\x01 \x01(\x03R\fsenderUserId\x12$\n" +
|
||||
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x17\n" +
|
||||
@ -1809,7 +1827,8 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
|
||||
"\x12billing_receipt_id\x18\x06 \x01(\tR\x10billingReceiptId\x12*\n" +
|
||||
"\x11visible_region_id\x18\a \x01(\x03R\x0fvisibleRegionId\x12\x1d\n" +
|
||||
"\n" +
|
||||
"command_id\x18\b \x01(\tR\tcommandId\"J\n" +
|
||||
"command_id\x18\b \x01(\tR\tcommandId\x12\x17\n" +
|
||||
"\apool_id\x18\t \x01(\tR\x06poolId\"J\n" +
|
||||
"\x0fRoomHeatChanged\x12\x14\n" +
|
||||
"\x05delta\x18\x01 \x01(\x03R\x05delta\x12!\n" +
|
||||
"\fcurrent_heat\x18\x02 \x01(\x03R\vcurrentHeat\"_\n" +
|
||||
|
||||
@ -40,6 +40,8 @@ message RoomProfileUpdated {
|
||||
message RoomUserJoined {
|
||||
int64 user_id = 1;
|
||||
string role = 2;
|
||||
// visible_region_id 是用户进房时所在房间的国家/区域桶,统计服务不能反查 room-service。
|
||||
int64 visible_region_id = 3;
|
||||
}
|
||||
|
||||
// RoomUserLeft 表达用户离房成功。
|
||||
@ -127,6 +129,7 @@ message RoomGiftSent {
|
||||
string billing_receipt_id = 6;
|
||||
int64 visible_region_id = 7;
|
||||
string command_id = 8;
|
||||
string pool_id = 9;
|
||||
}
|
||||
|
||||
// RoomHeatChanged 表达热度变化结果。
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v5.28.3
|
||||
// protoc v7.35.0
|
||||
// source: proto/game/v1/game.proto
|
||||
|
||||
package gamev1
|
||||
@ -107,17 +107,23 @@ func (x *RequestMeta) GetAppCode() string {
|
||||
}
|
||||
|
||||
type GamePlatform struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
|
||||
PlatformCode string `protobuf:"bytes,2,opt,name=platform_code,json=platformCode,proto3" json:"platform_code,omitempty"`
|
||||
PlatformName string `protobuf:"bytes,3,opt,name=platform_name,json=platformName,proto3" json:"platform_name,omitempty"`
|
||||
Status string `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"`
|
||||
ApiBaseUrl string `protobuf:"bytes,5,opt,name=api_base_url,json=apiBaseUrl,proto3" json:"api_base_url,omitempty"`
|
||||
SortOrder int32 `protobuf:"varint,6,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"`
|
||||
CreatedAtMs int64 `protobuf:"varint,7,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
|
||||
UpdatedAtMs int64 `protobuf:"varint,8,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
|
||||
PlatformCode string `protobuf:"bytes,2,opt,name=platform_code,json=platformCode,proto3" json:"platform_code,omitempty"`
|
||||
PlatformName string `protobuf:"bytes,3,opt,name=platform_name,json=platformName,proto3" json:"platform_name,omitempty"`
|
||||
Status string `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"`
|
||||
ApiBaseUrl string `protobuf:"bytes,5,opt,name=api_base_url,json=apiBaseUrl,proto3" json:"api_base_url,omitempty"`
|
||||
SortOrder int32 `protobuf:"varint,6,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"`
|
||||
CreatedAtMs int64 `protobuf:"varint,7,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
|
||||
UpdatedAtMs int64 `protobuf:"varint,8,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
|
||||
AdapterType string `protobuf:"bytes,9,opt,name=adapter_type,json=adapterType,proto3" json:"adapter_type,omitempty"`
|
||||
// callback_secret is visible to admin platform configuration pages and writable for key rotation.
|
||||
CallbackSecret string `protobuf:"bytes,10,opt,name=callback_secret,json=callbackSecret,proto3" json:"callback_secret,omitempty"`
|
||||
CallbackSecretSet bool `protobuf:"varint,11,opt,name=callback_secret_set,json=callbackSecretSet,proto3" json:"callback_secret_set,omitempty"`
|
||||
CallbackIpWhitelist []string `protobuf:"bytes,12,rep,name=callback_ip_whitelist,json=callbackIpWhitelist,proto3" json:"callback_ip_whitelist,omitempty"`
|
||||
AdapterConfigJson string `protobuf:"bytes,13,opt,name=adapter_config_json,json=adapterConfigJson,proto3" json:"adapter_config_json,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *GamePlatform) Reset() {
|
||||
@ -206,6 +212,41 @@ func (x *GamePlatform) GetUpdatedAtMs() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *GamePlatform) GetAdapterType() string {
|
||||
if x != nil {
|
||||
return x.AdapterType
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *GamePlatform) GetCallbackSecret() string {
|
||||
if x != nil {
|
||||
return x.CallbackSecret
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *GamePlatform) GetCallbackSecretSet() bool {
|
||||
if x != nil {
|
||||
return x.CallbackSecretSet
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *GamePlatform) GetCallbackIpWhitelist() []string {
|
||||
if x != nil {
|
||||
return x.CallbackIpWhitelist
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *GamePlatform) GetAdapterConfigJson() string {
|
||||
if x != nil {
|
||||
return x.AdapterConfigJson
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type GameCatalogItem struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
|
||||
@ -662,6 +703,9 @@ type LaunchGameRequest struct {
|
||||
GameId string `protobuf:"bytes,4,opt,name=game_id,json=gameId,proto3" json:"game_id,omitempty"`
|
||||
Scene string `protobuf:"bytes,5,opt,name=scene,proto3" json:"scene,omitempty"`
|
||||
RoomId string `protobuf:"bytes,6,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"`
|
||||
// access_token is the same bearer token used by the App request.
|
||||
// Provider adapters pass it through as the game token instead of minting a separate game token.
|
||||
AccessToken string `protobuf:"bytes,7,opt,name=access_token,json=accessToken,proto3" json:"access_token,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -738,6 +782,13 @@ func (x *LaunchGameRequest) GetRoomId() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LaunchGameRequest) GetAccessToken() string {
|
||||
if x != nil {
|
||||
return x.AccessToken
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type LaunchGameResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"`
|
||||
@ -1618,6 +1669,102 @@ func (x *SetGameStatusRequest) GetStatus() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
type DeleteCatalogRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
GameId string `protobuf:"bytes,2,opt,name=game_id,json=gameId,proto3" json:"game_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *DeleteCatalogRequest) Reset() {
|
||||
*x = DeleteCatalogRequest{}
|
||||
mi := &file_proto_game_v1_game_proto_msgTypes[21]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *DeleteCatalogRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*DeleteCatalogRequest) ProtoMessage() {}
|
||||
|
||||
func (x *DeleteCatalogRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_game_v1_game_proto_msgTypes[21]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use DeleteCatalogRequest.ProtoReflect.Descriptor instead.
|
||||
func (*DeleteCatalogRequest) Descriptor() ([]byte, []int) {
|
||||
return file_proto_game_v1_game_proto_rawDescGZIP(), []int{21}
|
||||
}
|
||||
|
||||
func (x *DeleteCatalogRequest) GetMeta() *RequestMeta {
|
||||
if x != nil {
|
||||
return x.Meta
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *DeleteCatalogRequest) GetGameId() string {
|
||||
if x != nil {
|
||||
return x.GameId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type DeleteCatalogResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ServerTimeMs int64 `protobuf:"varint,1,opt,name=server_time_ms,json=serverTimeMs,proto3" json:"server_time_ms,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *DeleteCatalogResponse) Reset() {
|
||||
*x = DeleteCatalogResponse{}
|
||||
mi := &file_proto_game_v1_game_proto_msgTypes[22]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *DeleteCatalogResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*DeleteCatalogResponse) ProtoMessage() {}
|
||||
|
||||
func (x *DeleteCatalogResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_game_v1_game_proto_msgTypes[22]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use DeleteCatalogResponse.ProtoReflect.Descriptor instead.
|
||||
func (*DeleteCatalogResponse) Descriptor() ([]byte, []int) {
|
||||
return file_proto_game_v1_game_proto_rawDescGZIP(), []int{22}
|
||||
}
|
||||
|
||||
func (x *DeleteCatalogResponse) GetServerTimeMs() int64 {
|
||||
if x != nil {
|
||||
return x.ServerTimeMs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var File_proto_game_v1_game_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_proto_game_v1_game_proto_rawDesc = "" +
|
||||
@ -1631,7 +1778,7 @@ const file_proto_game_v1_game_proto_rawDesc = "" +
|
||||
"\ractor_user_id\x18\x04 \x01(\x03R\vactorUserId\x12\x1c\n" +
|
||||
"\n" +
|
||||
"sent_at_ms\x18\x05 \x01(\x03R\bsentAtMs\x12\x19\n" +
|
||||
"\bapp_code\x18\x06 \x01(\tR\aappCode\"\x94\x02\n" +
|
||||
"\bapp_code\x18\x06 \x01(\tR\aappCode\"\xf4\x03\n" +
|
||||
"\fGamePlatform\x12\x19\n" +
|
||||
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12#\n" +
|
||||
"\rplatform_code\x18\x02 \x01(\tR\fplatformCode\x12#\n" +
|
||||
@ -1642,7 +1789,13 @@ const file_proto_game_v1_game_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"sort_order\x18\x06 \x01(\x05R\tsortOrder\x12\"\n" +
|
||||
"\rcreated_at_ms\x18\a \x01(\x03R\vcreatedAtMs\x12\"\n" +
|
||||
"\rupdated_at_ms\x18\b \x01(\x03R\vupdatedAtMs\"\xf6\x03\n" +
|
||||
"\rupdated_at_ms\x18\b \x01(\x03R\vupdatedAtMs\x12!\n" +
|
||||
"\fadapter_type\x18\t \x01(\tR\vadapterType\x12'\n" +
|
||||
"\x0fcallback_secret\x18\n" +
|
||||
" \x01(\tR\x0ecallbackSecret\x12.\n" +
|
||||
"\x13callback_secret_set\x18\v \x01(\bR\x11callbackSecretSet\x122\n" +
|
||||
"\x15callback_ip_whitelist\x18\f \x03(\tR\x13callbackIpWhitelist\x12.\n" +
|
||||
"\x13adapter_config_json\x18\r \x01(\tR\x11adapterConfigJson\"\xf6\x03\n" +
|
||||
"\x0fGameCatalogItem\x12\x19\n" +
|
||||
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" +
|
||||
"\agame_id\x18\x02 \x01(\tR\x06gameId\x12#\n" +
|
||||
@ -1690,14 +1843,15 @@ const file_proto_game_v1_game_proto_rawDesc = "" +
|
||||
"\x0fclient_platform\x18\a \x01(\tR\x0eclientPlatform\"g\n" +
|
||||
"\x11ListGamesResponse\x12,\n" +
|
||||
"\x05games\x18\x01 \x03(\v2\x16.hyapp.game.v1.AppGameR\x05games\x12$\n" +
|
||||
"\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"\xcc\x01\n" +
|
||||
"\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"\xef\x01\n" +
|
||||
"\x11LaunchGameRequest\x12.\n" +
|
||||
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.game.v1.RequestMetaR\x04meta\x12\x17\n" +
|
||||
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12&\n" +
|
||||
"\x0fdisplay_user_id\x18\x03 \x01(\tR\rdisplayUserId\x12\x17\n" +
|
||||
"\agame_id\x18\x04 \x01(\tR\x06gameId\x12\x14\n" +
|
||||
"\x05scene\x18\x05 \x01(\tR\x05scene\x12\x17\n" +
|
||||
"\aroom_id\x18\x06 \x01(\tR\x06roomId\"\xbe\x01\n" +
|
||||
"\aroom_id\x18\x06 \x01(\tR\x06roomId\x12!\n" +
|
||||
"\faccess_token\x18\a \x01(\tR\vaccessToken\"\xbe\x01\n" +
|
||||
"\x12LaunchGameResponse\x12\x1d\n" +
|
||||
"\n" +
|
||||
"session_id\x18\x01 \x01(\tR\tsessionId\x12\x1d\n" +
|
||||
@ -1770,7 +1924,12 @@ const file_proto_game_v1_game_proto_rawDesc = "" +
|
||||
"\x14SetGameStatusRequest\x12.\n" +
|
||||
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.game.v1.RequestMetaR\x04meta\x12\x17\n" +
|
||||
"\agame_id\x18\x02 \x01(\tR\x06gameId\x12\x16\n" +
|
||||
"\x06status\x18\x03 \x01(\tR\x06status2\xb3\x01\n" +
|
||||
"\x06status\x18\x03 \x01(\tR\x06status\"_\n" +
|
||||
"\x14DeleteCatalogRequest\x12.\n" +
|
||||
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.game.v1.RequestMetaR\x04meta\x12\x17\n" +
|
||||
"\agame_id\x18\x02 \x01(\tR\x06gameId\"=\n" +
|
||||
"\x15DeleteCatalogResponse\x12$\n" +
|
||||
"\x0eserver_time_ms\x18\x01 \x01(\x03R\fserverTimeMs2\xb3\x01\n" +
|
||||
"\x0eGameAppService\x12N\n" +
|
||||
"\tListGames\x12\x1f.hyapp.game.v1.ListGamesRequest\x1a .hyapp.game.v1.ListGamesResponse\x12Q\n" +
|
||||
"\n" +
|
||||
@ -1778,13 +1937,14 @@ const file_proto_game_v1_game_proto_rawDesc = "" +
|
||||
"\x13GameCallbackService\x12Q\n" +
|
||||
"\x0eHandleCallback\x12\x1e.hyapp.game.v1.CallbackRequest\x1a\x1f.hyapp.game.v1.CallbackResponse2t\n" +
|
||||
"\x0fGameCronService\x12a\n" +
|
||||
"\x1cProcessLevelEventOutboxBatch\x12\x1f.hyapp.game.v1.CronBatchRequest\x1a .hyapp.game.v1.CronBatchResponse2\xc9\x03\n" +
|
||||
"\x1cProcessLevelEventOutboxBatch\x12\x1f.hyapp.game.v1.CronBatchRequest\x1a .hyapp.game.v1.CronBatchResponse2\xa5\x04\n" +
|
||||
"\x10GameAdminService\x12Z\n" +
|
||||
"\rListPlatforms\x12#.hyapp.game.v1.ListPlatformsRequest\x1a$.hyapp.game.v1.ListPlatformsResponse\x12W\n" +
|
||||
"\x0eUpsertPlatform\x12$.hyapp.game.v1.UpsertPlatformRequest\x1a\x1f.hyapp.game.v1.PlatformResponse\x12T\n" +
|
||||
"\vListCatalog\x12!.hyapp.game.v1.ListCatalogRequest\x1a\".hyapp.game.v1.ListCatalogResponse\x12T\n" +
|
||||
"\rUpsertCatalog\x12#.hyapp.game.v1.UpsertCatalogRequest\x1a\x1e.hyapp.game.v1.CatalogResponse\x12T\n" +
|
||||
"\rSetGameStatus\x12#.hyapp.game.v1.SetGameStatusRequest\x1a\x1e.hyapp.game.v1.CatalogResponseB&Z$hyapp.local/api/proto/game/v1;gamev1b\x06proto3"
|
||||
"\rSetGameStatus\x12#.hyapp.game.v1.SetGameStatusRequest\x1a\x1e.hyapp.game.v1.CatalogResponse\x12Z\n" +
|
||||
"\rDeleteCatalog\x12#.hyapp.game.v1.DeleteCatalogRequest\x1a$.hyapp.game.v1.DeleteCatalogResponseB&Z$hyapp.local/api/proto/game/v1;gamev1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_proto_game_v1_game_proto_rawDescOnce sync.Once
|
||||
@ -1798,7 +1958,7 @@ func file_proto_game_v1_game_proto_rawDescGZIP() []byte {
|
||||
return file_proto_game_v1_game_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_proto_game_v1_game_proto_msgTypes = make([]protoimpl.MessageInfo, 23)
|
||||
var file_proto_game_v1_game_proto_msgTypes = make([]protoimpl.MessageInfo, 25)
|
||||
var file_proto_game_v1_game_proto_goTypes = []any{
|
||||
(*RequestMeta)(nil), // 0: hyapp.game.v1.RequestMeta
|
||||
(*GamePlatform)(nil), // 1: hyapp.game.v1.GamePlatform
|
||||
@ -1821,16 +1981,18 @@ var file_proto_game_v1_game_proto_goTypes = []any{
|
||||
(*UpsertCatalogRequest)(nil), // 18: hyapp.game.v1.UpsertCatalogRequest
|
||||
(*CatalogResponse)(nil), // 19: hyapp.game.v1.CatalogResponse
|
||||
(*SetGameStatusRequest)(nil), // 20: hyapp.game.v1.SetGameStatusRequest
|
||||
nil, // 21: hyapp.game.v1.CallbackRequest.HeadersEntry
|
||||
nil, // 22: hyapp.game.v1.CallbackRequest.QueryEntry
|
||||
(*DeleteCatalogRequest)(nil), // 21: hyapp.game.v1.DeleteCatalogRequest
|
||||
(*DeleteCatalogResponse)(nil), // 22: hyapp.game.v1.DeleteCatalogResponse
|
||||
nil, // 23: hyapp.game.v1.CallbackRequest.HeadersEntry
|
||||
nil, // 24: hyapp.game.v1.CallbackRequest.QueryEntry
|
||||
}
|
||||
var file_proto_game_v1_game_proto_depIdxs = []int32{
|
||||
0, // 0: hyapp.game.v1.ListGamesRequest.meta:type_name -> hyapp.game.v1.RequestMeta
|
||||
3, // 1: hyapp.game.v1.ListGamesResponse.games:type_name -> hyapp.game.v1.AppGame
|
||||
0, // 2: hyapp.game.v1.LaunchGameRequest.meta:type_name -> hyapp.game.v1.RequestMeta
|
||||
0, // 3: hyapp.game.v1.CallbackRequest.meta:type_name -> hyapp.game.v1.RequestMeta
|
||||
21, // 4: hyapp.game.v1.CallbackRequest.headers:type_name -> hyapp.game.v1.CallbackRequest.HeadersEntry
|
||||
22, // 5: hyapp.game.v1.CallbackRequest.query:type_name -> hyapp.game.v1.CallbackRequest.QueryEntry
|
||||
23, // 4: hyapp.game.v1.CallbackRequest.headers:type_name -> hyapp.game.v1.CallbackRequest.HeadersEntry
|
||||
24, // 5: hyapp.game.v1.CallbackRequest.query:type_name -> hyapp.game.v1.CallbackRequest.QueryEntry
|
||||
0, // 6: hyapp.game.v1.CronBatchRequest.meta:type_name -> hyapp.game.v1.RequestMeta
|
||||
0, // 7: hyapp.game.v1.ListPlatformsRequest.meta:type_name -> hyapp.game.v1.RequestMeta
|
||||
1, // 8: hyapp.game.v1.ListPlatformsResponse.platforms:type_name -> hyapp.game.v1.GamePlatform
|
||||
@ -1843,29 +2005,32 @@ var file_proto_game_v1_game_proto_depIdxs = []int32{
|
||||
2, // 15: hyapp.game.v1.UpsertCatalogRequest.game:type_name -> hyapp.game.v1.GameCatalogItem
|
||||
2, // 16: hyapp.game.v1.CatalogResponse.game:type_name -> hyapp.game.v1.GameCatalogItem
|
||||
0, // 17: hyapp.game.v1.SetGameStatusRequest.meta:type_name -> hyapp.game.v1.RequestMeta
|
||||
4, // 18: hyapp.game.v1.GameAppService.ListGames:input_type -> hyapp.game.v1.ListGamesRequest
|
||||
6, // 19: hyapp.game.v1.GameAppService.LaunchGame:input_type -> hyapp.game.v1.LaunchGameRequest
|
||||
8, // 20: hyapp.game.v1.GameCallbackService.HandleCallback:input_type -> hyapp.game.v1.CallbackRequest
|
||||
10, // 21: hyapp.game.v1.GameCronService.ProcessLevelEventOutboxBatch:input_type -> hyapp.game.v1.CronBatchRequest
|
||||
12, // 22: hyapp.game.v1.GameAdminService.ListPlatforms:input_type -> hyapp.game.v1.ListPlatformsRequest
|
||||
14, // 23: hyapp.game.v1.GameAdminService.UpsertPlatform:input_type -> hyapp.game.v1.UpsertPlatformRequest
|
||||
16, // 24: hyapp.game.v1.GameAdminService.ListCatalog:input_type -> hyapp.game.v1.ListCatalogRequest
|
||||
18, // 25: hyapp.game.v1.GameAdminService.UpsertCatalog:input_type -> hyapp.game.v1.UpsertCatalogRequest
|
||||
20, // 26: hyapp.game.v1.GameAdminService.SetGameStatus:input_type -> hyapp.game.v1.SetGameStatusRequest
|
||||
5, // 27: hyapp.game.v1.GameAppService.ListGames:output_type -> hyapp.game.v1.ListGamesResponse
|
||||
7, // 28: hyapp.game.v1.GameAppService.LaunchGame:output_type -> hyapp.game.v1.LaunchGameResponse
|
||||
9, // 29: hyapp.game.v1.GameCallbackService.HandleCallback:output_type -> hyapp.game.v1.CallbackResponse
|
||||
11, // 30: hyapp.game.v1.GameCronService.ProcessLevelEventOutboxBatch:output_type -> hyapp.game.v1.CronBatchResponse
|
||||
13, // 31: hyapp.game.v1.GameAdminService.ListPlatforms:output_type -> hyapp.game.v1.ListPlatformsResponse
|
||||
15, // 32: hyapp.game.v1.GameAdminService.UpsertPlatform:output_type -> hyapp.game.v1.PlatformResponse
|
||||
17, // 33: hyapp.game.v1.GameAdminService.ListCatalog:output_type -> hyapp.game.v1.ListCatalogResponse
|
||||
19, // 34: hyapp.game.v1.GameAdminService.UpsertCatalog:output_type -> hyapp.game.v1.CatalogResponse
|
||||
19, // 35: hyapp.game.v1.GameAdminService.SetGameStatus:output_type -> hyapp.game.v1.CatalogResponse
|
||||
27, // [27:36] is the sub-list for method output_type
|
||||
18, // [18:27] is the sub-list for method input_type
|
||||
18, // [18:18] is the sub-list for extension type_name
|
||||
18, // [18:18] is the sub-list for extension extendee
|
||||
0, // [0:18] is the sub-list for field type_name
|
||||
0, // 18: hyapp.game.v1.DeleteCatalogRequest.meta:type_name -> hyapp.game.v1.RequestMeta
|
||||
4, // 19: hyapp.game.v1.GameAppService.ListGames:input_type -> hyapp.game.v1.ListGamesRequest
|
||||
6, // 20: hyapp.game.v1.GameAppService.LaunchGame:input_type -> hyapp.game.v1.LaunchGameRequest
|
||||
8, // 21: hyapp.game.v1.GameCallbackService.HandleCallback:input_type -> hyapp.game.v1.CallbackRequest
|
||||
10, // 22: hyapp.game.v1.GameCronService.ProcessLevelEventOutboxBatch:input_type -> hyapp.game.v1.CronBatchRequest
|
||||
12, // 23: hyapp.game.v1.GameAdminService.ListPlatforms:input_type -> hyapp.game.v1.ListPlatformsRequest
|
||||
14, // 24: hyapp.game.v1.GameAdminService.UpsertPlatform:input_type -> hyapp.game.v1.UpsertPlatformRequest
|
||||
16, // 25: hyapp.game.v1.GameAdminService.ListCatalog:input_type -> hyapp.game.v1.ListCatalogRequest
|
||||
18, // 26: hyapp.game.v1.GameAdminService.UpsertCatalog:input_type -> hyapp.game.v1.UpsertCatalogRequest
|
||||
20, // 27: hyapp.game.v1.GameAdminService.SetGameStatus:input_type -> hyapp.game.v1.SetGameStatusRequest
|
||||
21, // 28: hyapp.game.v1.GameAdminService.DeleteCatalog:input_type -> hyapp.game.v1.DeleteCatalogRequest
|
||||
5, // 29: hyapp.game.v1.GameAppService.ListGames:output_type -> hyapp.game.v1.ListGamesResponse
|
||||
7, // 30: hyapp.game.v1.GameAppService.LaunchGame:output_type -> hyapp.game.v1.LaunchGameResponse
|
||||
9, // 31: hyapp.game.v1.GameCallbackService.HandleCallback:output_type -> hyapp.game.v1.CallbackResponse
|
||||
11, // 32: hyapp.game.v1.GameCronService.ProcessLevelEventOutboxBatch:output_type -> hyapp.game.v1.CronBatchResponse
|
||||
13, // 33: hyapp.game.v1.GameAdminService.ListPlatforms:output_type -> hyapp.game.v1.ListPlatformsResponse
|
||||
15, // 34: hyapp.game.v1.GameAdminService.UpsertPlatform:output_type -> hyapp.game.v1.PlatformResponse
|
||||
17, // 35: hyapp.game.v1.GameAdminService.ListCatalog:output_type -> hyapp.game.v1.ListCatalogResponse
|
||||
19, // 36: hyapp.game.v1.GameAdminService.UpsertCatalog:output_type -> hyapp.game.v1.CatalogResponse
|
||||
19, // 37: hyapp.game.v1.GameAdminService.SetGameStatus:output_type -> hyapp.game.v1.CatalogResponse
|
||||
22, // 38: hyapp.game.v1.GameAdminService.DeleteCatalog:output_type -> hyapp.game.v1.DeleteCatalogResponse
|
||||
29, // [29:39] is the sub-list for method output_type
|
||||
19, // [19:29] is the sub-list for method input_type
|
||||
19, // [19:19] is the sub-list for extension type_name
|
||||
19, // [19:19] is the sub-list for extension extendee
|
||||
0, // [0:19] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_proto_game_v1_game_proto_init() }
|
||||
@ -1879,7 +2044,7 @@ func file_proto_game_v1_game_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_game_v1_game_proto_rawDesc), len(file_proto_game_v1_game_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 23,
|
||||
NumMessages: 25,
|
||||
NumExtensions: 0,
|
||||
NumServices: 4,
|
||||
},
|
||||
|
||||
@ -23,6 +23,12 @@ message GamePlatform {
|
||||
int32 sort_order = 6;
|
||||
int64 created_at_ms = 7;
|
||||
int64 updated_at_ms = 8;
|
||||
string adapter_type = 9;
|
||||
// callback_secret is visible to admin platform configuration pages and writable for key rotation.
|
||||
string callback_secret = 10;
|
||||
bool callback_secret_set = 11;
|
||||
repeated string callback_ip_whitelist = 12;
|
||||
string adapter_config_json = 13;
|
||||
}
|
||||
|
||||
message GameCatalogItem {
|
||||
@ -82,6 +88,9 @@ message LaunchGameRequest {
|
||||
string game_id = 4;
|
||||
string scene = 5;
|
||||
string room_id = 6;
|
||||
// access_token is the same bearer token used by the App request.
|
||||
// Provider adapters pass it through as the game token instead of minting a separate game token.
|
||||
string access_token = 7;
|
||||
}
|
||||
|
||||
message LaunchGameResponse {
|
||||
@ -173,6 +182,15 @@ message SetGameStatusRequest {
|
||||
string status = 3;
|
||||
}
|
||||
|
||||
message DeleteCatalogRequest {
|
||||
RequestMeta meta = 1;
|
||||
string game_id = 2;
|
||||
}
|
||||
|
||||
message DeleteCatalogResponse {
|
||||
int64 server_time_ms = 1;
|
||||
}
|
||||
|
||||
service GameAppService {
|
||||
rpc ListGames(ListGamesRequest) returns (ListGamesResponse);
|
||||
rpc LaunchGame(LaunchGameRequest) returns (LaunchGameResponse);
|
||||
@ -193,4 +211,5 @@ service GameAdminService {
|
||||
rpc ListCatalog(ListCatalogRequest) returns (ListCatalogResponse);
|
||||
rpc UpsertCatalog(UpsertCatalogRequest) returns (CatalogResponse);
|
||||
rpc SetGameStatus(SetGameStatusRequest) returns (CatalogResponse);
|
||||
rpc DeleteCatalog(DeleteCatalogRequest) returns (DeleteCatalogResponse);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - protoc v5.28.3
|
||||
// - protoc v7.35.0
|
||||
// source: proto/game/v1/game.proto
|
||||
|
||||
package gamev1
|
||||
@ -372,6 +372,7 @@ const (
|
||||
GameAdminService_ListCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/ListCatalog"
|
||||
GameAdminService_UpsertCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/UpsertCatalog"
|
||||
GameAdminService_SetGameStatus_FullMethodName = "/hyapp.game.v1.GameAdminService/SetGameStatus"
|
||||
GameAdminService_DeleteCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/DeleteCatalog"
|
||||
)
|
||||
|
||||
// GameAdminServiceClient is the client API for GameAdminService service.
|
||||
@ -383,6 +384,7 @@ type GameAdminServiceClient interface {
|
||||
ListCatalog(ctx context.Context, in *ListCatalogRequest, opts ...grpc.CallOption) (*ListCatalogResponse, error)
|
||||
UpsertCatalog(ctx context.Context, in *UpsertCatalogRequest, opts ...grpc.CallOption) (*CatalogResponse, error)
|
||||
SetGameStatus(ctx context.Context, in *SetGameStatusRequest, opts ...grpc.CallOption) (*CatalogResponse, error)
|
||||
DeleteCatalog(ctx context.Context, in *DeleteCatalogRequest, opts ...grpc.CallOption) (*DeleteCatalogResponse, error)
|
||||
}
|
||||
|
||||
type gameAdminServiceClient struct {
|
||||
@ -443,6 +445,16 @@ func (c *gameAdminServiceClient) SetGameStatus(ctx context.Context, in *SetGameS
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *gameAdminServiceClient) DeleteCatalog(ctx context.Context, in *DeleteCatalogRequest, opts ...grpc.CallOption) (*DeleteCatalogResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(DeleteCatalogResponse)
|
||||
err := c.cc.Invoke(ctx, GameAdminService_DeleteCatalog_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GameAdminServiceServer is the server API for GameAdminService service.
|
||||
// All implementations must embed UnimplementedGameAdminServiceServer
|
||||
// for forward compatibility.
|
||||
@ -452,6 +464,7 @@ type GameAdminServiceServer interface {
|
||||
ListCatalog(context.Context, *ListCatalogRequest) (*ListCatalogResponse, error)
|
||||
UpsertCatalog(context.Context, *UpsertCatalogRequest) (*CatalogResponse, error)
|
||||
SetGameStatus(context.Context, *SetGameStatusRequest) (*CatalogResponse, error)
|
||||
DeleteCatalog(context.Context, *DeleteCatalogRequest) (*DeleteCatalogResponse, error)
|
||||
mustEmbedUnimplementedGameAdminServiceServer()
|
||||
}
|
||||
|
||||
@ -477,6 +490,9 @@ func (UnimplementedGameAdminServiceServer) UpsertCatalog(context.Context, *Upser
|
||||
func (UnimplementedGameAdminServiceServer) SetGameStatus(context.Context, *SetGameStatusRequest) (*CatalogResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method SetGameStatus not implemented")
|
||||
}
|
||||
func (UnimplementedGameAdminServiceServer) DeleteCatalog(context.Context, *DeleteCatalogRequest) (*DeleteCatalogResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method DeleteCatalog not implemented")
|
||||
}
|
||||
func (UnimplementedGameAdminServiceServer) mustEmbedUnimplementedGameAdminServiceServer() {}
|
||||
func (UnimplementedGameAdminServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
@ -588,6 +604,24 @@ func _GameAdminService_SetGameStatus_Handler(srv interface{}, ctx context.Contex
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _GameAdminService_DeleteCatalog_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(DeleteCatalogRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(GameAdminServiceServer).DeleteCatalog(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: GameAdminService_DeleteCatalog_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(GameAdminServiceServer).DeleteCatalog(ctx, req.(*DeleteCatalogRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// GameAdminService_ServiceDesc is the grpc.ServiceDesc for GameAdminService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@ -615,6 +649,10 @@ var GameAdminService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "SetGameStatus",
|
||||
Handler: _GameAdminService_SetGameStatus_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "DeleteCatalog",
|
||||
Handler: _GameAdminService_DeleteCatalog_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "proto/game/v1/game.proto",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -126,6 +126,146 @@ message RoomTreasureInfo {
|
||||
string reward_stack_policy = 8;
|
||||
}
|
||||
|
||||
// RoomTreasureGiftEnergyRule 是后台配置的礼物能量覆盖规则。
|
||||
message RoomTreasureGiftEnergyRule {
|
||||
string rule_id = 1;
|
||||
string gift_id = 2;
|
||||
string gift_type_code = 3;
|
||||
int64 multiplier_ppm = 4;
|
||||
int64 fixed_energy = 5;
|
||||
bool excluded = 6;
|
||||
}
|
||||
|
||||
// AdminRoomTreasureConfig 是后台管理读写的完整宝箱配置。
|
||||
message AdminRoomTreasureConfig {
|
||||
string app_code = 1;
|
||||
bool enabled = 2;
|
||||
int64 config_version = 3;
|
||||
string energy_source = 4;
|
||||
int64 open_delay_ms = 5;
|
||||
bool broadcast_enabled = 6;
|
||||
string broadcast_scope = 7;
|
||||
int64 broadcast_delay_ms = 8;
|
||||
string reward_stack_policy = 9;
|
||||
repeated RoomTreasureLevel levels = 10;
|
||||
repeated RoomTreasureGiftEnergyRule gift_energy_rules = 11;
|
||||
int64 updated_by_admin_id = 12;
|
||||
int64 created_at_ms = 13;
|
||||
int64 updated_at_ms = 14;
|
||||
}
|
||||
|
||||
message AdminGetRoomTreasureConfigRequest {
|
||||
RequestMeta meta = 1;
|
||||
}
|
||||
|
||||
message AdminGetRoomTreasureConfigResponse {
|
||||
AdminRoomTreasureConfig config = 1;
|
||||
int64 server_time_ms = 2;
|
||||
}
|
||||
|
||||
message AdminUpdateRoomTreasureConfigRequest {
|
||||
RequestMeta meta = 1;
|
||||
AdminRoomTreasureConfig config = 2;
|
||||
int64 admin_id = 3;
|
||||
}
|
||||
|
||||
message AdminUpdateRoomTreasureConfigResponse {
|
||||
AdminRoomTreasureConfig config = 1;
|
||||
int64 server_time_ms = 2;
|
||||
}
|
||||
|
||||
message AdminRoomSeatConfig {
|
||||
repeated int32 candidate_seat_counts = 1;
|
||||
repeated int32 allowed_seat_counts = 2;
|
||||
int32 default_seat_count = 3;
|
||||
int64 updated_at_ms = 4;
|
||||
}
|
||||
|
||||
message AdminGetRoomSeatConfigRequest {
|
||||
RequestMeta meta = 1;
|
||||
}
|
||||
|
||||
message AdminGetRoomSeatConfigResponse {
|
||||
AdminRoomSeatConfig config = 1;
|
||||
int64 server_time_ms = 2;
|
||||
}
|
||||
|
||||
message AdminUpdateRoomSeatConfigRequest {
|
||||
RequestMeta meta = 1;
|
||||
repeated int32 allowed_seat_counts = 2;
|
||||
int32 default_seat_count = 3;
|
||||
}
|
||||
|
||||
message AdminUpdateRoomSeatConfigResponse {
|
||||
AdminRoomSeatConfig config = 1;
|
||||
int64 server_time_ms = 2;
|
||||
}
|
||||
|
||||
message AdminRoomPinRoom {
|
||||
string room_id = 1;
|
||||
string room_short_id = 2;
|
||||
int64 visible_region_id = 3;
|
||||
int64 owner_user_id = 4;
|
||||
string title = 5;
|
||||
string cover_url = 6;
|
||||
string status = 7;
|
||||
}
|
||||
|
||||
message AdminRoomPin {
|
||||
int64 id = 1;
|
||||
int64 visible_region_id = 2;
|
||||
string room_id = 3;
|
||||
int64 weight = 4;
|
||||
string status = 5;
|
||||
int64 pinned_at_ms = 6;
|
||||
int64 expires_at_ms = 7;
|
||||
int64 cancelled_at_ms = 8;
|
||||
uint64 created_by_admin_id = 9;
|
||||
uint64 cancelled_by_admin_id = 10;
|
||||
int64 created_at_ms = 11;
|
||||
int64 updated_at_ms = 12;
|
||||
AdminRoomPinRoom room = 13;
|
||||
}
|
||||
|
||||
message AdminListRoomPinsRequest {
|
||||
RequestMeta meta = 1;
|
||||
int32 page = 2;
|
||||
int32 page_size = 3;
|
||||
string keyword = 4;
|
||||
string status = 5;
|
||||
int64 visible_region_id = 6;
|
||||
}
|
||||
|
||||
message AdminListRoomPinsResponse {
|
||||
repeated AdminRoomPin pins = 1;
|
||||
int64 total = 2;
|
||||
int64 server_time_ms = 3;
|
||||
}
|
||||
|
||||
message AdminCreateRoomPinRequest {
|
||||
RequestMeta meta = 1;
|
||||
string room_id = 2;
|
||||
int64 weight = 3;
|
||||
int64 duration_days = 4;
|
||||
uint64 admin_id = 5;
|
||||
}
|
||||
|
||||
message AdminCreateRoomPinResponse {
|
||||
AdminRoomPin pin = 1;
|
||||
int64 server_time_ms = 2;
|
||||
}
|
||||
|
||||
message AdminCancelRoomPinRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 pin_id = 2;
|
||||
uint64 admin_id = 3;
|
||||
}
|
||||
|
||||
message AdminCancelRoomPinResponse {
|
||||
AdminRoomPin pin = 1;
|
||||
int64 server_time_ms = 2;
|
||||
}
|
||||
|
||||
// RoomSnapshot 把 room-service 对外需要暴露的房间投影集中返回。
|
||||
message RoomSnapshot {
|
||||
string room_id = 1;
|
||||
@ -245,6 +385,85 @@ message CloseRoomResponse {
|
||||
RoomSnapshot room = 2;
|
||||
}
|
||||
|
||||
// AdminRoomListItem 是后台房间管理卡片;它来自 room-service owner 读模型,不允许后台直连房间库。
|
||||
message AdminRoomListItem {
|
||||
string room_id = 1;
|
||||
string room_short_id = 2;
|
||||
int64 visible_region_id = 3;
|
||||
int64 owner_user_id = 4;
|
||||
string title = 5;
|
||||
string cover_url = 6;
|
||||
string mode = 7;
|
||||
string status = 8;
|
||||
string close_reason = 9;
|
||||
uint64 closed_by_admin_id = 10;
|
||||
string closed_by_admin_name = 11;
|
||||
int64 closed_at_ms = 12;
|
||||
int64 heat = 13;
|
||||
int32 online_count = 14;
|
||||
int32 seat_count = 15;
|
||||
int32 occupied_seat_count = 16;
|
||||
int64 sort_score = 17;
|
||||
int64 created_at_ms = 18;
|
||||
int64 updated_at_ms = 19;
|
||||
}
|
||||
|
||||
message AdminListRoomsRequest {
|
||||
RequestMeta meta = 1;
|
||||
int32 page = 2;
|
||||
int32 page_size = 3;
|
||||
string keyword = 4;
|
||||
string status = 5;
|
||||
int64 visible_region_id = 6;
|
||||
string sort_by = 7;
|
||||
string sort_direction = 8;
|
||||
}
|
||||
|
||||
message AdminListRoomsResponse {
|
||||
repeated AdminRoomListItem rooms = 1;
|
||||
int64 total = 2;
|
||||
int64 server_time_ms = 3;
|
||||
}
|
||||
|
||||
message AdminGetRoomRequest {
|
||||
RequestMeta meta = 1;
|
||||
string room_id = 2;
|
||||
}
|
||||
|
||||
message AdminGetRoomResponse {
|
||||
AdminRoomListItem room = 1;
|
||||
int64 server_time_ms = 2;
|
||||
}
|
||||
|
||||
message AdminUpdateRoomRequest {
|
||||
RequestMeta meta = 1;
|
||||
optional string title = 2;
|
||||
optional string cover_url = 3;
|
||||
optional string description = 4;
|
||||
optional string mode = 5;
|
||||
optional string status = 6;
|
||||
optional int64 visible_region_id = 7;
|
||||
string close_reason = 8;
|
||||
uint64 admin_id = 9;
|
||||
string admin_name = 10;
|
||||
}
|
||||
|
||||
message AdminUpdateRoomResponse {
|
||||
CommandResult result = 1;
|
||||
RoomSnapshot room = 2;
|
||||
}
|
||||
|
||||
message AdminDeleteRoomRequest {
|
||||
RequestMeta meta = 1;
|
||||
uint64 admin_id = 2;
|
||||
string admin_name = 3;
|
||||
}
|
||||
|
||||
message AdminDeleteRoomResponse {
|
||||
CommandResult result = 1;
|
||||
RoomSnapshot room = 2;
|
||||
}
|
||||
|
||||
// MicUpRequest 申请把用户放到指定麦位。
|
||||
message MicUpRequest {
|
||||
RequestMeta meta = 1;
|
||||
@ -465,6 +684,7 @@ message SendGiftRequest {
|
||||
// target_type 当前只提交 user;后续扩展 all_mic、all_room、couple 时不再改 HTTP 契约。
|
||||
string target_type = 5;
|
||||
repeated int64 target_user_ids = 6;
|
||||
string pool_id = 7;
|
||||
}
|
||||
|
||||
// SendGiftResponse 返回扣费成功并落到房间后的状态结果。
|
||||
@ -681,6 +901,12 @@ service RoomCommandService {
|
||||
rpc RoomHeartbeat(RoomHeartbeatRequest) returns (RoomHeartbeatResponse);
|
||||
rpc LeaveRoom(LeaveRoomRequest) returns (LeaveRoomResponse);
|
||||
rpc CloseRoom(CloseRoomRequest) returns (CloseRoomResponse);
|
||||
rpc AdminUpdateRoom(AdminUpdateRoomRequest) returns (AdminUpdateRoomResponse);
|
||||
rpc AdminDeleteRoom(AdminDeleteRoomRequest) returns (AdminDeleteRoomResponse);
|
||||
rpc AdminUpdateRoomTreasureConfig(AdminUpdateRoomTreasureConfigRequest) returns (AdminUpdateRoomTreasureConfigResponse);
|
||||
rpc AdminUpdateRoomSeatConfig(AdminUpdateRoomSeatConfigRequest) returns (AdminUpdateRoomSeatConfigResponse);
|
||||
rpc AdminCreateRoomPin(AdminCreateRoomPinRequest) returns (AdminCreateRoomPinResponse);
|
||||
rpc AdminCancelRoomPin(AdminCancelRoomPinRequest) returns (AdminCancelRoomPinResponse);
|
||||
rpc MicUp(MicUpRequest) returns (MicUpResponse);
|
||||
rpc MicDown(MicDownRequest) returns (MicDownResponse);
|
||||
rpc ChangeMicSeat(ChangeMicSeatRequest) returns (ChangeMicSeatResponse);
|
||||
@ -708,6 +934,11 @@ service RoomGuardService {
|
||||
|
||||
// RoomQueryService 承载不会改变 Room Cell 状态的读模型查询。
|
||||
service RoomQueryService {
|
||||
rpc AdminListRooms(AdminListRoomsRequest) returns (AdminListRoomsResponse);
|
||||
rpc AdminGetRoom(AdminGetRoomRequest) returns (AdminGetRoomResponse);
|
||||
rpc AdminGetRoomTreasureConfig(AdminGetRoomTreasureConfigRequest) returns (AdminGetRoomTreasureConfigResponse);
|
||||
rpc AdminGetRoomSeatConfig(AdminGetRoomSeatConfigRequest) returns (AdminGetRoomSeatConfigResponse);
|
||||
rpc AdminListRoomPins(AdminListRoomPinsRequest) returns (AdminListRoomPinsResponse);
|
||||
rpc ListRooms(ListRoomsRequest) returns (ListRoomsResponse);
|
||||
rpc ListRoomFeeds(ListRoomFeedsRequest) returns (ListRoomsResponse);
|
||||
rpc GetMyRoom(GetMyRoomRequest) returns (GetMyRoomResponse);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - protoc (unknown)
|
||||
// - protoc v7.35.0
|
||||
// source: proto/room/v1/room.proto
|
||||
|
||||
package roomv1
|
||||
@ -19,29 +19,35 @@ import (
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
RoomCommandService_CreateRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/CreateRoom"
|
||||
RoomCommandService_UpdateRoomProfile_FullMethodName = "/hyapp.room.v1.RoomCommandService/UpdateRoomProfile"
|
||||
RoomCommandService_JoinRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/JoinRoom"
|
||||
RoomCommandService_RoomHeartbeat_FullMethodName = "/hyapp.room.v1.RoomCommandService/RoomHeartbeat"
|
||||
RoomCommandService_LeaveRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/LeaveRoom"
|
||||
RoomCommandService_CloseRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/CloseRoom"
|
||||
RoomCommandService_MicUp_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicUp"
|
||||
RoomCommandService_MicDown_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicDown"
|
||||
RoomCommandService_ChangeMicSeat_FullMethodName = "/hyapp.room.v1.RoomCommandService/ChangeMicSeat"
|
||||
RoomCommandService_ConfirmMicPublishing_FullMethodName = "/hyapp.room.v1.RoomCommandService/ConfirmMicPublishing"
|
||||
RoomCommandService_SetMicMute_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetMicMute"
|
||||
RoomCommandService_ApplyRTCEvent_FullMethodName = "/hyapp.room.v1.RoomCommandService/ApplyRTCEvent"
|
||||
RoomCommandService_SetMicSeatLock_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetMicSeatLock"
|
||||
RoomCommandService_SetChatEnabled_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetChatEnabled"
|
||||
RoomCommandService_SetRoomPassword_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetRoomPassword"
|
||||
RoomCommandService_SetRoomAdmin_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetRoomAdmin"
|
||||
RoomCommandService_MuteUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/MuteUser"
|
||||
RoomCommandService_KickUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/KickUser"
|
||||
RoomCommandService_UnbanUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/UnbanUser"
|
||||
RoomCommandService_SystemEvictUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/SystemEvictUser"
|
||||
RoomCommandService_SendGift_FullMethodName = "/hyapp.room.v1.RoomCommandService/SendGift"
|
||||
RoomCommandService_FollowRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/FollowRoom"
|
||||
RoomCommandService_UnfollowRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/UnfollowRoom"
|
||||
RoomCommandService_CreateRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/CreateRoom"
|
||||
RoomCommandService_UpdateRoomProfile_FullMethodName = "/hyapp.room.v1.RoomCommandService/UpdateRoomProfile"
|
||||
RoomCommandService_JoinRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/JoinRoom"
|
||||
RoomCommandService_RoomHeartbeat_FullMethodName = "/hyapp.room.v1.RoomCommandService/RoomHeartbeat"
|
||||
RoomCommandService_LeaveRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/LeaveRoom"
|
||||
RoomCommandService_CloseRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/CloseRoom"
|
||||
RoomCommandService_AdminUpdateRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminUpdateRoom"
|
||||
RoomCommandService_AdminDeleteRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminDeleteRoom"
|
||||
RoomCommandService_AdminUpdateRoomTreasureConfig_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminUpdateRoomTreasureConfig"
|
||||
RoomCommandService_AdminUpdateRoomSeatConfig_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminUpdateRoomSeatConfig"
|
||||
RoomCommandService_AdminCreateRoomPin_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminCreateRoomPin"
|
||||
RoomCommandService_AdminCancelRoomPin_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminCancelRoomPin"
|
||||
RoomCommandService_MicUp_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicUp"
|
||||
RoomCommandService_MicDown_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicDown"
|
||||
RoomCommandService_ChangeMicSeat_FullMethodName = "/hyapp.room.v1.RoomCommandService/ChangeMicSeat"
|
||||
RoomCommandService_ConfirmMicPublishing_FullMethodName = "/hyapp.room.v1.RoomCommandService/ConfirmMicPublishing"
|
||||
RoomCommandService_SetMicMute_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetMicMute"
|
||||
RoomCommandService_ApplyRTCEvent_FullMethodName = "/hyapp.room.v1.RoomCommandService/ApplyRTCEvent"
|
||||
RoomCommandService_SetMicSeatLock_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetMicSeatLock"
|
||||
RoomCommandService_SetChatEnabled_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetChatEnabled"
|
||||
RoomCommandService_SetRoomPassword_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetRoomPassword"
|
||||
RoomCommandService_SetRoomAdmin_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetRoomAdmin"
|
||||
RoomCommandService_MuteUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/MuteUser"
|
||||
RoomCommandService_KickUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/KickUser"
|
||||
RoomCommandService_UnbanUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/UnbanUser"
|
||||
RoomCommandService_SystemEvictUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/SystemEvictUser"
|
||||
RoomCommandService_SendGift_FullMethodName = "/hyapp.room.v1.RoomCommandService/SendGift"
|
||||
RoomCommandService_FollowRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/FollowRoom"
|
||||
RoomCommandService_UnfollowRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/UnfollowRoom"
|
||||
)
|
||||
|
||||
// RoomCommandServiceClient is the client API for RoomCommandService service.
|
||||
@ -56,6 +62,12 @@ type RoomCommandServiceClient interface {
|
||||
RoomHeartbeat(ctx context.Context, in *RoomHeartbeatRequest, opts ...grpc.CallOption) (*RoomHeartbeatResponse, error)
|
||||
LeaveRoom(ctx context.Context, in *LeaveRoomRequest, opts ...grpc.CallOption) (*LeaveRoomResponse, error)
|
||||
CloseRoom(ctx context.Context, in *CloseRoomRequest, opts ...grpc.CallOption) (*CloseRoomResponse, error)
|
||||
AdminUpdateRoom(ctx context.Context, in *AdminUpdateRoomRequest, opts ...grpc.CallOption) (*AdminUpdateRoomResponse, error)
|
||||
AdminDeleteRoom(ctx context.Context, in *AdminDeleteRoomRequest, opts ...grpc.CallOption) (*AdminDeleteRoomResponse, error)
|
||||
AdminUpdateRoomTreasureConfig(ctx context.Context, in *AdminUpdateRoomTreasureConfigRequest, opts ...grpc.CallOption) (*AdminUpdateRoomTreasureConfigResponse, error)
|
||||
AdminUpdateRoomSeatConfig(ctx context.Context, in *AdminUpdateRoomSeatConfigRequest, opts ...grpc.CallOption) (*AdminUpdateRoomSeatConfigResponse, error)
|
||||
AdminCreateRoomPin(ctx context.Context, in *AdminCreateRoomPinRequest, opts ...grpc.CallOption) (*AdminCreateRoomPinResponse, error)
|
||||
AdminCancelRoomPin(ctx context.Context, in *AdminCancelRoomPinRequest, opts ...grpc.CallOption) (*AdminCancelRoomPinResponse, error)
|
||||
MicUp(ctx context.Context, in *MicUpRequest, opts ...grpc.CallOption) (*MicUpResponse, error)
|
||||
MicDown(ctx context.Context, in *MicDownRequest, opts ...grpc.CallOption) (*MicDownResponse, error)
|
||||
ChangeMicSeat(ctx context.Context, in *ChangeMicSeatRequest, opts ...grpc.CallOption) (*ChangeMicSeatResponse, error)
|
||||
@ -143,6 +155,66 @@ func (c *roomCommandServiceClient) CloseRoom(ctx context.Context, in *CloseRoomR
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomCommandServiceClient) AdminUpdateRoom(ctx context.Context, in *AdminUpdateRoomRequest, opts ...grpc.CallOption) (*AdminUpdateRoomResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AdminUpdateRoomResponse)
|
||||
err := c.cc.Invoke(ctx, RoomCommandService_AdminUpdateRoom_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomCommandServiceClient) AdminDeleteRoom(ctx context.Context, in *AdminDeleteRoomRequest, opts ...grpc.CallOption) (*AdminDeleteRoomResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AdminDeleteRoomResponse)
|
||||
err := c.cc.Invoke(ctx, RoomCommandService_AdminDeleteRoom_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomCommandServiceClient) AdminUpdateRoomTreasureConfig(ctx context.Context, in *AdminUpdateRoomTreasureConfigRequest, opts ...grpc.CallOption) (*AdminUpdateRoomTreasureConfigResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AdminUpdateRoomTreasureConfigResponse)
|
||||
err := c.cc.Invoke(ctx, RoomCommandService_AdminUpdateRoomTreasureConfig_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomCommandServiceClient) AdminUpdateRoomSeatConfig(ctx context.Context, in *AdminUpdateRoomSeatConfigRequest, opts ...grpc.CallOption) (*AdminUpdateRoomSeatConfigResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AdminUpdateRoomSeatConfigResponse)
|
||||
err := c.cc.Invoke(ctx, RoomCommandService_AdminUpdateRoomSeatConfig_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomCommandServiceClient) AdminCreateRoomPin(ctx context.Context, in *AdminCreateRoomPinRequest, opts ...grpc.CallOption) (*AdminCreateRoomPinResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AdminCreateRoomPinResponse)
|
||||
err := c.cc.Invoke(ctx, RoomCommandService_AdminCreateRoomPin_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomCommandServiceClient) AdminCancelRoomPin(ctx context.Context, in *AdminCancelRoomPinRequest, opts ...grpc.CallOption) (*AdminCancelRoomPinResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AdminCancelRoomPinResponse)
|
||||
err := c.cc.Invoke(ctx, RoomCommandService_AdminCancelRoomPin_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomCommandServiceClient) MicUp(ctx context.Context, in *MicUpRequest, opts ...grpc.CallOption) (*MicUpResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(MicUpResponse)
|
||||
@ -325,6 +397,12 @@ type RoomCommandServiceServer interface {
|
||||
RoomHeartbeat(context.Context, *RoomHeartbeatRequest) (*RoomHeartbeatResponse, error)
|
||||
LeaveRoom(context.Context, *LeaveRoomRequest) (*LeaveRoomResponse, error)
|
||||
CloseRoom(context.Context, *CloseRoomRequest) (*CloseRoomResponse, error)
|
||||
AdminUpdateRoom(context.Context, *AdminUpdateRoomRequest) (*AdminUpdateRoomResponse, error)
|
||||
AdminDeleteRoom(context.Context, *AdminDeleteRoomRequest) (*AdminDeleteRoomResponse, error)
|
||||
AdminUpdateRoomTreasureConfig(context.Context, *AdminUpdateRoomTreasureConfigRequest) (*AdminUpdateRoomTreasureConfigResponse, error)
|
||||
AdminUpdateRoomSeatConfig(context.Context, *AdminUpdateRoomSeatConfigRequest) (*AdminUpdateRoomSeatConfigResponse, error)
|
||||
AdminCreateRoomPin(context.Context, *AdminCreateRoomPinRequest) (*AdminCreateRoomPinResponse, error)
|
||||
AdminCancelRoomPin(context.Context, *AdminCancelRoomPinRequest) (*AdminCancelRoomPinResponse, error)
|
||||
MicUp(context.Context, *MicUpRequest) (*MicUpResponse, error)
|
||||
MicDown(context.Context, *MicDownRequest) (*MicDownResponse, error)
|
||||
ChangeMicSeat(context.Context, *ChangeMicSeatRequest) (*ChangeMicSeatResponse, error)
|
||||
@ -370,6 +448,24 @@ func (UnimplementedRoomCommandServiceServer) LeaveRoom(context.Context, *LeaveRo
|
||||
func (UnimplementedRoomCommandServiceServer) CloseRoom(context.Context, *CloseRoomRequest) (*CloseRoomResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method CloseRoom not implemented")
|
||||
}
|
||||
func (UnimplementedRoomCommandServiceServer) AdminUpdateRoom(context.Context, *AdminUpdateRoomRequest) (*AdminUpdateRoomResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AdminUpdateRoom not implemented")
|
||||
}
|
||||
func (UnimplementedRoomCommandServiceServer) AdminDeleteRoom(context.Context, *AdminDeleteRoomRequest) (*AdminDeleteRoomResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AdminDeleteRoom not implemented")
|
||||
}
|
||||
func (UnimplementedRoomCommandServiceServer) AdminUpdateRoomTreasureConfig(context.Context, *AdminUpdateRoomTreasureConfigRequest) (*AdminUpdateRoomTreasureConfigResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AdminUpdateRoomTreasureConfig not implemented")
|
||||
}
|
||||
func (UnimplementedRoomCommandServiceServer) AdminUpdateRoomSeatConfig(context.Context, *AdminUpdateRoomSeatConfigRequest) (*AdminUpdateRoomSeatConfigResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AdminUpdateRoomSeatConfig not implemented")
|
||||
}
|
||||
func (UnimplementedRoomCommandServiceServer) AdminCreateRoomPin(context.Context, *AdminCreateRoomPinRequest) (*AdminCreateRoomPinResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AdminCreateRoomPin not implemented")
|
||||
}
|
||||
func (UnimplementedRoomCommandServiceServer) AdminCancelRoomPin(context.Context, *AdminCancelRoomPinRequest) (*AdminCancelRoomPinResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AdminCancelRoomPin not implemented")
|
||||
}
|
||||
func (UnimplementedRoomCommandServiceServer) MicUp(context.Context, *MicUpRequest) (*MicUpResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method MicUp not implemented")
|
||||
}
|
||||
@ -550,6 +646,114 @@ func _RoomCommandService_CloseRoom_Handler(srv interface{}, ctx context.Context,
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomCommandService_AdminUpdateRoom_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AdminUpdateRoomRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(RoomCommandServiceServer).AdminUpdateRoom(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: RoomCommandService_AdminUpdateRoom_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(RoomCommandServiceServer).AdminUpdateRoom(ctx, req.(*AdminUpdateRoomRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomCommandService_AdminDeleteRoom_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AdminDeleteRoomRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(RoomCommandServiceServer).AdminDeleteRoom(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: RoomCommandService_AdminDeleteRoom_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(RoomCommandServiceServer).AdminDeleteRoom(ctx, req.(*AdminDeleteRoomRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomCommandService_AdminUpdateRoomTreasureConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AdminUpdateRoomTreasureConfigRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(RoomCommandServiceServer).AdminUpdateRoomTreasureConfig(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: RoomCommandService_AdminUpdateRoomTreasureConfig_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(RoomCommandServiceServer).AdminUpdateRoomTreasureConfig(ctx, req.(*AdminUpdateRoomTreasureConfigRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomCommandService_AdminUpdateRoomSeatConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AdminUpdateRoomSeatConfigRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(RoomCommandServiceServer).AdminUpdateRoomSeatConfig(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: RoomCommandService_AdminUpdateRoomSeatConfig_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(RoomCommandServiceServer).AdminUpdateRoomSeatConfig(ctx, req.(*AdminUpdateRoomSeatConfigRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomCommandService_AdminCreateRoomPin_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AdminCreateRoomPinRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(RoomCommandServiceServer).AdminCreateRoomPin(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: RoomCommandService_AdminCreateRoomPin_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(RoomCommandServiceServer).AdminCreateRoomPin(ctx, req.(*AdminCreateRoomPinRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomCommandService_AdminCancelRoomPin_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AdminCancelRoomPinRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(RoomCommandServiceServer).AdminCancelRoomPin(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: RoomCommandService_AdminCancelRoomPin_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(RoomCommandServiceServer).AdminCancelRoomPin(ctx, req.(*AdminCancelRoomPinRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomCommandService_MicUp_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(MicUpRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -887,6 +1091,30 @@ var RoomCommandService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "CloseRoom",
|
||||
Handler: _RoomCommandService_CloseRoom_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AdminUpdateRoom",
|
||||
Handler: _RoomCommandService_AdminUpdateRoom_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AdminDeleteRoom",
|
||||
Handler: _RoomCommandService_AdminDeleteRoom_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AdminUpdateRoomTreasureConfig",
|
||||
Handler: _RoomCommandService_AdminUpdateRoomTreasureConfig_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AdminUpdateRoomSeatConfig",
|
||||
Handler: _RoomCommandService_AdminUpdateRoomSeatConfig_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AdminCreateRoomPin",
|
||||
Handler: _RoomCommandService_AdminCreateRoomPin_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AdminCancelRoomPin",
|
||||
Handler: _RoomCommandService_AdminCancelRoomPin_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "MicUp",
|
||||
Handler: _RoomCommandService_MicUp_Handler,
|
||||
@ -1105,13 +1333,18 @@ var RoomGuardService_ServiceDesc = grpc.ServiceDesc{
|
||||
}
|
||||
|
||||
const (
|
||||
RoomQueryService_ListRooms_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRooms"
|
||||
RoomQueryService_ListRoomFeeds_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomFeeds"
|
||||
RoomQueryService_GetMyRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetMyRoom"
|
||||
RoomQueryService_GetCurrentRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetCurrentRoom"
|
||||
RoomQueryService_GetRoomSnapshot_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetRoomSnapshot"
|
||||
RoomQueryService_GetRoomTreasure_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetRoomTreasure"
|
||||
RoomQueryService_ListRoomOnlineUsers_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomOnlineUsers"
|
||||
RoomQueryService_AdminListRooms_FullMethodName = "/hyapp.room.v1.RoomQueryService/AdminListRooms"
|
||||
RoomQueryService_AdminGetRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/AdminGetRoom"
|
||||
RoomQueryService_AdminGetRoomTreasureConfig_FullMethodName = "/hyapp.room.v1.RoomQueryService/AdminGetRoomTreasureConfig"
|
||||
RoomQueryService_AdminGetRoomSeatConfig_FullMethodName = "/hyapp.room.v1.RoomQueryService/AdminGetRoomSeatConfig"
|
||||
RoomQueryService_AdminListRoomPins_FullMethodName = "/hyapp.room.v1.RoomQueryService/AdminListRoomPins"
|
||||
RoomQueryService_ListRooms_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRooms"
|
||||
RoomQueryService_ListRoomFeeds_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomFeeds"
|
||||
RoomQueryService_GetMyRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetMyRoom"
|
||||
RoomQueryService_GetCurrentRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetCurrentRoom"
|
||||
RoomQueryService_GetRoomSnapshot_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetRoomSnapshot"
|
||||
RoomQueryService_GetRoomTreasure_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetRoomTreasure"
|
||||
RoomQueryService_ListRoomOnlineUsers_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomOnlineUsers"
|
||||
)
|
||||
|
||||
// RoomQueryServiceClient is the client API for RoomQueryService service.
|
||||
@ -1120,6 +1353,11 @@ const (
|
||||
//
|
||||
// RoomQueryService 承载不会改变 Room Cell 状态的读模型查询。
|
||||
type RoomQueryServiceClient interface {
|
||||
AdminListRooms(ctx context.Context, in *AdminListRoomsRequest, opts ...grpc.CallOption) (*AdminListRoomsResponse, error)
|
||||
AdminGetRoom(ctx context.Context, in *AdminGetRoomRequest, opts ...grpc.CallOption) (*AdminGetRoomResponse, error)
|
||||
AdminGetRoomTreasureConfig(ctx context.Context, in *AdminGetRoomTreasureConfigRequest, opts ...grpc.CallOption) (*AdminGetRoomTreasureConfigResponse, error)
|
||||
AdminGetRoomSeatConfig(ctx context.Context, in *AdminGetRoomSeatConfigRequest, opts ...grpc.CallOption) (*AdminGetRoomSeatConfigResponse, error)
|
||||
AdminListRoomPins(ctx context.Context, in *AdminListRoomPinsRequest, opts ...grpc.CallOption) (*AdminListRoomPinsResponse, error)
|
||||
ListRooms(ctx context.Context, in *ListRoomsRequest, opts ...grpc.CallOption) (*ListRoomsResponse, error)
|
||||
ListRoomFeeds(ctx context.Context, in *ListRoomFeedsRequest, opts ...grpc.CallOption) (*ListRoomsResponse, error)
|
||||
GetMyRoom(ctx context.Context, in *GetMyRoomRequest, opts ...grpc.CallOption) (*GetMyRoomResponse, error)
|
||||
@ -1137,6 +1375,56 @@ func NewRoomQueryServiceClient(cc grpc.ClientConnInterface) RoomQueryServiceClie
|
||||
return &roomQueryServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *roomQueryServiceClient) AdminListRooms(ctx context.Context, in *AdminListRoomsRequest, opts ...grpc.CallOption) (*AdminListRoomsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AdminListRoomsResponse)
|
||||
err := c.cc.Invoke(ctx, RoomQueryService_AdminListRooms_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomQueryServiceClient) AdminGetRoom(ctx context.Context, in *AdminGetRoomRequest, opts ...grpc.CallOption) (*AdminGetRoomResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AdminGetRoomResponse)
|
||||
err := c.cc.Invoke(ctx, RoomQueryService_AdminGetRoom_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomQueryServiceClient) AdminGetRoomTreasureConfig(ctx context.Context, in *AdminGetRoomTreasureConfigRequest, opts ...grpc.CallOption) (*AdminGetRoomTreasureConfigResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AdminGetRoomTreasureConfigResponse)
|
||||
err := c.cc.Invoke(ctx, RoomQueryService_AdminGetRoomTreasureConfig_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomQueryServiceClient) AdminGetRoomSeatConfig(ctx context.Context, in *AdminGetRoomSeatConfigRequest, opts ...grpc.CallOption) (*AdminGetRoomSeatConfigResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AdminGetRoomSeatConfigResponse)
|
||||
err := c.cc.Invoke(ctx, RoomQueryService_AdminGetRoomSeatConfig_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomQueryServiceClient) AdminListRoomPins(ctx context.Context, in *AdminListRoomPinsRequest, opts ...grpc.CallOption) (*AdminListRoomPinsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AdminListRoomPinsResponse)
|
||||
err := c.cc.Invoke(ctx, RoomQueryService_AdminListRoomPins_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomQueryServiceClient) ListRooms(ctx context.Context, in *ListRoomsRequest, opts ...grpc.CallOption) (*ListRoomsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListRoomsResponse)
|
||||
@ -1213,6 +1501,11 @@ func (c *roomQueryServiceClient) ListRoomOnlineUsers(ctx context.Context, in *Li
|
||||
//
|
||||
// RoomQueryService 承载不会改变 Room Cell 状态的读模型查询。
|
||||
type RoomQueryServiceServer interface {
|
||||
AdminListRooms(context.Context, *AdminListRoomsRequest) (*AdminListRoomsResponse, error)
|
||||
AdminGetRoom(context.Context, *AdminGetRoomRequest) (*AdminGetRoomResponse, error)
|
||||
AdminGetRoomTreasureConfig(context.Context, *AdminGetRoomTreasureConfigRequest) (*AdminGetRoomTreasureConfigResponse, error)
|
||||
AdminGetRoomSeatConfig(context.Context, *AdminGetRoomSeatConfigRequest) (*AdminGetRoomSeatConfigResponse, error)
|
||||
AdminListRoomPins(context.Context, *AdminListRoomPinsRequest) (*AdminListRoomPinsResponse, error)
|
||||
ListRooms(context.Context, *ListRoomsRequest) (*ListRoomsResponse, error)
|
||||
ListRoomFeeds(context.Context, *ListRoomFeedsRequest) (*ListRoomsResponse, error)
|
||||
GetMyRoom(context.Context, *GetMyRoomRequest) (*GetMyRoomResponse, error)
|
||||
@ -1230,6 +1523,21 @@ type RoomQueryServiceServer interface {
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedRoomQueryServiceServer struct{}
|
||||
|
||||
func (UnimplementedRoomQueryServiceServer) AdminListRooms(context.Context, *AdminListRoomsRequest) (*AdminListRoomsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AdminListRooms not implemented")
|
||||
}
|
||||
func (UnimplementedRoomQueryServiceServer) AdminGetRoom(context.Context, *AdminGetRoomRequest) (*AdminGetRoomResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AdminGetRoom not implemented")
|
||||
}
|
||||
func (UnimplementedRoomQueryServiceServer) AdminGetRoomTreasureConfig(context.Context, *AdminGetRoomTreasureConfigRequest) (*AdminGetRoomTreasureConfigResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AdminGetRoomTreasureConfig not implemented")
|
||||
}
|
||||
func (UnimplementedRoomQueryServiceServer) AdminGetRoomSeatConfig(context.Context, *AdminGetRoomSeatConfigRequest) (*AdminGetRoomSeatConfigResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AdminGetRoomSeatConfig not implemented")
|
||||
}
|
||||
func (UnimplementedRoomQueryServiceServer) AdminListRoomPins(context.Context, *AdminListRoomPinsRequest) (*AdminListRoomPinsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AdminListRoomPins not implemented")
|
||||
}
|
||||
func (UnimplementedRoomQueryServiceServer) ListRooms(context.Context, *ListRoomsRequest) (*ListRoomsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListRooms not implemented")
|
||||
}
|
||||
@ -1272,6 +1580,96 @@ func RegisterRoomQueryServiceServer(s grpc.ServiceRegistrar, srv RoomQueryServic
|
||||
s.RegisterService(&RoomQueryService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _RoomQueryService_AdminListRooms_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AdminListRoomsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(RoomQueryServiceServer).AdminListRooms(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: RoomQueryService_AdminListRooms_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(RoomQueryServiceServer).AdminListRooms(ctx, req.(*AdminListRoomsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomQueryService_AdminGetRoom_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AdminGetRoomRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(RoomQueryServiceServer).AdminGetRoom(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: RoomQueryService_AdminGetRoom_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(RoomQueryServiceServer).AdminGetRoom(ctx, req.(*AdminGetRoomRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomQueryService_AdminGetRoomTreasureConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AdminGetRoomTreasureConfigRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(RoomQueryServiceServer).AdminGetRoomTreasureConfig(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: RoomQueryService_AdminGetRoomTreasureConfig_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(RoomQueryServiceServer).AdminGetRoomTreasureConfig(ctx, req.(*AdminGetRoomTreasureConfigRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomQueryService_AdminGetRoomSeatConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AdminGetRoomSeatConfigRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(RoomQueryServiceServer).AdminGetRoomSeatConfig(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: RoomQueryService_AdminGetRoomSeatConfig_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(RoomQueryServiceServer).AdminGetRoomSeatConfig(ctx, req.(*AdminGetRoomSeatConfigRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomQueryService_AdminListRoomPins_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AdminListRoomPinsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(RoomQueryServiceServer).AdminListRoomPins(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: RoomQueryService_AdminListRoomPins_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(RoomQueryServiceServer).AdminListRoomPins(ctx, req.(*AdminListRoomPinsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomQueryService_ListRooms_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListRoomsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -1405,6 +1803,26 @@ var RoomQueryService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "hyapp.room.v1.RoomQueryService",
|
||||
HandlerType: (*RoomQueryServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "AdminListRooms",
|
||||
Handler: _RoomQueryService_AdminListRooms_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AdminGetRoom",
|
||||
Handler: _RoomQueryService_AdminGetRoom_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AdminGetRoomTreasureConfig",
|
||||
Handler: _RoomQueryService_AdminGetRoomTreasureConfig_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AdminGetRoomSeatConfig",
|
||||
Handler: _RoomQueryService_AdminGetRoomSeatConfig_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AdminListRoomPins",
|
||||
Handler: _RoomQueryService_AdminListRoomPins_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListRooms",
|
||||
Handler: _RoomQueryService_ListRooms_Handler,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v5.28.3
|
||||
// protoc v7.35.0
|
||||
// source: proto/user/v1/auth.proto
|
||||
|
||||
package userv1
|
||||
@ -454,6 +454,248 @@ func (x *SetPasswordResponse) GetPasswordSet() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// QuickCreateAccountRequest 为测试和三方联调快速创建一个可密码登录的完整账号。
|
||||
type QuickCreateAccountRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"`
|
||||
Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"`
|
||||
Avatar string `protobuf:"bytes,4,opt,name=avatar,proto3" json:"avatar,omitempty"`
|
||||
Gender string `protobuf:"bytes,5,opt,name=gender,proto3" json:"gender,omitempty"`
|
||||
Country string `protobuf:"bytes,6,opt,name=country,proto3" json:"country,omitempty"`
|
||||
InviteCode string `protobuf:"bytes,7,opt,name=invite_code,json=inviteCode,proto3" json:"invite_code,omitempty"`
|
||||
DeviceId string `protobuf:"bytes,8,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"`
|
||||
Device string `protobuf:"bytes,9,opt,name=device,proto3" json:"device,omitempty"`
|
||||
OsVersion string `protobuf:"bytes,10,opt,name=os_version,json=osVersion,proto3" json:"os_version,omitempty"`
|
||||
Birth string `protobuf:"bytes,11,opt,name=birth,proto3" json:"birth,omitempty"`
|
||||
AppVersion string `protobuf:"bytes,12,opt,name=app_version,json=appVersion,proto3" json:"app_version,omitempty"`
|
||||
BuildNumber string `protobuf:"bytes,13,opt,name=build_number,json=buildNumber,proto3" json:"build_number,omitempty"`
|
||||
Source string `protobuf:"bytes,14,opt,name=source,proto3" json:"source,omitempty"`
|
||||
InstallChannel string `protobuf:"bytes,15,opt,name=install_channel,json=installChannel,proto3" json:"install_channel,omitempty"`
|
||||
Campaign string `protobuf:"bytes,16,opt,name=campaign,proto3" json:"campaign,omitempty"`
|
||||
Platform string `protobuf:"bytes,17,opt,name=platform,proto3" json:"platform,omitempty"`
|
||||
Language string `protobuf:"bytes,18,opt,name=language,proto3" json:"language,omitempty"`
|
||||
Timezone string `protobuf:"bytes,19,opt,name=timezone,proto3" json:"timezone,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *QuickCreateAccountRequest) Reset() {
|
||||
*x = QuickCreateAccountRequest{}
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *QuickCreateAccountRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*QuickCreateAccountRequest) ProtoMessage() {}
|
||||
|
||||
func (x *QuickCreateAccountRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[5]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use QuickCreateAccountRequest.ProtoReflect.Descriptor instead.
|
||||
func (*QuickCreateAccountRequest) Descriptor() ([]byte, []int) {
|
||||
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
func (x *QuickCreateAccountRequest) GetMeta() *RequestMeta {
|
||||
if x != nil {
|
||||
return x.Meta
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *QuickCreateAccountRequest) GetPassword() string {
|
||||
if x != nil {
|
||||
return x.Password
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *QuickCreateAccountRequest) GetUsername() string {
|
||||
if x != nil {
|
||||
return x.Username
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *QuickCreateAccountRequest) GetAvatar() string {
|
||||
if x != nil {
|
||||
return x.Avatar
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *QuickCreateAccountRequest) GetGender() string {
|
||||
if x != nil {
|
||||
return x.Gender
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *QuickCreateAccountRequest) GetCountry() string {
|
||||
if x != nil {
|
||||
return x.Country
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *QuickCreateAccountRequest) GetInviteCode() string {
|
||||
if x != nil {
|
||||
return x.InviteCode
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *QuickCreateAccountRequest) GetDeviceId() string {
|
||||
if x != nil {
|
||||
return x.DeviceId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *QuickCreateAccountRequest) GetDevice() string {
|
||||
if x != nil {
|
||||
return x.Device
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *QuickCreateAccountRequest) GetOsVersion() string {
|
||||
if x != nil {
|
||||
return x.OsVersion
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *QuickCreateAccountRequest) GetBirth() string {
|
||||
if x != nil {
|
||||
return x.Birth
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *QuickCreateAccountRequest) GetAppVersion() string {
|
||||
if x != nil {
|
||||
return x.AppVersion
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *QuickCreateAccountRequest) GetBuildNumber() string {
|
||||
if x != nil {
|
||||
return x.BuildNumber
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *QuickCreateAccountRequest) GetSource() string {
|
||||
if x != nil {
|
||||
return x.Source
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *QuickCreateAccountRequest) GetInstallChannel() string {
|
||||
if x != nil {
|
||||
return x.InstallChannel
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *QuickCreateAccountRequest) GetCampaign() string {
|
||||
if x != nil {
|
||||
return x.Campaign
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *QuickCreateAccountRequest) GetPlatform() string {
|
||||
if x != nil {
|
||||
return x.Platform
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *QuickCreateAccountRequest) GetLanguage() string {
|
||||
if x != nil {
|
||||
return x.Language
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *QuickCreateAccountRequest) GetTimezone() string {
|
||||
if x != nil {
|
||||
return x.Timezone
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// QuickCreateAccountResponse 返回新账号登录态;display_user_id 即后续账号密码登录用账号。
|
||||
type QuickCreateAccountResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Token *AuthToken `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"`
|
||||
PasswordSet bool `protobuf:"varint,2,opt,name=password_set,json=passwordSet,proto3" json:"password_set,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *QuickCreateAccountResponse) Reset() {
|
||||
*x = QuickCreateAccountResponse{}
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *QuickCreateAccountResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*QuickCreateAccountResponse) ProtoMessage() {}
|
||||
|
||||
func (x *QuickCreateAccountResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[6]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use QuickCreateAccountResponse.ProtoReflect.Descriptor instead.
|
||||
func (*QuickCreateAccountResponse) Descriptor() ([]byte, []int) {
|
||||
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{6}
|
||||
}
|
||||
|
||||
func (x *QuickCreateAccountResponse) GetToken() *AuthToken {
|
||||
if x != nil {
|
||||
return x.Token
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *QuickCreateAccountResponse) GetPasswordSet() bool {
|
||||
if x != nil {
|
||||
return x.PasswordSet
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RefreshTokenRequest 使用 refresh token 换取新 token。
|
||||
type RefreshTokenRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@ -465,7 +707,7 @@ type RefreshTokenRequest struct {
|
||||
|
||||
func (x *RefreshTokenRequest) Reset() {
|
||||
*x = RefreshTokenRequest{}
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[5]
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -477,7 +719,7 @@ func (x *RefreshTokenRequest) String() string {
|
||||
func (*RefreshTokenRequest) ProtoMessage() {}
|
||||
|
||||
func (x *RefreshTokenRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[5]
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[7]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -490,7 +732,7 @@ func (x *RefreshTokenRequest) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use RefreshTokenRequest.ProtoReflect.Descriptor instead.
|
||||
func (*RefreshTokenRequest) Descriptor() ([]byte, []int) {
|
||||
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{5}
|
||||
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{7}
|
||||
}
|
||||
|
||||
func (x *RefreshTokenRequest) GetMeta() *RequestMeta {
|
||||
@ -517,7 +759,7 @@ type RefreshTokenResponse struct {
|
||||
|
||||
func (x *RefreshTokenResponse) Reset() {
|
||||
*x = RefreshTokenResponse{}
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[6]
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -529,7 +771,7 @@ func (x *RefreshTokenResponse) String() string {
|
||||
func (*RefreshTokenResponse) ProtoMessage() {}
|
||||
|
||||
func (x *RefreshTokenResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[6]
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[8]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -542,7 +784,7 @@ func (x *RefreshTokenResponse) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use RefreshTokenResponse.ProtoReflect.Descriptor instead.
|
||||
func (*RefreshTokenResponse) Descriptor() ([]byte, []int) {
|
||||
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{6}
|
||||
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{8}
|
||||
}
|
||||
|
||||
func (x *RefreshTokenResponse) GetToken() *AuthToken {
|
||||
@ -564,7 +806,7 @@ type LogoutRequest struct {
|
||||
|
||||
func (x *LogoutRequest) Reset() {
|
||||
*x = LogoutRequest{}
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[7]
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[9]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -576,7 +818,7 @@ func (x *LogoutRequest) String() string {
|
||||
func (*LogoutRequest) ProtoMessage() {}
|
||||
|
||||
func (x *LogoutRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[7]
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[9]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -589,7 +831,7 @@ func (x *LogoutRequest) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead.
|
||||
func (*LogoutRequest) Descriptor() ([]byte, []int) {
|
||||
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{7}
|
||||
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{9}
|
||||
}
|
||||
|
||||
func (x *LogoutRequest) GetMeta() *RequestMeta {
|
||||
@ -623,7 +865,7 @@ type LogoutResponse struct {
|
||||
|
||||
func (x *LogoutResponse) Reset() {
|
||||
*x = LogoutResponse{}
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[8]
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[10]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -635,7 +877,7 @@ func (x *LogoutResponse) String() string {
|
||||
func (*LogoutResponse) ProtoMessage() {}
|
||||
|
||||
func (x *LogoutResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[8]
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[10]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -648,7 +890,7 @@ func (x *LogoutResponse) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead.
|
||||
func (*LogoutResponse) Descriptor() ([]byte, []int) {
|
||||
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{8}
|
||||
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{10}
|
||||
}
|
||||
|
||||
func (x *LogoutResponse) GetRevoked() bool {
|
||||
@ -673,7 +915,7 @@ type RecordLoginBlockedRequest struct {
|
||||
|
||||
func (x *RecordLoginBlockedRequest) Reset() {
|
||||
*x = RecordLoginBlockedRequest{}
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[9]
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[11]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -685,7 +927,7 @@ func (x *RecordLoginBlockedRequest) String() string {
|
||||
func (*RecordLoginBlockedRequest) ProtoMessage() {}
|
||||
|
||||
func (x *RecordLoginBlockedRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[9]
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[11]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -698,7 +940,7 @@ func (x *RecordLoginBlockedRequest) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use RecordLoginBlockedRequest.ProtoReflect.Descriptor instead.
|
||||
func (*RecordLoginBlockedRequest) Descriptor() ([]byte, []int) {
|
||||
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{9}
|
||||
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{11}
|
||||
}
|
||||
|
||||
func (x *RecordLoginBlockedRequest) GetMeta() *RequestMeta {
|
||||
@ -753,7 +995,7 @@ type RecordLoginBlockedResponse struct {
|
||||
|
||||
func (x *RecordLoginBlockedResponse) Reset() {
|
||||
*x = RecordLoginBlockedResponse{}
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[10]
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[12]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -765,7 +1007,7 @@ func (x *RecordLoginBlockedResponse) String() string {
|
||||
func (*RecordLoginBlockedResponse) ProtoMessage() {}
|
||||
|
||||
func (x *RecordLoginBlockedResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[10]
|
||||
mi := &file_proto_user_v1_auth_proto_msgTypes[12]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -778,7 +1020,7 @@ func (x *RecordLoginBlockedResponse) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use RecordLoginBlockedResponse.ProtoReflect.Descriptor instead.
|
||||
func (*RecordLoginBlockedResponse) Descriptor() ([]byte, []int) {
|
||||
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{10}
|
||||
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{12}
|
||||
}
|
||||
|
||||
func (x *RecordLoginBlockedResponse) GetRecorded() bool {
|
||||
@ -834,7 +1076,34 @@ const file_proto_user_v1_auth_proto_rawDesc = "" +
|
||||
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x1a\n" +
|
||||
"\bpassword\x18\x03 \x01(\tR\bpassword\"8\n" +
|
||||
"\x13SetPasswordResponse\x12!\n" +
|
||||
"\fpassword_set\x18\x01 \x01(\bR\vpasswordSet\"j\n" +
|
||||
"\fpassword_set\x18\x01 \x01(\bR\vpasswordSet\"\xcd\x04\n" +
|
||||
"\x19QuickCreateAccountRequest\x12.\n" +
|
||||
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12\x1a\n" +
|
||||
"\bpassword\x18\x02 \x01(\tR\bpassword\x12\x1a\n" +
|
||||
"\busername\x18\x03 \x01(\tR\busername\x12\x16\n" +
|
||||
"\x06avatar\x18\x04 \x01(\tR\x06avatar\x12\x16\n" +
|
||||
"\x06gender\x18\x05 \x01(\tR\x06gender\x12\x18\n" +
|
||||
"\acountry\x18\x06 \x01(\tR\acountry\x12\x1f\n" +
|
||||
"\vinvite_code\x18\a \x01(\tR\n" +
|
||||
"inviteCode\x12\x1b\n" +
|
||||
"\tdevice_id\x18\b \x01(\tR\bdeviceId\x12\x16\n" +
|
||||
"\x06device\x18\t \x01(\tR\x06device\x12\x1d\n" +
|
||||
"\n" +
|
||||
"os_version\x18\n" +
|
||||
" \x01(\tR\tosVersion\x12\x14\n" +
|
||||
"\x05birth\x18\v \x01(\tR\x05birth\x12\x1f\n" +
|
||||
"\vapp_version\x18\f \x01(\tR\n" +
|
||||
"appVersion\x12!\n" +
|
||||
"\fbuild_number\x18\r \x01(\tR\vbuildNumber\x12\x16\n" +
|
||||
"\x06source\x18\x0e \x01(\tR\x06source\x12'\n" +
|
||||
"\x0finstall_channel\x18\x0f \x01(\tR\x0einstallChannel\x12\x1a\n" +
|
||||
"\bcampaign\x18\x10 \x01(\tR\bcampaign\x12\x1a\n" +
|
||||
"\bplatform\x18\x11 \x01(\tR\bplatform\x12\x1a\n" +
|
||||
"\blanguage\x18\x12 \x01(\tR\blanguage\x12\x1a\n" +
|
||||
"\btimezone\x18\x13 \x01(\tR\btimezone\"o\n" +
|
||||
"\x1aQuickCreateAccountResponse\x12.\n" +
|
||||
"\x05token\x18\x01 \x01(\v2\x18.hyapp.user.v1.AuthTokenR\x05token\x12!\n" +
|
||||
"\fpassword_set\x18\x02 \x01(\bR\vpasswordSet\"j\n" +
|
||||
"\x13RefreshTokenRequest\x12.\n" +
|
||||
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12#\n" +
|
||||
"\rrefresh_token\x18\x02 \x01(\tR\frefreshToken\"F\n" +
|
||||
@ -855,11 +1124,12 @@ const file_proto_user_v1_auth_proto_rawDesc = "" +
|
||||
"\btimezone\x18\x05 \x01(\tR\btimezone\x12!\n" +
|
||||
"\fblock_reason\x18\x06 \x01(\tR\vblockReason\"8\n" +
|
||||
"\x1aRecordLoginBlockedResponse\x12\x1a\n" +
|
||||
"\brecorded\x18\x01 \x01(\bR\brecorded2\x98\x04\n" +
|
||||
"\brecorded\x18\x01 \x01(\bR\brecorded2\x83\x05\n" +
|
||||
"\vAuthService\x12Q\n" +
|
||||
"\rLoginPassword\x12#.hyapp.user.v1.LoginPasswordRequest\x1a\x1b.hyapp.user.v1.AuthResponse\x12U\n" +
|
||||
"\x0fLoginThirdParty\x12%.hyapp.user.v1.LoginThirdPartyRequest\x1a\x1b.hyapp.user.v1.AuthResponse\x12T\n" +
|
||||
"\vSetPassword\x12!.hyapp.user.v1.SetPasswordRequest\x1a\".hyapp.user.v1.SetPasswordResponse\x12W\n" +
|
||||
"\vSetPassword\x12!.hyapp.user.v1.SetPasswordRequest\x1a\".hyapp.user.v1.SetPasswordResponse\x12i\n" +
|
||||
"\x12QuickCreateAccount\x12(.hyapp.user.v1.QuickCreateAccountRequest\x1a).hyapp.user.v1.QuickCreateAccountResponse\x12W\n" +
|
||||
"\fRefreshToken\x12\".hyapp.user.v1.RefreshTokenRequest\x1a#.hyapp.user.v1.RefreshTokenResponse\x12E\n" +
|
||||
"\x06Logout\x12\x1c.hyapp.user.v1.LogoutRequest\x1a\x1d.hyapp.user.v1.LogoutResponse\x12i\n" +
|
||||
"\x12RecordLoginBlocked\x12(.hyapp.user.v1.RecordLoginBlockedRequest\x1a).hyapp.user.v1.RecordLoginBlockedResponseB&Z$hyapp.local/api/proto/user/v1;userv1b\x06proto3"
|
||||
@ -876,48 +1146,54 @@ func file_proto_user_v1_auth_proto_rawDescGZIP() []byte {
|
||||
return file_proto_user_v1_auth_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_proto_user_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
|
||||
var file_proto_user_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
|
||||
var file_proto_user_v1_auth_proto_goTypes = []any{
|
||||
(*LoginPasswordRequest)(nil), // 0: hyapp.user.v1.LoginPasswordRequest
|
||||
(*LoginThirdPartyRequest)(nil), // 1: hyapp.user.v1.LoginThirdPartyRequest
|
||||
(*AuthResponse)(nil), // 2: hyapp.user.v1.AuthResponse
|
||||
(*SetPasswordRequest)(nil), // 3: hyapp.user.v1.SetPasswordRequest
|
||||
(*SetPasswordResponse)(nil), // 4: hyapp.user.v1.SetPasswordResponse
|
||||
(*RefreshTokenRequest)(nil), // 5: hyapp.user.v1.RefreshTokenRequest
|
||||
(*RefreshTokenResponse)(nil), // 6: hyapp.user.v1.RefreshTokenResponse
|
||||
(*LogoutRequest)(nil), // 7: hyapp.user.v1.LogoutRequest
|
||||
(*LogoutResponse)(nil), // 8: hyapp.user.v1.LogoutResponse
|
||||
(*RecordLoginBlockedRequest)(nil), // 9: hyapp.user.v1.RecordLoginBlockedRequest
|
||||
(*RecordLoginBlockedResponse)(nil), // 10: hyapp.user.v1.RecordLoginBlockedResponse
|
||||
(*RequestMeta)(nil), // 11: hyapp.user.v1.RequestMeta
|
||||
(*AuthToken)(nil), // 12: hyapp.user.v1.AuthToken
|
||||
(*QuickCreateAccountRequest)(nil), // 5: hyapp.user.v1.QuickCreateAccountRequest
|
||||
(*QuickCreateAccountResponse)(nil), // 6: hyapp.user.v1.QuickCreateAccountResponse
|
||||
(*RefreshTokenRequest)(nil), // 7: hyapp.user.v1.RefreshTokenRequest
|
||||
(*RefreshTokenResponse)(nil), // 8: hyapp.user.v1.RefreshTokenResponse
|
||||
(*LogoutRequest)(nil), // 9: hyapp.user.v1.LogoutRequest
|
||||
(*LogoutResponse)(nil), // 10: hyapp.user.v1.LogoutResponse
|
||||
(*RecordLoginBlockedRequest)(nil), // 11: hyapp.user.v1.RecordLoginBlockedRequest
|
||||
(*RecordLoginBlockedResponse)(nil), // 12: hyapp.user.v1.RecordLoginBlockedResponse
|
||||
(*RequestMeta)(nil), // 13: hyapp.user.v1.RequestMeta
|
||||
(*AuthToken)(nil), // 14: hyapp.user.v1.AuthToken
|
||||
}
|
||||
var file_proto_user_v1_auth_proto_depIdxs = []int32{
|
||||
11, // 0: hyapp.user.v1.LoginPasswordRequest.meta:type_name -> hyapp.user.v1.RequestMeta
|
||||
11, // 1: hyapp.user.v1.LoginThirdPartyRequest.meta:type_name -> hyapp.user.v1.RequestMeta
|
||||
12, // 2: hyapp.user.v1.AuthResponse.token:type_name -> hyapp.user.v1.AuthToken
|
||||
11, // 3: hyapp.user.v1.SetPasswordRequest.meta:type_name -> hyapp.user.v1.RequestMeta
|
||||
11, // 4: hyapp.user.v1.RefreshTokenRequest.meta:type_name -> hyapp.user.v1.RequestMeta
|
||||
12, // 5: hyapp.user.v1.RefreshTokenResponse.token:type_name -> hyapp.user.v1.AuthToken
|
||||
11, // 6: hyapp.user.v1.LogoutRequest.meta:type_name -> hyapp.user.v1.RequestMeta
|
||||
11, // 7: hyapp.user.v1.RecordLoginBlockedRequest.meta:type_name -> hyapp.user.v1.RequestMeta
|
||||
0, // 8: hyapp.user.v1.AuthService.LoginPassword:input_type -> hyapp.user.v1.LoginPasswordRequest
|
||||
1, // 9: hyapp.user.v1.AuthService.LoginThirdParty:input_type -> hyapp.user.v1.LoginThirdPartyRequest
|
||||
3, // 10: hyapp.user.v1.AuthService.SetPassword:input_type -> hyapp.user.v1.SetPasswordRequest
|
||||
5, // 11: hyapp.user.v1.AuthService.RefreshToken:input_type -> hyapp.user.v1.RefreshTokenRequest
|
||||
7, // 12: hyapp.user.v1.AuthService.Logout:input_type -> hyapp.user.v1.LogoutRequest
|
||||
9, // 13: hyapp.user.v1.AuthService.RecordLoginBlocked:input_type -> hyapp.user.v1.RecordLoginBlockedRequest
|
||||
2, // 14: hyapp.user.v1.AuthService.LoginPassword:output_type -> hyapp.user.v1.AuthResponse
|
||||
2, // 15: hyapp.user.v1.AuthService.LoginThirdParty:output_type -> hyapp.user.v1.AuthResponse
|
||||
4, // 16: hyapp.user.v1.AuthService.SetPassword:output_type -> hyapp.user.v1.SetPasswordResponse
|
||||
6, // 17: hyapp.user.v1.AuthService.RefreshToken:output_type -> hyapp.user.v1.RefreshTokenResponse
|
||||
8, // 18: hyapp.user.v1.AuthService.Logout:output_type -> hyapp.user.v1.LogoutResponse
|
||||
10, // 19: hyapp.user.v1.AuthService.RecordLoginBlocked:output_type -> hyapp.user.v1.RecordLoginBlockedResponse
|
||||
14, // [14:20] is the sub-list for method output_type
|
||||
8, // [8:14] is the sub-list for method input_type
|
||||
8, // [8:8] is the sub-list for extension type_name
|
||||
8, // [8:8] is the sub-list for extension extendee
|
||||
0, // [0:8] is the sub-list for field type_name
|
||||
13, // 0: hyapp.user.v1.LoginPasswordRequest.meta:type_name -> hyapp.user.v1.RequestMeta
|
||||
13, // 1: hyapp.user.v1.LoginThirdPartyRequest.meta:type_name -> hyapp.user.v1.RequestMeta
|
||||
14, // 2: hyapp.user.v1.AuthResponse.token:type_name -> hyapp.user.v1.AuthToken
|
||||
13, // 3: hyapp.user.v1.SetPasswordRequest.meta:type_name -> hyapp.user.v1.RequestMeta
|
||||
13, // 4: hyapp.user.v1.QuickCreateAccountRequest.meta:type_name -> hyapp.user.v1.RequestMeta
|
||||
14, // 5: hyapp.user.v1.QuickCreateAccountResponse.token:type_name -> hyapp.user.v1.AuthToken
|
||||
13, // 6: hyapp.user.v1.RefreshTokenRequest.meta:type_name -> hyapp.user.v1.RequestMeta
|
||||
14, // 7: hyapp.user.v1.RefreshTokenResponse.token:type_name -> hyapp.user.v1.AuthToken
|
||||
13, // 8: hyapp.user.v1.LogoutRequest.meta:type_name -> hyapp.user.v1.RequestMeta
|
||||
13, // 9: hyapp.user.v1.RecordLoginBlockedRequest.meta:type_name -> hyapp.user.v1.RequestMeta
|
||||
0, // 10: hyapp.user.v1.AuthService.LoginPassword:input_type -> hyapp.user.v1.LoginPasswordRequest
|
||||
1, // 11: hyapp.user.v1.AuthService.LoginThirdParty:input_type -> hyapp.user.v1.LoginThirdPartyRequest
|
||||
3, // 12: hyapp.user.v1.AuthService.SetPassword:input_type -> hyapp.user.v1.SetPasswordRequest
|
||||
5, // 13: hyapp.user.v1.AuthService.QuickCreateAccount:input_type -> hyapp.user.v1.QuickCreateAccountRequest
|
||||
7, // 14: hyapp.user.v1.AuthService.RefreshToken:input_type -> hyapp.user.v1.RefreshTokenRequest
|
||||
9, // 15: hyapp.user.v1.AuthService.Logout:input_type -> hyapp.user.v1.LogoutRequest
|
||||
11, // 16: hyapp.user.v1.AuthService.RecordLoginBlocked:input_type -> hyapp.user.v1.RecordLoginBlockedRequest
|
||||
2, // 17: hyapp.user.v1.AuthService.LoginPassword:output_type -> hyapp.user.v1.AuthResponse
|
||||
2, // 18: hyapp.user.v1.AuthService.LoginThirdParty:output_type -> hyapp.user.v1.AuthResponse
|
||||
4, // 19: hyapp.user.v1.AuthService.SetPassword:output_type -> hyapp.user.v1.SetPasswordResponse
|
||||
6, // 20: hyapp.user.v1.AuthService.QuickCreateAccount:output_type -> hyapp.user.v1.QuickCreateAccountResponse
|
||||
8, // 21: hyapp.user.v1.AuthService.RefreshToken:output_type -> hyapp.user.v1.RefreshTokenResponse
|
||||
10, // 22: hyapp.user.v1.AuthService.Logout:output_type -> hyapp.user.v1.LogoutResponse
|
||||
12, // 23: hyapp.user.v1.AuthService.RecordLoginBlocked:output_type -> hyapp.user.v1.RecordLoginBlockedResponse
|
||||
17, // [17:24] is the sub-list for method output_type
|
||||
10, // [10:17] is the sub-list for method input_type
|
||||
10, // [10:10] is the sub-list for extension type_name
|
||||
10, // [10:10] is the sub-list for extension extendee
|
||||
0, // [0:10] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_proto_user_v1_auth_proto_init() }
|
||||
@ -932,7 +1208,7 @@ func file_proto_user_v1_auth_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_user_v1_auth_proto_rawDesc), len(file_proto_user_v1_auth_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 11,
|
||||
NumMessages: 13,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@ -57,6 +57,35 @@ message SetPasswordResponse {
|
||||
bool password_set = 1;
|
||||
}
|
||||
|
||||
// QuickCreateAccountRequest 为测试和三方联调快速创建一个可密码登录的完整账号。
|
||||
message QuickCreateAccountRequest {
|
||||
RequestMeta meta = 1;
|
||||
string password = 2;
|
||||
string username = 3;
|
||||
string avatar = 4;
|
||||
string gender = 5;
|
||||
string country = 6;
|
||||
string invite_code = 7;
|
||||
string device_id = 8;
|
||||
string device = 9;
|
||||
string os_version = 10;
|
||||
string birth = 11;
|
||||
string app_version = 12;
|
||||
string build_number = 13;
|
||||
string source = 14;
|
||||
string install_channel = 15;
|
||||
string campaign = 16;
|
||||
string platform = 17;
|
||||
string language = 18;
|
||||
string timezone = 19;
|
||||
}
|
||||
|
||||
// QuickCreateAccountResponse 返回新账号登录态;display_user_id 即后续账号密码登录用账号。
|
||||
message QuickCreateAccountResponse {
|
||||
AuthToken token = 1;
|
||||
bool password_set = 2;
|
||||
}
|
||||
|
||||
// RefreshTokenRequest 使用 refresh token 换取新 token。
|
||||
message RefreshTokenRequest {
|
||||
RequestMeta meta = 1;
|
||||
@ -100,6 +129,7 @@ service AuthService {
|
||||
rpc LoginPassword(LoginPasswordRequest) returns (AuthResponse);
|
||||
rpc LoginThirdParty(LoginThirdPartyRequest) returns (AuthResponse);
|
||||
rpc SetPassword(SetPasswordRequest) returns (SetPasswordResponse);
|
||||
rpc QuickCreateAccount(QuickCreateAccountRequest) returns (QuickCreateAccountResponse);
|
||||
rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse);
|
||||
rpc Logout(LogoutRequest) returns (LogoutResponse);
|
||||
rpc RecordLoginBlocked(RecordLoginBlockedRequest) returns (RecordLoginBlockedResponse);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - protoc v5.28.3
|
||||
// - protoc v7.35.0
|
||||
// source: proto/user/v1/auth.proto
|
||||
|
||||
package userv1
|
||||
@ -22,6 +22,7 @@ const (
|
||||
AuthService_LoginPassword_FullMethodName = "/hyapp.user.v1.AuthService/LoginPassword"
|
||||
AuthService_LoginThirdParty_FullMethodName = "/hyapp.user.v1.AuthService/LoginThirdParty"
|
||||
AuthService_SetPassword_FullMethodName = "/hyapp.user.v1.AuthService/SetPassword"
|
||||
AuthService_QuickCreateAccount_FullMethodName = "/hyapp.user.v1.AuthService/QuickCreateAccount"
|
||||
AuthService_RefreshToken_FullMethodName = "/hyapp.user.v1.AuthService/RefreshToken"
|
||||
AuthService_Logout_FullMethodName = "/hyapp.user.v1.AuthService/Logout"
|
||||
AuthService_RecordLoginBlocked_FullMethodName = "/hyapp.user.v1.AuthService/RecordLoginBlocked"
|
||||
@ -36,6 +37,7 @@ type AuthServiceClient interface {
|
||||
LoginPassword(ctx context.Context, in *LoginPasswordRequest, opts ...grpc.CallOption) (*AuthResponse, error)
|
||||
LoginThirdParty(ctx context.Context, in *LoginThirdPartyRequest, opts ...grpc.CallOption) (*AuthResponse, error)
|
||||
SetPassword(ctx context.Context, in *SetPasswordRequest, opts ...grpc.CallOption) (*SetPasswordResponse, error)
|
||||
QuickCreateAccount(ctx context.Context, in *QuickCreateAccountRequest, opts ...grpc.CallOption) (*QuickCreateAccountResponse, error)
|
||||
RefreshToken(ctx context.Context, in *RefreshTokenRequest, opts ...grpc.CallOption) (*RefreshTokenResponse, error)
|
||||
Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error)
|
||||
RecordLoginBlocked(ctx context.Context, in *RecordLoginBlockedRequest, opts ...grpc.CallOption) (*RecordLoginBlockedResponse, error)
|
||||
@ -79,6 +81,16 @@ func (c *authServiceClient) SetPassword(ctx context.Context, in *SetPasswordRequ
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *authServiceClient) QuickCreateAccount(ctx context.Context, in *QuickCreateAccountRequest, opts ...grpc.CallOption) (*QuickCreateAccountResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(QuickCreateAccountResponse)
|
||||
err := c.cc.Invoke(ctx, AuthService_QuickCreateAccount_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *authServiceClient) RefreshToken(ctx context.Context, in *RefreshTokenRequest, opts ...grpc.CallOption) (*RefreshTokenResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(RefreshTokenResponse)
|
||||
@ -118,6 +130,7 @@ type AuthServiceServer interface {
|
||||
LoginPassword(context.Context, *LoginPasswordRequest) (*AuthResponse, error)
|
||||
LoginThirdParty(context.Context, *LoginThirdPartyRequest) (*AuthResponse, error)
|
||||
SetPassword(context.Context, *SetPasswordRequest) (*SetPasswordResponse, error)
|
||||
QuickCreateAccount(context.Context, *QuickCreateAccountRequest) (*QuickCreateAccountResponse, error)
|
||||
RefreshToken(context.Context, *RefreshTokenRequest) (*RefreshTokenResponse, error)
|
||||
Logout(context.Context, *LogoutRequest) (*LogoutResponse, error)
|
||||
RecordLoginBlocked(context.Context, *RecordLoginBlockedRequest) (*RecordLoginBlockedResponse, error)
|
||||
@ -140,6 +153,9 @@ func (UnimplementedAuthServiceServer) LoginThirdParty(context.Context, *LoginThi
|
||||
func (UnimplementedAuthServiceServer) SetPassword(context.Context, *SetPasswordRequest) (*SetPasswordResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method SetPassword not implemented")
|
||||
}
|
||||
func (UnimplementedAuthServiceServer) QuickCreateAccount(context.Context, *QuickCreateAccountRequest) (*QuickCreateAccountResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method QuickCreateAccount not implemented")
|
||||
}
|
||||
func (UnimplementedAuthServiceServer) RefreshToken(context.Context, *RefreshTokenRequest) (*RefreshTokenResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method RefreshToken not implemented")
|
||||
}
|
||||
@ -224,6 +240,24 @@ func _AuthService_SetPassword_Handler(srv interface{}, ctx context.Context, dec
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AuthService_QuickCreateAccount_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(QuickCreateAccountRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AuthServiceServer).QuickCreateAccount(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AuthService_QuickCreateAccount_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AuthServiceServer).QuickCreateAccount(ctx, req.(*QuickCreateAccountRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AuthService_RefreshToken_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(RefreshTokenRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -297,6 +331,10 @@ var AuthService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "SetPassword",
|
||||
Handler: _AuthService_SetPassword_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "QuickCreateAccount",
|
||||
Handler: _AuthService_QuickCreateAccount_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "RefreshToken",
|
||||
Handler: _AuthService_RefreshToken_Handler,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v5.28.3
|
||||
// protoc v7.35.0
|
||||
// source: proto/user/v1/host.proto
|
||||
|
||||
package userv1
|
||||
@ -442,7 +442,7 @@ func (x *CoinSellerProfile) GetUpdatedAtMs() int64 {
|
||||
}
|
||||
|
||||
// UserRoleSummary 是 App 我的页和入口显隐使用的轻量角色摘要。
|
||||
// 它把 host、Agency、BD 和币商身份收敛到一次 user-service RPC,避免 gateway 随角色扩张继续 fanout。
|
||||
// 它把 host、Agency、manager、BD 和币商身份收敛到一次 user-service RPC,避免 gateway 随角色扩张继续 fanout。
|
||||
type UserRoleSummary struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||
@ -457,6 +457,7 @@ type UserRoleSummary struct {
|
||||
BdStatus string `protobuf:"bytes,10,opt,name=bd_status,json=bdStatus,proto3" json:"bd_status,omitempty"`
|
||||
CoinSellerStatus string `protobuf:"bytes,11,opt,name=coin_seller_status,json=coinSellerStatus,proto3" json:"coin_seller_status,omitempty"`
|
||||
PendingRoleInvitations int64 `protobuf:"varint,12,opt,name=pending_role_invitations,json=pendingRoleInvitations,proto3" json:"pending_role_invitations,omitempty"`
|
||||
IsManager bool `protobuf:"varint,13,opt,name=is_manager,json=isManager,proto3" json:"is_manager,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -575,6 +576,13 @@ func (x *UserRoleSummary) GetPendingRoleInvitations() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *UserRoleSummary) GetIsManager() bool {
|
||||
if x != nil {
|
||||
return x.IsManager
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AgencyMembership 是 host 与 Agency 的有时效关系事实。
|
||||
type AgencyMembership struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@ -3689,7 +3697,7 @@ const file_proto_user_v1_host_proto_rawDesc = "" +
|
||||
"\x13merchant_asset_type\x18\x03 \x01(\tR\x11merchantAssetType\x12+\n" +
|
||||
"\x12created_by_user_id\x18\x04 \x01(\x03R\x0fcreatedByUserId\x12\"\n" +
|
||||
"\rcreated_at_ms\x18\x05 \x01(\x03R\vcreatedAtMs\x12\"\n" +
|
||||
"\rupdated_at_ms\x18\x06 \x01(\x03R\vupdatedAtMs\"\x95\x03\n" +
|
||||
"\rupdated_at_ms\x18\x06 \x01(\x03R\vupdatedAtMs\"\xb4\x03\n" +
|
||||
"\x0fUserRoleSummary\x12\x17\n" +
|
||||
"\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x17\n" +
|
||||
"\ais_host\x18\x02 \x01(\bR\x06isHost\x12\x1b\n" +
|
||||
@ -3705,7 +3713,9 @@ const file_proto_user_v1_host_proto_rawDesc = "" +
|
||||
"\tbd_status\x18\n" +
|
||||
" \x01(\tR\bbdStatus\x12,\n" +
|
||||
"\x12coin_seller_status\x18\v \x01(\tR\x10coinSellerStatus\x128\n" +
|
||||
"\x18pending_role_invitations\x18\f \x01(\x03R\x16pendingRoleInvitations\"\xaa\x03\n" +
|
||||
"\x18pending_role_invitations\x18\f \x01(\x03R\x16pendingRoleInvitations\x12\x1d\n" +
|
||||
"\n" +
|
||||
"is_manager\x18\r \x01(\bR\tisManager\"\xaa\x03\n" +
|
||||
"\x10AgencyMembership\x12#\n" +
|
||||
"\rmembership_id\x18\x01 \x01(\x03R\fmembershipId\x12\x1b\n" +
|
||||
"\tagency_id\x18\x02 \x01(\x03R\bagencyId\x12 \n" +
|
||||
|
||||
@ -57,7 +57,7 @@ message CoinSellerProfile {
|
||||
}
|
||||
|
||||
// UserRoleSummary 是 App 我的页和入口显隐使用的轻量角色摘要。
|
||||
// 它把 host、Agency、BD 和币商身份收敛到一次 user-service RPC,避免 gateway 随角色扩张继续 fanout。
|
||||
// 它把 host、Agency、manager、BD 和币商身份收敛到一次 user-service RPC,避免 gateway 随角色扩张继续 fanout。
|
||||
message UserRoleSummary {
|
||||
int64 user_id = 1;
|
||||
bool is_host = 2;
|
||||
@ -71,6 +71,7 @@ message UserRoleSummary {
|
||||
string bd_status = 10;
|
||||
string coin_seller_status = 11;
|
||||
int64 pending_role_invitations = 12;
|
||||
bool is_manager = 13;
|
||||
}
|
||||
|
||||
// AgencyMembership 是 host 与 Agency 的有时效关系事实。
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - protoc v5.28.3
|
||||
// - protoc v7.35.0
|
||||
// source: proto/user/v1/host.proto
|
||||
|
||||
package userv1
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v5.28.3
|
||||
// protoc v7.35.0
|
||||
// source: proto/user/v1/user.proto
|
||||
|
||||
package userv1
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - protoc v5.28.3
|
||||
// - protoc v7.35.0
|
||||
// source: proto/user/v1/user.proto
|
||||
|
||||
package userv1
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -149,6 +149,9 @@ message Resource {
|
||||
int64 created_at_ms = 19;
|
||||
int64 updated_at_ms = 20;
|
||||
bool manager_grant_enabled = 21;
|
||||
string price_type = 22;
|
||||
int64 coin_price = 23;
|
||||
int64 gift_point_amount = 24;
|
||||
}
|
||||
|
||||
message ResourceGroupItem {
|
||||
@ -320,6 +323,9 @@ message CreateResourceRequest {
|
||||
int32 sort_order = 16;
|
||||
int64 operator_user_id = 17;
|
||||
optional bool manager_grant_enabled = 18;
|
||||
string price_type = 19;
|
||||
int64 coin_price = 20;
|
||||
int64 gift_point_amount = 21;
|
||||
}
|
||||
|
||||
message UpdateResourceRequest {
|
||||
@ -342,6 +348,9 @@ message UpdateResourceRequest {
|
||||
int32 sort_order = 17;
|
||||
int64 operator_user_id = 18;
|
||||
optional bool manager_grant_enabled = 19;
|
||||
string price_type = 20;
|
||||
int64 coin_price = 21;
|
||||
int64 gift_point_amount = 22;
|
||||
}
|
||||
|
||||
message SetResourceStatusRequest {
|
||||
@ -889,6 +898,12 @@ message VipLevel {
|
||||
repeated VipRewardItem reward_items = 7;
|
||||
bool can_purchase = 8;
|
||||
int32 sort_order = 9;
|
||||
bool recharge_gate_required = 10;
|
||||
int64 required_recharge_coin_amount = 11;
|
||||
int64 user_recharge_coin_amount = 12;
|
||||
string purchase_locked_reason = 13;
|
||||
int64 created_at_ms = 14;
|
||||
int64 updated_at_ms = 15;
|
||||
}
|
||||
|
||||
message UserVip {
|
||||
@ -938,6 +953,56 @@ message PurchaseVipResponse {
|
||||
repeated VipRewardItem reward_items = 6;
|
||||
}
|
||||
|
||||
message GrantVipRequest {
|
||||
string command_id = 1;
|
||||
string app_code = 2;
|
||||
int64 target_user_id = 3;
|
||||
int32 level = 4;
|
||||
string grant_source = 5;
|
||||
int64 operator_user_id = 6;
|
||||
string reason = 7;
|
||||
}
|
||||
|
||||
message GrantVipResponse {
|
||||
string transaction_id = 1;
|
||||
UserVip vip = 2;
|
||||
repeated VipRewardItem reward_items = 3;
|
||||
int64 server_time_ms = 4;
|
||||
}
|
||||
|
||||
message AdminVipLevelInput {
|
||||
int32 level = 1;
|
||||
string name = 2;
|
||||
string status = 3;
|
||||
int64 price_coin = 4;
|
||||
int64 duration_ms = 5;
|
||||
int64 reward_resource_group_id = 6;
|
||||
int32 sort_order = 7;
|
||||
int64 required_recharge_coin_amount = 8;
|
||||
}
|
||||
|
||||
message ListAdminVipLevelsRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
}
|
||||
|
||||
message ListAdminVipLevelsResponse {
|
||||
repeated VipLevel levels = 1;
|
||||
int64 server_time_ms = 2;
|
||||
}
|
||||
|
||||
message UpdateAdminVipLevelsRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
repeated AdminVipLevelInput levels = 3;
|
||||
int64 operator_user_id = 4;
|
||||
}
|
||||
|
||||
message UpdateAdminVipLevelsResponse {
|
||||
repeated VipLevel levels = 1;
|
||||
int64 server_time_ms = 2;
|
||||
}
|
||||
|
||||
// CreditTaskRewardRequest 是 activity-service 领取任务奖励时调用的钱包入账命令。
|
||||
message CreditTaskRewardRequest {
|
||||
string command_id = 1;
|
||||
@ -981,6 +1046,160 @@ message ApplyGameCoinChangeResponse {
|
||||
bool idempotent_replay = 3;
|
||||
}
|
||||
|
||||
message RedPacketConfig {
|
||||
string app_code = 1;
|
||||
bool enabled = 2;
|
||||
repeated int32 count_tiers = 3;
|
||||
repeated int64 amount_tiers = 4;
|
||||
int32 delayed_open_seconds = 5;
|
||||
int32 expire_seconds = 6;
|
||||
int32 daily_send_limit = 7;
|
||||
int64 updated_by_admin_id = 8;
|
||||
int64 created_at_ms = 9;
|
||||
int64 updated_at_ms = 10;
|
||||
}
|
||||
|
||||
message RedPacketClaim {
|
||||
string app_code = 1;
|
||||
string claim_id = 2;
|
||||
string command_id = 3;
|
||||
string packet_id = 4;
|
||||
int64 user_id = 5;
|
||||
int64 amount = 6;
|
||||
string wallet_transaction_id = 7;
|
||||
string status = 8;
|
||||
string failure_reason = 9;
|
||||
int64 created_at_ms = 10;
|
||||
int64 updated_at_ms = 11;
|
||||
}
|
||||
|
||||
message RedPacket {
|
||||
string app_code = 1;
|
||||
string packet_id = 2;
|
||||
string command_id = 3;
|
||||
int64 sender_user_id = 4;
|
||||
string room_id = 5;
|
||||
int64 region_id = 6;
|
||||
string packet_type = 7;
|
||||
int64 total_amount = 8;
|
||||
int32 packet_count = 9;
|
||||
int64 remaining_amount = 10;
|
||||
int32 remaining_count = 11;
|
||||
string status = 12;
|
||||
int64 open_at_ms = 13;
|
||||
int64 expires_at_ms = 14;
|
||||
int64 created_at_ms = 15;
|
||||
int64 updated_at_ms = 16;
|
||||
bool claimed_by_viewer = 17;
|
||||
int64 viewer_claim_amount = 18;
|
||||
repeated RedPacketClaim claims = 19;
|
||||
}
|
||||
|
||||
message GetRedPacketConfigRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
}
|
||||
|
||||
message GetRedPacketConfigResponse {
|
||||
RedPacketConfig config = 1;
|
||||
int64 server_time_ms = 2;
|
||||
}
|
||||
|
||||
message UpdateRedPacketConfigRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
bool enabled = 3;
|
||||
repeated int32 count_tiers = 4;
|
||||
repeated int64 amount_tiers = 5;
|
||||
int32 delayed_open_seconds = 6;
|
||||
int32 expire_seconds = 7;
|
||||
int32 daily_send_limit = 8;
|
||||
int64 operator_user_id = 9;
|
||||
}
|
||||
|
||||
message UpdateRedPacketConfigResponse {
|
||||
RedPacketConfig config = 1;
|
||||
int64 server_time_ms = 2;
|
||||
}
|
||||
|
||||
message CreateRedPacketRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
string command_id = 3;
|
||||
int64 sender_user_id = 4;
|
||||
string room_id = 5;
|
||||
int64 region_id = 6;
|
||||
string packet_type = 7;
|
||||
int64 total_amount = 8;
|
||||
int32 packet_count = 9;
|
||||
}
|
||||
|
||||
message CreateRedPacketResponse {
|
||||
RedPacket packet = 1;
|
||||
int64 balance_after = 2;
|
||||
int64 server_time_ms = 3;
|
||||
}
|
||||
|
||||
message ClaimRedPacketRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
string command_id = 3;
|
||||
string packet_id = 4;
|
||||
int64 user_id = 5;
|
||||
}
|
||||
|
||||
message ClaimRedPacketResponse {
|
||||
RedPacketClaim claim = 1;
|
||||
int64 balance_after = 2;
|
||||
int64 server_time_ms = 3;
|
||||
}
|
||||
|
||||
message ListRedPacketsRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
string room_id = 3;
|
||||
int64 sender_user_id = 4;
|
||||
int64 region_id = 5;
|
||||
string packet_type = 6;
|
||||
string status = 7;
|
||||
int64 viewer_user_id = 8;
|
||||
int64 start_at_ms = 9;
|
||||
int64 end_at_ms = 10;
|
||||
int32 page = 11;
|
||||
int32 page_size = 12;
|
||||
}
|
||||
|
||||
message ListRedPacketsResponse {
|
||||
repeated RedPacket packets = 1;
|
||||
int64 total = 2;
|
||||
int64 server_time_ms = 3;
|
||||
}
|
||||
|
||||
message GetRedPacketRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
string packet_id = 3;
|
||||
int64 viewer_user_id = 4;
|
||||
bool include_claims = 5;
|
||||
}
|
||||
|
||||
message GetRedPacketResponse {
|
||||
RedPacket packet = 1;
|
||||
int64 server_time_ms = 2;
|
||||
}
|
||||
|
||||
message ExpireRedPacketsRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
int32 batch_size = 3;
|
||||
}
|
||||
|
||||
message ExpireRedPacketsResponse {
|
||||
int32 expired_count = 1;
|
||||
int64 refunded_amount = 2;
|
||||
int64 server_time_ms = 3;
|
||||
}
|
||||
|
||||
// WalletService 是内部账务 gRPC 边界,外部 HTTP 入口仍由 gateway-service 承载。
|
||||
service WalletService {
|
||||
rpc DebitGift(DebitGiftRequest) returns (DebitGiftResponse);
|
||||
@ -1024,6 +1243,16 @@ service WalletService {
|
||||
rpc ListVipPackages(ListVipPackagesRequest) returns (ListVipPackagesResponse);
|
||||
rpc GetMyVip(GetMyVipRequest) returns (GetMyVipResponse);
|
||||
rpc PurchaseVip(PurchaseVipRequest) returns (PurchaseVipResponse);
|
||||
rpc GrantVip(GrantVipRequest) returns (GrantVipResponse);
|
||||
rpc ListAdminVipLevels(ListAdminVipLevelsRequest) returns (ListAdminVipLevelsResponse);
|
||||
rpc UpdateAdminVipLevels(UpdateAdminVipLevelsRequest) returns (UpdateAdminVipLevelsResponse);
|
||||
rpc CreditTaskReward(CreditTaskRewardRequest) returns (CreditTaskRewardResponse);
|
||||
rpc ApplyGameCoinChange(ApplyGameCoinChangeRequest) returns (ApplyGameCoinChangeResponse);
|
||||
rpc GetRedPacketConfig(GetRedPacketConfigRequest) returns (GetRedPacketConfigResponse);
|
||||
rpc UpdateRedPacketConfig(UpdateRedPacketConfigRequest) returns (UpdateRedPacketConfigResponse);
|
||||
rpc CreateRedPacket(CreateRedPacketRequest) returns (CreateRedPacketResponse);
|
||||
rpc ClaimRedPacket(ClaimRedPacketRequest) returns (ClaimRedPacketResponse);
|
||||
rpc ListRedPackets(ListRedPacketsRequest) returns (ListRedPacketsResponse);
|
||||
rpc GetRedPacket(GetRedPacketRequest) returns (GetRedPacketResponse);
|
||||
rpc ExpireRedPackets(ExpireRedPacketsRequest) returns (ExpireRedPacketsResponse);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - protoc (unknown)
|
||||
// - protoc v7.35.0
|
||||
// source: proto/wallet/v1/wallet.proto
|
||||
|
||||
package walletv1
|
||||
@ -60,8 +60,18 @@ const (
|
||||
WalletService_ListVipPackages_FullMethodName = "/hyapp.wallet.v1.WalletService/ListVipPackages"
|
||||
WalletService_GetMyVip_FullMethodName = "/hyapp.wallet.v1.WalletService/GetMyVip"
|
||||
WalletService_PurchaseVip_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseVip"
|
||||
WalletService_GrantVip_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantVip"
|
||||
WalletService_ListAdminVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/ListAdminVipLevels"
|
||||
WalletService_UpdateAdminVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateAdminVipLevels"
|
||||
WalletService_CreditTaskReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditTaskReward"
|
||||
WalletService_ApplyGameCoinChange_FullMethodName = "/hyapp.wallet.v1.WalletService/ApplyGameCoinChange"
|
||||
WalletService_GetRedPacketConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetRedPacketConfig"
|
||||
WalletService_UpdateRedPacketConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateRedPacketConfig"
|
||||
WalletService_CreateRedPacket_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateRedPacket"
|
||||
WalletService_ClaimRedPacket_FullMethodName = "/hyapp.wallet.v1.WalletService/ClaimRedPacket"
|
||||
WalletService_ListRedPackets_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRedPackets"
|
||||
WalletService_GetRedPacket_FullMethodName = "/hyapp.wallet.v1.WalletService/GetRedPacket"
|
||||
WalletService_ExpireRedPackets_FullMethodName = "/hyapp.wallet.v1.WalletService/ExpireRedPackets"
|
||||
)
|
||||
|
||||
// WalletServiceClient is the client API for WalletService service.
|
||||
@ -111,8 +121,18 @@ type WalletServiceClient interface {
|
||||
ListVipPackages(ctx context.Context, in *ListVipPackagesRequest, opts ...grpc.CallOption) (*ListVipPackagesResponse, error)
|
||||
GetMyVip(ctx context.Context, in *GetMyVipRequest, opts ...grpc.CallOption) (*GetMyVipResponse, error)
|
||||
PurchaseVip(ctx context.Context, in *PurchaseVipRequest, opts ...grpc.CallOption) (*PurchaseVipResponse, error)
|
||||
GrantVip(ctx context.Context, in *GrantVipRequest, opts ...grpc.CallOption) (*GrantVipResponse, error)
|
||||
ListAdminVipLevels(ctx context.Context, in *ListAdminVipLevelsRequest, opts ...grpc.CallOption) (*ListAdminVipLevelsResponse, error)
|
||||
UpdateAdminVipLevels(ctx context.Context, in *UpdateAdminVipLevelsRequest, opts ...grpc.CallOption) (*UpdateAdminVipLevelsResponse, error)
|
||||
CreditTaskReward(ctx context.Context, in *CreditTaskRewardRequest, opts ...grpc.CallOption) (*CreditTaskRewardResponse, error)
|
||||
ApplyGameCoinChange(ctx context.Context, in *ApplyGameCoinChangeRequest, opts ...grpc.CallOption) (*ApplyGameCoinChangeResponse, error)
|
||||
GetRedPacketConfig(ctx context.Context, in *GetRedPacketConfigRequest, opts ...grpc.CallOption) (*GetRedPacketConfigResponse, error)
|
||||
UpdateRedPacketConfig(ctx context.Context, in *UpdateRedPacketConfigRequest, opts ...grpc.CallOption) (*UpdateRedPacketConfigResponse, error)
|
||||
CreateRedPacket(ctx context.Context, in *CreateRedPacketRequest, opts ...grpc.CallOption) (*CreateRedPacketResponse, error)
|
||||
ClaimRedPacket(ctx context.Context, in *ClaimRedPacketRequest, opts ...grpc.CallOption) (*ClaimRedPacketResponse, error)
|
||||
ListRedPackets(ctx context.Context, in *ListRedPacketsRequest, opts ...grpc.CallOption) (*ListRedPacketsResponse, error)
|
||||
GetRedPacket(ctx context.Context, in *GetRedPacketRequest, opts ...grpc.CallOption) (*GetRedPacketResponse, error)
|
||||
ExpireRedPackets(ctx context.Context, in *ExpireRedPacketsRequest, opts ...grpc.CallOption) (*ExpireRedPacketsResponse, error)
|
||||
}
|
||||
|
||||
type walletServiceClient struct {
|
||||
@ -533,6 +553,36 @@ func (c *walletServiceClient) PurchaseVip(ctx context.Context, in *PurchaseVipRe
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GrantVip(ctx context.Context, in *GrantVipRequest, opts ...grpc.CallOption) (*GrantVipResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GrantVipResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_GrantVip_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) ListAdminVipLevels(ctx context.Context, in *ListAdminVipLevelsRequest, opts ...grpc.CallOption) (*ListAdminVipLevelsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListAdminVipLevelsResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_ListAdminVipLevels_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) UpdateAdminVipLevels(ctx context.Context, in *UpdateAdminVipLevelsRequest, opts ...grpc.CallOption) (*UpdateAdminVipLevelsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(UpdateAdminVipLevelsResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_UpdateAdminVipLevels_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) CreditTaskReward(ctx context.Context, in *CreditTaskRewardRequest, opts ...grpc.CallOption) (*CreditTaskRewardResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CreditTaskRewardResponse)
|
||||
@ -553,6 +603,76 @@ func (c *walletServiceClient) ApplyGameCoinChange(ctx context.Context, in *Apply
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GetRedPacketConfig(ctx context.Context, in *GetRedPacketConfigRequest, opts ...grpc.CallOption) (*GetRedPacketConfigResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetRedPacketConfigResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_GetRedPacketConfig_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) UpdateRedPacketConfig(ctx context.Context, in *UpdateRedPacketConfigRequest, opts ...grpc.CallOption) (*UpdateRedPacketConfigResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(UpdateRedPacketConfigResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_UpdateRedPacketConfig_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) CreateRedPacket(ctx context.Context, in *CreateRedPacketRequest, opts ...grpc.CallOption) (*CreateRedPacketResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CreateRedPacketResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_CreateRedPacket_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) ClaimRedPacket(ctx context.Context, in *ClaimRedPacketRequest, opts ...grpc.CallOption) (*ClaimRedPacketResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ClaimRedPacketResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_ClaimRedPacket_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) ListRedPackets(ctx context.Context, in *ListRedPacketsRequest, opts ...grpc.CallOption) (*ListRedPacketsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListRedPacketsResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_ListRedPackets_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GetRedPacket(ctx context.Context, in *GetRedPacketRequest, opts ...grpc.CallOption) (*GetRedPacketResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetRedPacketResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_GetRedPacket_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) ExpireRedPackets(ctx context.Context, in *ExpireRedPacketsRequest, opts ...grpc.CallOption) (*ExpireRedPacketsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ExpireRedPacketsResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_ExpireRedPackets_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// WalletServiceServer is the server API for WalletService service.
|
||||
// All implementations must embed UnimplementedWalletServiceServer
|
||||
// for forward compatibility.
|
||||
@ -600,8 +720,18 @@ type WalletServiceServer interface {
|
||||
ListVipPackages(context.Context, *ListVipPackagesRequest) (*ListVipPackagesResponse, error)
|
||||
GetMyVip(context.Context, *GetMyVipRequest) (*GetMyVipResponse, error)
|
||||
PurchaseVip(context.Context, *PurchaseVipRequest) (*PurchaseVipResponse, error)
|
||||
GrantVip(context.Context, *GrantVipRequest) (*GrantVipResponse, error)
|
||||
ListAdminVipLevels(context.Context, *ListAdminVipLevelsRequest) (*ListAdminVipLevelsResponse, error)
|
||||
UpdateAdminVipLevels(context.Context, *UpdateAdminVipLevelsRequest) (*UpdateAdminVipLevelsResponse, error)
|
||||
CreditTaskReward(context.Context, *CreditTaskRewardRequest) (*CreditTaskRewardResponse, error)
|
||||
ApplyGameCoinChange(context.Context, *ApplyGameCoinChangeRequest) (*ApplyGameCoinChangeResponse, error)
|
||||
GetRedPacketConfig(context.Context, *GetRedPacketConfigRequest) (*GetRedPacketConfigResponse, error)
|
||||
UpdateRedPacketConfig(context.Context, *UpdateRedPacketConfigRequest) (*UpdateRedPacketConfigResponse, error)
|
||||
CreateRedPacket(context.Context, *CreateRedPacketRequest) (*CreateRedPacketResponse, error)
|
||||
ClaimRedPacket(context.Context, *ClaimRedPacketRequest) (*ClaimRedPacketResponse, error)
|
||||
ListRedPackets(context.Context, *ListRedPacketsRequest) (*ListRedPacketsResponse, error)
|
||||
GetRedPacket(context.Context, *GetRedPacketRequest) (*GetRedPacketResponse, error)
|
||||
ExpireRedPackets(context.Context, *ExpireRedPacketsRequest) (*ExpireRedPacketsResponse, error)
|
||||
mustEmbedUnimplementedWalletServiceServer()
|
||||
}
|
||||
|
||||
@ -735,12 +865,42 @@ func (UnimplementedWalletServiceServer) GetMyVip(context.Context, *GetMyVipReque
|
||||
func (UnimplementedWalletServiceServer) PurchaseVip(context.Context, *PurchaseVipRequest) (*PurchaseVipResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method PurchaseVip not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GrantVip(context.Context, *GrantVipRequest) (*GrantVipResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GrantVip not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) ListAdminVipLevels(context.Context, *ListAdminVipLevelsRequest) (*ListAdminVipLevelsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListAdminVipLevels not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) UpdateAdminVipLevels(context.Context, *UpdateAdminVipLevelsRequest) (*UpdateAdminVipLevelsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method UpdateAdminVipLevels not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) CreditTaskReward(context.Context, *CreditTaskRewardRequest) (*CreditTaskRewardResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method CreditTaskReward not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) ApplyGameCoinChange(context.Context, *ApplyGameCoinChangeRequest) (*ApplyGameCoinChangeResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ApplyGameCoinChange not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GetRedPacketConfig(context.Context, *GetRedPacketConfigRequest) (*GetRedPacketConfigResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetRedPacketConfig not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) UpdateRedPacketConfig(context.Context, *UpdateRedPacketConfigRequest) (*UpdateRedPacketConfigResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method UpdateRedPacketConfig not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) CreateRedPacket(context.Context, *CreateRedPacketRequest) (*CreateRedPacketResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method CreateRedPacket not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) ClaimRedPacket(context.Context, *ClaimRedPacketRequest) (*ClaimRedPacketResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ClaimRedPacket not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) ListRedPackets(context.Context, *ListRedPacketsRequest) (*ListRedPacketsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListRedPackets not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GetRedPacket(context.Context, *GetRedPacketRequest) (*GetRedPacketResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetRedPacket not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) ExpireRedPackets(context.Context, *ExpireRedPacketsRequest) (*ExpireRedPacketsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ExpireRedPackets not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) mustEmbedUnimplementedWalletServiceServer() {}
|
||||
func (UnimplementedWalletServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
@ -1500,6 +1660,60 @@ func _WalletService_PurchaseVip_Handler(srv interface{}, ctx context.Context, de
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GrantVip_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GrantVipRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).GrantVip(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_GrantVip_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).GrantVip(ctx, req.(*GrantVipRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_ListAdminVipLevels_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListAdminVipLevelsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).ListAdminVipLevels(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_ListAdminVipLevels_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).ListAdminVipLevels(ctx, req.(*ListAdminVipLevelsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_UpdateAdminVipLevels_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UpdateAdminVipLevelsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).UpdateAdminVipLevels(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_UpdateAdminVipLevels_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).UpdateAdminVipLevels(ctx, req.(*UpdateAdminVipLevelsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_CreditTaskReward_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CreditTaskRewardRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -1536,6 +1750,132 @@ func _WalletService_ApplyGameCoinChange_Handler(srv interface{}, ctx context.Con
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GetRedPacketConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetRedPacketConfigRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).GetRedPacketConfig(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_GetRedPacketConfig_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).GetRedPacketConfig(ctx, req.(*GetRedPacketConfigRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_UpdateRedPacketConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UpdateRedPacketConfigRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).UpdateRedPacketConfig(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_UpdateRedPacketConfig_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).UpdateRedPacketConfig(ctx, req.(*UpdateRedPacketConfigRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_CreateRedPacket_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CreateRedPacketRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).CreateRedPacket(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_CreateRedPacket_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).CreateRedPacket(ctx, req.(*CreateRedPacketRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_ClaimRedPacket_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ClaimRedPacketRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).ClaimRedPacket(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_ClaimRedPacket_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).ClaimRedPacket(ctx, req.(*ClaimRedPacketRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_ListRedPackets_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListRedPacketsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).ListRedPackets(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_ListRedPackets_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).ListRedPackets(ctx, req.(*ListRedPacketsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GetRedPacket_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetRedPacketRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).GetRedPacket(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_GetRedPacket_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).GetRedPacket(ctx, req.(*GetRedPacketRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_ExpireRedPackets_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ExpireRedPacketsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).ExpireRedPackets(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_ExpireRedPackets_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).ExpireRedPackets(ctx, req.(*ExpireRedPacketsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// WalletService_ServiceDesc is the grpc.ServiceDesc for WalletService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@ -1707,6 +2047,18 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "PurchaseVip",
|
||||
Handler: _WalletService_PurchaseVip_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GrantVip",
|
||||
Handler: _WalletService_GrantVip_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListAdminVipLevels",
|
||||
Handler: _WalletService_ListAdminVipLevels_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UpdateAdminVipLevels",
|
||||
Handler: _WalletService_UpdateAdminVipLevels_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CreditTaskReward",
|
||||
Handler: _WalletService_CreditTaskReward_Handler,
|
||||
@ -1715,6 +2067,34 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "ApplyGameCoinChange",
|
||||
Handler: _WalletService_ApplyGameCoinChange_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetRedPacketConfig",
|
||||
Handler: _WalletService_GetRedPacketConfig_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UpdateRedPacketConfig",
|
||||
Handler: _WalletService_UpdateRedPacketConfig_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CreateRedPacket",
|
||||
Handler: _WalletService_CreateRedPacket_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ClaimRedPacket",
|
||||
Handler: _WalletService_ClaimRedPacket_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListRedPackets",
|
||||
Handler: _WalletService_ListRedPackets_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetRedPacket",
|
||||
Handler: _WalletService_GetRedPacket_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ExpireRedPackets",
|
||||
Handler: _WalletService_ExpireRedPackets_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "proto/wallet/v1/wallet.proto",
|
||||
|
||||
520
cmd/dev-run/main.go
Normal file
520
cmd/dev-run/main.go
Normal file
@ -0,0 +1,520 @@
|
||||
// Package main provides the repository-local hot reload runner behind `make run`.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
scanInterval = 800 * time.Millisecond
|
||||
restartDebounce = 350 * time.Millisecond
|
||||
stopTimeout = 8 * time.Second
|
||||
)
|
||||
|
||||
type serviceSpec struct {
|
||||
Name string
|
||||
Dir string
|
||||
Args []string
|
||||
WatchRoot []string
|
||||
Ports []int
|
||||
Color string
|
||||
}
|
||||
|
||||
type serviceRunner struct {
|
||||
spec serviceSpec
|
||||
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
done chan struct{}
|
||||
stopping bool
|
||||
}
|
||||
|
||||
var logMu sync.Mutex
|
||||
|
||||
func main() {
|
||||
serviceList := flag.String("services", "all", "comma-separated services to run, or all")
|
||||
flag.Parse()
|
||||
|
||||
root, err := os.Getwd()
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
specs, err := selectServices(*serviceList)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
if len(specs) == 0 {
|
||||
fatal(errors.New("no services selected"))
|
||||
}
|
||||
|
||||
ctx, stop := signalContext()
|
||||
defer stop()
|
||||
|
||||
runners := make(map[string]*serviceRunner, len(specs))
|
||||
watchers := make([]*watchState, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
runner := &serviceRunner{spec: spec}
|
||||
runners[spec.Name] = runner
|
||||
killPortOwners(spec)
|
||||
if err := runner.start(root); err != nil {
|
||||
logf(os.Stderr, spec, "start failed: %v", err)
|
||||
}
|
||||
watchers = append(watchers, newWatchState(root, spec))
|
||||
}
|
||||
|
||||
printPlain(os.Stdout, "dev-run watching %d service(s): %s", len(specs), serviceNames(specs))
|
||||
printPlain(os.Stdout, "press Ctrl-C to stop all services")
|
||||
|
||||
ticker := time.NewTicker(scanInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
pending := map[string]time.Time{}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
stopAll(runners)
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
for _, watcher := range watchers {
|
||||
changed, err := watcher.changed()
|
||||
if err != nil {
|
||||
logf(os.Stderr, watcher.spec, "watch failed: %v", err)
|
||||
continue
|
||||
}
|
||||
if changed {
|
||||
pending[watcher.spec.Name] = now
|
||||
}
|
||||
}
|
||||
for name, changedAt := range pending {
|
||||
if now.Sub(changedAt) < restartDebounce {
|
||||
continue
|
||||
}
|
||||
delete(pending, name)
|
||||
runner := runners[name]
|
||||
logf(os.Stdout, runner.spec, "change detected, restarting")
|
||||
runner.restart(root)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func allServices() []serviceSpec {
|
||||
// 启动顺序按低层依赖到入口服务排列;gRPC dial 当前不阻塞,但这样能让日志更接近真实依赖链路。
|
||||
return []serviceSpec{
|
||||
appService("user-service", []int{13005, 13105}, "36"),
|
||||
appService("activity-service", []int{13006, 13106}, "35"),
|
||||
appService("wallet-service", []int{13004, 13104}, "33"),
|
||||
appService("game-service", []int{13008, 13108}, "32"),
|
||||
appService("room-service", []int{13001, 13101}, "34"),
|
||||
appService("notice-service", []int{13009, 13109}, "96"),
|
||||
appService("cron-service", []int{13007, 13107}, "95"),
|
||||
appService("gateway-service", []int{13000}, "92"),
|
||||
appService("statistics-service", []int{13010, 13110}, "94"),
|
||||
{
|
||||
Name: "admin",
|
||||
Dir: "server/admin",
|
||||
Args: []string{"run", "./cmd/server", "-config", "configs/config.yaml"},
|
||||
WatchRoot: []string{
|
||||
"server/admin",
|
||||
"api",
|
||||
},
|
||||
Ports: []int{13100},
|
||||
Color: "91",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func appService(name string, ports []int, color string) serviceSpec {
|
||||
return serviceSpec{
|
||||
Name: name,
|
||||
Dir: ".",
|
||||
Args: []string{"run", "./services/" + name + "/cmd/server", "-config", "services/" + name + "/configs/config.yaml"},
|
||||
WatchRoot: []string{
|
||||
"services/" + name,
|
||||
"pkg",
|
||||
"api",
|
||||
},
|
||||
Ports: ports,
|
||||
Color: color,
|
||||
}
|
||||
}
|
||||
|
||||
func selectServices(raw string) ([]serviceSpec, error) {
|
||||
all := allServices()
|
||||
if strings.TrimSpace(raw) == "" || strings.TrimSpace(raw) == "all" {
|
||||
return all, nil
|
||||
}
|
||||
byName := make(map[string]serviceSpec, len(all))
|
||||
for _, spec := range all {
|
||||
byName[spec.Name] = spec
|
||||
}
|
||||
aliases := map[string]string{
|
||||
"gateway": "gateway-service",
|
||||
"gs": "gateway-service",
|
||||
"room": "room-service",
|
||||
"rs": "room-service",
|
||||
"wallet": "wallet-service",
|
||||
"ws": "wallet-service",
|
||||
"user": "user-service",
|
||||
"us": "user-service",
|
||||
"activity": "activity-service",
|
||||
"as": "activity-service",
|
||||
"cron": "cron-service",
|
||||
"cs": "cron-service",
|
||||
"game": "game-service",
|
||||
"games": "game-service",
|
||||
"notice": "notice-service",
|
||||
"ns": "notice-service",
|
||||
"stats": "statistics-service",
|
||||
"statistics": "statistics-service",
|
||||
}
|
||||
var selected []serviceSpec
|
||||
seen := map[string]bool{}
|
||||
for _, token := range strings.Split(raw, ",") {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
if canonical, ok := aliases[token]; ok {
|
||||
token = canonical
|
||||
}
|
||||
spec, ok := byName[token]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown service %q", token)
|
||||
}
|
||||
if !seen[spec.Name] {
|
||||
selected = append(selected, spec)
|
||||
seen[spec.Name] = true
|
||||
}
|
||||
}
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
func (r *serviceRunner) start(repoRoot string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
cmd := exec.Command("go", r.spec.Args...)
|
||||
cmd.Dir = filepath.Join(repoRoot, r.spec.Dir)
|
||||
cmd.Env = append(os.Environ(), "TZ=UTC")
|
||||
// go run 会再托管一个编译后的子进程;独立进程组保证重启时能一起收到 SIGINT。
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
r.cmd = cmd
|
||||
r.done = make(chan struct{})
|
||||
r.stopping = false
|
||||
|
||||
go prefixPipe(r.spec, stdout)
|
||||
go prefixPipe(r.spec, stderr)
|
||||
go r.wait(cmd, r.done)
|
||||
|
||||
logf(os.Stdout, r.spec, "started: go %s", strings.Join(r.spec.Args, " "))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *serviceRunner) restart(repoRoot string) {
|
||||
r.stop()
|
||||
killPortOwners(r.spec)
|
||||
if err := r.start(repoRoot); err != nil {
|
||||
logf(os.Stderr, r.spec, "restart failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *serviceRunner) stop() {
|
||||
r.mu.Lock()
|
||||
cmd := r.cmd
|
||||
done := r.done
|
||||
r.stopping = true
|
||||
r.mu.Unlock()
|
||||
if cmd == nil || cmd.Process == nil || done == nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGINT)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(stopTimeout):
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
func (r *serviceRunner) wait(cmd *exec.Cmd, done chan struct{}) {
|
||||
err := cmd.Wait()
|
||||
close(done)
|
||||
|
||||
r.mu.Lock()
|
||||
stopping := r.stopping
|
||||
if r.cmd == cmd {
|
||||
r.cmd = nil
|
||||
r.done = nil
|
||||
r.stopping = false
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
if stopping {
|
||||
logf(os.Stdout, r.spec, "stopped")
|
||||
return
|
||||
}
|
||||
logf(os.Stderr, r.spec, "exited: %v", err)
|
||||
return
|
||||
}
|
||||
logf(os.Stdout, r.spec, "exited")
|
||||
}
|
||||
|
||||
func stopAll(runners map[string]*serviceRunner) {
|
||||
names := make([]string, 0, len(runners))
|
||||
for name := range runners {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var wg sync.WaitGroup
|
||||
for _, name := range names {
|
||||
wg.Add(1)
|
||||
go func(runner *serviceRunner) {
|
||||
defer wg.Done()
|
||||
runner.stop()
|
||||
}(runners[name])
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
type watchState struct {
|
||||
root string
|
||||
spec serviceSpec
|
||||
snapshot map[string]fileStamp
|
||||
}
|
||||
|
||||
type fileStamp struct {
|
||||
modTime time.Time
|
||||
size int64
|
||||
}
|
||||
|
||||
func newWatchState(root string, spec serviceSpec) *watchState {
|
||||
w := &watchState{root: root, spec: spec}
|
||||
w.snapshot = w.scan()
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *watchState) changed() (bool, error) {
|
||||
next := w.scan()
|
||||
changed := !sameSnapshot(w.snapshot, next)
|
||||
w.snapshot = next
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func (w *watchState) scan() map[string]fileStamp {
|
||||
out := make(map[string]fileStamp)
|
||||
for _, rel := range w.spec.WatchRoot {
|
||||
w.scanPath(out, rel)
|
||||
}
|
||||
// 根 module 变更会影响所有 app 服务;admin 还有自己的独立 module 文件。
|
||||
w.scanPath(out, "go.mod")
|
||||
w.scanPath(out, "go.sum")
|
||||
if w.spec.Name == "admin" {
|
||||
w.scanPath(out, filepath.Join("server", "admin", "go.mod"))
|
||||
w.scanPath(out, filepath.Join("server", "admin", "go.sum"))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (w *watchState) scanPath(out map[string]fileStamp, rel string) {
|
||||
path := filepath.Join(w.root, rel)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !info.IsDir() {
|
||||
if watchableFile(path) {
|
||||
out[rel] = fileStamp{modTime: info.ModTime(), size: info.Size()}
|
||||
}
|
||||
return
|
||||
}
|
||||
_ = filepath.WalkDir(path, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
if ignoredDir(d.Name()) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !watchableFile(path) {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
relPath, err := filepath.Rel(w.root, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out[relPath] = fileStamp{modTime: info.ModTime(), size: info.Size()}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func ignoredDir(name string) bool {
|
||||
switch name {
|
||||
case ".git", ".idea", ".vscode", "bin", "dist", "node_modules", "storage", "tmp", "vendor":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func watchableFile(path string) bool {
|
||||
switch filepath.Ext(path) {
|
||||
case ".go", ".yaml", ".yml", ".proto", ".mod", ".sum":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sameSnapshot(a, b map[string]fileStamp) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for path, stampA := range a {
|
||||
stampB, ok := b[path]
|
||||
if !ok || !stampA.modTime.Equal(stampB.modTime) || stampA.size != stampB.size {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func killPortOwners(spec serviceSpec) {
|
||||
seen := map[int]bool{}
|
||||
for _, port := range spec.Ports {
|
||||
if port <= 0 || seen[port] {
|
||||
continue
|
||||
}
|
||||
seen[port] = true
|
||||
pids, err := listeningPIDs(port)
|
||||
if err != nil {
|
||||
logf(os.Stderr, spec, "port %d lookup failed: %v", port, err)
|
||||
continue
|
||||
}
|
||||
if len(pids) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, pid := range pids {
|
||||
// 本地开发启动以抢占端口为准:旧进程可能是 go run 托管的子进程,直接杀监听 PID 最可靠。
|
||||
if err := syscall.Kill(pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
|
||||
logf(os.Stderr, spec, "kill pid %d on port %d failed: %v", pid, port, err)
|
||||
continue
|
||||
}
|
||||
logf(os.Stdout, spec, "killed pid %d occupying port %d", pid, port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func listeningPIDs(port int) ([]int, error) {
|
||||
output, err := exec.Command("lsof", "-nP", "-tiTCP:"+strconv.Itoa(port), "-sTCP:LISTEN").Output()
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) && len(output) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
lines := strings.Fields(string(output))
|
||||
pids := make([]int, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(line))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
pids = append(pids, pid)
|
||||
}
|
||||
return pids, nil
|
||||
}
|
||||
|
||||
func prefixPipe(spec serviceSpec, reader io.Reader) {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
buf := make([]byte, 0, 64*1024)
|
||||
scanner.Buffer(buf, 1024*1024)
|
||||
for scanner.Scan() {
|
||||
logf(os.Stdout, spec, "%s", scanner.Text())
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
logf(os.Stderr, spec, "log pipe failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func logf(w io.Writer, spec serviceSpec, format string, args ...any) {
|
||||
line := fmt.Sprintf(format, args...)
|
||||
prefix := "[" + spec.Name + "]"
|
||||
if colorEnabled() && spec.Color != "" {
|
||||
prefix = "\x1b[" + spec.Color + "m" + prefix + "\x1b[0m"
|
||||
}
|
||||
printPlain(w, "%s %s", prefix, line)
|
||||
}
|
||||
|
||||
func printPlain(w io.Writer, format string, args ...any) {
|
||||
logMu.Lock()
|
||||
defer logMu.Unlock()
|
||||
fmt.Fprintf(w, format+"\n", args...)
|
||||
}
|
||||
|
||||
func colorEnabled() bool {
|
||||
if _, ok := os.LookupEnv("NO_COLOR"); ok {
|
||||
return false
|
||||
}
|
||||
return os.Getenv("TERM") != "dumb"
|
||||
}
|
||||
|
||||
func serviceNames(specs []serviceSpec) string {
|
||||
names := make([]string, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
names = append(names, spec.Name)
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
func signalContext() (context.Context, context.CancelFunc) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
signals := make(chan os.Signal, 1)
|
||||
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-signals
|
||||
cancel()
|
||||
}()
|
||||
return ctx, cancel
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@ -32,7 +32,8 @@ room-service 实例之间通过 Redis owner route 和 node registry 直连转发
|
||||
| 层级 | 地址 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 公网 API | `https://api.global-interaction.com` | HTTPS 443 由公网 CLB 转发到两台 `gateway-service:13000` |
|
||||
| 后台平台 | `https://hyapp-platform.global-interaction.com` | 香港 Lighthouse 上 nginx 终止 HTTPS,反代本机 `admin-server:13100` |
|
||||
| 后台前端 | `http://1.14.164.2:7001` | 广州 CVM `172.16.0.10` 上 nginx 托管 `admin-platform` 静态资源,`/api` 反代内网后台服务 |
|
||||
| 后台 API | `172.16.0.6:13100` | 广州 CVM `ins-0x5mjwmc` 运行 `hyapp-admin-server`,通过 `wg-quick@hyapp-admin` 访问生产内网依赖 |
|
||||
| room/user/activity/game/notice 内网入口 | `10.2.1.16:13001/13005/13006/13008/13009` | `lb-epvnr4o0` 的 Go 服务监听器 |
|
||||
| wallet 内网入口 | `10.2.1.15:13004` | `lb-4f5xi6p0` 的 Go 服务监听器 |
|
||||
|
||||
|
||||
@ -12,5 +12,6 @@ GRANT ALL PRIVILEGES ON hyapp_admin.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hyapp_cron.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hyapp_game.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hyapp_notice.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hyapp_statistics.* TO 'hyapp'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
|
||||
9
deploy/rocketmq/broker.conf
Normal file
9
deploy/rocketmq/broker.conf
Normal file
@ -0,0 +1,9 @@
|
||||
brokerClusterName = hyapp-local
|
||||
brokerName = broker-a
|
||||
brokerId = 0
|
||||
deleteWhen = 04
|
||||
fileReservedTime = 48
|
||||
brokerRole = ASYNC_MASTER
|
||||
flushDiskType = ASYNC_FLUSH
|
||||
autoCreateTopicEnable = true
|
||||
listenPort = 10911
|
||||
@ -3,6 +3,34 @@ x-go-build-args: &go-build-args
|
||||
GOSUMDB: "${GOSUMDB:-sum.golang.org}"
|
||||
|
||||
services:
|
||||
rocketmq-namesrv:
|
||||
image: apache/rocketmq:5.3.1
|
||||
container_name: rocketmq-namesrv
|
||||
environment:
|
||||
TZ: UTC
|
||||
JAVA_OPT_EXT: "-Xms256m -Xmx256m -Xmn128m"
|
||||
command: ["sh", "mqnamesrv"]
|
||||
ports:
|
||||
- "19876:9876"
|
||||
|
||||
rocketmq-broker:
|
||||
image: apache/rocketmq:5.3.1
|
||||
container_name: rocketmq-broker
|
||||
user: root
|
||||
environment:
|
||||
TZ: UTC
|
||||
NAMESRV_ADDR: "rocketmq-namesrv:9876"
|
||||
JAVA_OPT_EXT: "-Xms512m -Xmx512m -Xmn256m"
|
||||
command: ["sh", "mqbroker", "-n", "rocketmq-namesrv:9876", "-c", "/home/rocketmq/broker.conf"]
|
||||
ports:
|
||||
- "19009:10909"
|
||||
- "19011:10911"
|
||||
volumes:
|
||||
- ./deploy/rocketmq/broker.conf:/home/rocketmq/broker.conf:ro
|
||||
- rocketmq-store:/home/rocketmq/store
|
||||
depends_on:
|
||||
- rocketmq-namesrv
|
||||
|
||||
gateway-service:
|
||||
build:
|
||||
context: .
|
||||
@ -22,6 +50,8 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
rocketmq-broker:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:13000/healthz/ready >/dev/null"]
|
||||
interval: 5s
|
||||
@ -47,6 +77,8 @@ services:
|
||||
condition: service_healthy
|
||||
wallet-service:
|
||||
condition: service_healthy
|
||||
rocketmq-broker:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:13101/healthz/ready >/dev/null"]
|
||||
interval: 5s
|
||||
@ -68,6 +100,8 @@ services:
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
rocketmq-broker:
|
||||
condition: service_started
|
||||
user-service:
|
||||
condition: service_healthy
|
||||
activity-service:
|
||||
@ -93,6 +127,8 @@ services:
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
rocketmq-broker:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:13105/healthz/ready >/dev/null"]
|
||||
interval: 5s
|
||||
@ -114,6 +150,8 @@ services:
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
rocketmq-broker:
|
||||
condition: service_started
|
||||
user-service:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
@ -141,6 +179,8 @@ services:
|
||||
condition: service_healthy
|
||||
activity-service:
|
||||
condition: service_healthy
|
||||
rocketmq-broker:
|
||||
condition: service_started
|
||||
game-service:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
@ -164,6 +204,8 @@ services:
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
rocketmq-broker:
|
||||
condition: service_started
|
||||
wallet-service:
|
||||
condition: service_healthy
|
||||
user-service:
|
||||
@ -198,6 +240,29 @@ services:
|
||||
retries: 20
|
||||
start_period: 10s
|
||||
|
||||
statistics-service:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: services/statistics-service/Dockerfile
|
||||
args: *go-build-args
|
||||
container_name: statistics-service
|
||||
environment:
|
||||
TZ: UTC
|
||||
ports:
|
||||
- "13010:13010"
|
||||
- "13110:13110"
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
rocketmq-broker:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:13110/healthz/ready >/dev/null"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
start_period: 10s
|
||||
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
container_name: hyapp-mysql
|
||||
@ -226,6 +291,7 @@ services:
|
||||
- ./services/cron-service/deploy/mysql/initdb/001_cron_service.sql:/docker-entrypoint-initdb.d/009_cron_service.sql:ro
|
||||
- ./services/game-service/deploy/mysql/initdb/001_game_service.sql:/docker-entrypoint-initdb.d/010_game_service.sql:ro
|
||||
- ./services/notice-service/deploy/mysql/initdb/001_notice_service.sql:/docker-entrypoint-initdb.d/011_notice_service.sql:ro
|
||||
- ./services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql:/docker-entrypoint-initdb.d/012_statistics_service.sql:ro
|
||||
- ./deploy/mysql/initdb/999_local_grants.sql:/docker-entrypoint-initdb.d/999_local_grants.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -proot --silent"]
|
||||
@ -248,3 +314,4 @@ services:
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
rocketmq-store:
|
||||
|
||||
382
docs/flutter对接/Google Play充值Flutter对接.md
Normal file
382
docs/flutter对接/Google Play充值Flutter对接.md
Normal 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 补单队列。
|
||||
588
docs/flutter对接/幸运礼物Flutter对接.md
Normal file
588
docs/flutter对接/幸运礼物Flutter对接.md
Normal file
@ -0,0 +1,588 @@
|
||||
# 幸运礼物 Flutter 对接
|
||||
|
||||
本文描述 Flutter App 对接幸运礼物所需的 HTTP 接口、请求参数、返回值、错误处理和腾讯云 IM 自定义消息。幸运礼物支持多个互相独立的奖池,App 只负责传入 `pool_id`,不计算 RTP、倍率概率、奖池水位或中奖结果。
|
||||
|
||||
## 基础约定
|
||||
|
||||
本地开发地址:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:13000
|
||||
```
|
||||
|
||||
业务接口统一响应 envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `code` | 成功固定 `OK`;失败为稳定错误码。 |
|
||||
| `message` | 短错误文案,不用于客户端分支判断。 |
|
||||
| `request_id` | 服务端链路追踪 ID;客户端日志要记录。 |
|
||||
| `data` | 成功业务数据;失败时通常不存在。 |
|
||||
|
||||
通用请求头:
|
||||
|
||||
| Header | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `Authorization` | 是 | `Bearer <access_token>`。 |
|
||||
| `X-App-Code` | 否 | App 编码,默认 `lalu`;登录态接口最终以 token 内 app_code 为准。 |
|
||||
| `Content-Type` | POST 必填 | `application/json`。 |
|
||||
|
||||
所有 `*_ms` 都是 Unix epoch milliseconds。金额单位都是整数金币。
|
||||
|
||||
## 核心概念
|
||||
|
||||
| 概念 | 说明 |
|
||||
| --- | --- |
|
||||
| `gift_id` | 用户实际送出的幸运礼物 ID,例如某个 100 金币、5000 金币或 500000 金币礼物。 |
|
||||
| `pool_id` | 后台配置的幸运礼物奖池 ID,例如 `pool_95`、`pool_98`。不同 `pool_id` 的 RTP、奖池、风控和用户阶段完全隔离。 |
|
||||
| `command_id` | 本次送礼动作幂等键。用户同一次点击产生一个值,HTTP 超时或重试必须复用。 |
|
||||
| `coin_spent` | 服务端按礼物价格和数量计算的实际扣费金额,App 不上传该字段。 |
|
||||
| `multiplier_ppm` | 倍率的 ppm 表达,`1x=1000000`、`0.5x=500000`、`100x=100000000`。 |
|
||||
| `base_reward_coins` | 基础 RTP 返奖,计入平台成本控制。 |
|
||||
| `effective_reward_coins` | 用户可见总奖励,等于基础返奖、房间气氛奖励、活动补贴之和。 |
|
||||
|
||||
App 不要展示后台 RTP 数值,不要展示实时概率,不要本地推算“下一次该中”。客户端只展示服务端返回的结果。
|
||||
|
||||
## 当前实现状态
|
||||
|
||||
当前仓库已经具备:
|
||||
|
||||
| 能力 | 状态 |
|
||||
| --- | --- |
|
||||
| `POST /api/v1/rooms/gift/send` 支持 `pool_id` 入参 | 已有 |
|
||||
| 房间送礼 IM `room_gift_sent` 可携带 `pool_id` | 已有 |
|
||||
| `activity-service` 内部 gRPC `CheckLuckyGift` / `ExecuteLuckyGiftDraw` | 已有 |
|
||||
| 后台按 `pool_id` 配置多奖池 | 已有 |
|
||||
|
||||
Flutter 完整体验还需要后端补齐:
|
||||
|
||||
| 能力 | 建议 |
|
||||
| --- | --- |
|
||||
| App 查询某个奖池是否可抽 | 新增 HTTP `POST /api/v1/activities/lucky-gifts/check` |
|
||||
| 送礼响应直接返回抽奖结果 | 在 `/rooms/gift/send` 成功 `data` 中增加 `lucky_gift` |
|
||||
| 中奖房间表现 | 新增房间 IM `lucky_gift_drawn` |
|
||||
| 返奖到账 | 钱包侧新增幸运礼物返奖入账后继续发 `WalletBalanceChanged` |
|
||||
|
||||
下面文档按最终 Flutter 契约编写;已存在字段可直接对接,标注为“建议新增”的字段需要后端继续落地。
|
||||
|
||||
## 客户端流程
|
||||
|
||||
1. 进入房间后调用 `GET /api/v1/rooms/{room_id}/gift-panel` 获取礼物面板、金币余额和收礼人。
|
||||
2. 找到 `gift_type_code=lucky` 或 `gift_type_code=super_lucky` 的礼物,读取该礼物对应的 `pool_id`。当前可从 `presentation_json.lucky_pool_id` 下发,后续建议提升为顶层字段 `lucky_pool_id`。
|
||||
3. 用户点击幸运礼物前,可调用 `POST /api/v1/activities/lucky-gifts/check` 判断奖池是否启用。
|
||||
4. 用户确认送礼时调用 `POST /api/v1/rooms/gift/send`,请求体带上 `pool_id`。
|
||||
5. 成功响应里如果有 `lucky_gift`,立即播放中奖或未中奖反馈。
|
||||
6. 房间内监听 `room_gift_sent` 和 `lucky_gift_drawn` IM,给其他用户同步展示。
|
||||
7. 金币余额以 `WalletBalanceChanged` 私有 IM 或 `GET /api/v1/wallet/me/balances` 刷新为准,不在本地推算。
|
||||
|
||||
## 获取礼物面板
|
||||
|
||||
```http
|
||||
GET /api/v1/rooms/{room_id}/gift-panel
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
```
|
||||
|
||||
路径参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `room_id` | string | 是 | 当前语音房 ID。 |
|
||||
|
||||
成功响应节选:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {
|
||||
"coin_balance": 1000000,
|
||||
"recipients": [],
|
||||
"tabs": [],
|
||||
"gifts": [
|
||||
{
|
||||
"gift_id": "lucky_star_100",
|
||||
"name": "幸运星",
|
||||
"gift_type_code": "lucky",
|
||||
"charge_asset_type": "COIN",
|
||||
"coin_price": 100,
|
||||
"presentation_json": "{\"lucky_pool_id\":\"pool_95\"}",
|
||||
"resource": {
|
||||
"asset_url": "https://cdn.example/lucky_star.png",
|
||||
"animation_url": "https://cdn.example/lucky_star.svga"
|
||||
}
|
||||
}
|
||||
],
|
||||
"quantity_presets": [1, 9, 99, 999]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
幸运礼物识别规则:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `gift_type_code=lucky` | 普通幸运礼物。 |
|
||||
| `gift_type_code=super_lucky` | 超级幸运礼物。 |
|
||||
| `presentation_json.lucky_pool_id` | 建议下发的奖池 ID;为空时 App 传 `default` 或不传,由服务端按默认奖池处理。 |
|
||||
| `coin_price` | 单个礼物价格;实际扣费仍以服务端钱包价格为准。 |
|
||||
|
||||
## 检查幸运礼物状态
|
||||
|
||||
建议新增 App HTTP 接口:
|
||||
|
||||
```http
|
||||
POST /api/v1/activities/lucky-gifts/check
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"room_id": "lalu_xxx",
|
||||
"gift_id": "lucky_star_100",
|
||||
"pool_id": "pool_95"
|
||||
}
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `room_id` | string | 是 | 当前房间 ID。 |
|
||||
| `gift_id` | string | 是 | 用户准备送出的幸运礼物 ID。 |
|
||||
| `pool_id` | string | 否 | 奖池 ID;为空时服务端使用 `default`。 |
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {
|
||||
"enabled": true,
|
||||
"reason": "enabled",
|
||||
"pool_id": "pool_95",
|
||||
"gift_id": "lucky_star_100",
|
||||
"rule_version": 12,
|
||||
"experience_pool": "novice",
|
||||
"server_time_ms": 1779259000000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
返回字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | bool | 是否允许当前用户在当前房间抽该奖池。 |
|
||||
| `reason` | string | 服务端原因,客户端只做日志或灰度排查。 |
|
||||
| `pool_id` | string | 实际使用的奖池 ID。 |
|
||||
| `gift_id` | string | 礼物 ID。 |
|
||||
| `rule_version` | int64 | 当前奖池规则版本。 |
|
||||
| `experience_pool` | string | `novice`、`intermediate`、`advanced`,只影响展示文案或动效强度。 |
|
||||
| `server_time_ms` | int64 | 服务端当前时间。 |
|
||||
|
||||
客户端处理:
|
||||
|
||||
| 场景 | 处理 |
|
||||
| --- | --- |
|
||||
| `enabled=true` | 允许点击送礼。 |
|
||||
| `enabled=false` | 置灰幸运抽奖入口,但普通礼物展示可继续。 |
|
||||
| 接口失败 | 不在本地兜底开奖;可以提示稍后重试或隐藏幸运玩法。 |
|
||||
|
||||
## 送礼并抽奖
|
||||
|
||||
已存在 App HTTP 入口:
|
||||
|
||||
```http
|
||||
POST /api/v1/rooms/gift/send
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"room_id": "lalu_xxx",
|
||||
"command_id": "gift_1779259000000_10001_01",
|
||||
"target_type": "user",
|
||||
"target_user_ids": [10002],
|
||||
"gift_id": "lucky_star_100",
|
||||
"gift_count": 1,
|
||||
"pool_id": "pool_95"
|
||||
}
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `room_id` | string | 是 | 当前房间 ID。 |
|
||||
| `command_id` | string | 是 | 本次用户动作幂等键;同一次重试必须复用。 |
|
||||
| `target_type` | string | 否 | 当前主流程使用 `user`;为空按 `user`。 |
|
||||
| `target_user_ids` | int64[] | 是 | 收礼用户 ID;`target_type=user` 时必须且只能 1 个。 |
|
||||
| `target_user_id` | int64 | 否 | 单目标兼容字段;新代码优先用 `target_user_ids`。 |
|
||||
| `gift_id` | string | 是 | 礼物 ID。 |
|
||||
| `gift_count` | int32 | 是 | 礼物数量,必须大于 0。 |
|
||||
| `pool_id` | string | 否 | 幸运礼物奖池 ID;不同奖池完全隔离。普通礼物可不传。 |
|
||||
|
||||
成功响应节选:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {
|
||||
"result": {
|
||||
"applied": true,
|
||||
"room_version": 14,
|
||||
"server_time_ms": 1779259000000
|
||||
},
|
||||
"billing_receipt_id": "br_xxx",
|
||||
"room_heat": 123456,
|
||||
"gift_rank": [],
|
||||
"room": {},
|
||||
"treasure": {},
|
||||
"lucky_gift": {
|
||||
"enabled": true,
|
||||
"draw_id": "lucky_draw_xxx",
|
||||
"command_id": "gift_1779259000000_10001_01",
|
||||
"pool_id": "pool_95",
|
||||
"gift_id": "lucky_star_100",
|
||||
"rule_version": 12,
|
||||
"experience_pool": "novice",
|
||||
"selected_tier_id": "novice_2x",
|
||||
"multiplier_ppm": 2000000,
|
||||
"base_reward_coins": 200,
|
||||
"room_atmosphere_reward_coins": 0,
|
||||
"activity_subsidy_coins": 0,
|
||||
"effective_reward_coins": 200,
|
||||
"reward_status": "granted",
|
||||
"stage_feedback": false,
|
||||
"high_multiplier": false,
|
||||
"created_at_ms": 1779259000000
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`lucky_gift` 字段为建议新增。普通礼物或奖池未启用时可以为空。
|
||||
|
||||
`data` 字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `result.applied` | bool | 本次命令是否实际落地;幂等重放可能为 `false`。 |
|
||||
| `result.room_version` | int64 | 房间版本。 |
|
||||
| `result.server_time_ms` | int64 | 服务端时间。 |
|
||||
| `billing_receipt_id` | string | 钱包扣费回执;存在表示送礼扣费成功。 |
|
||||
| `room_heat` | int64 | 房间热度。 |
|
||||
| `gift_rank` | array | 房间礼物榜快照。 |
|
||||
| `room` | object | 房间快照。 |
|
||||
| `treasure` | object | 宝箱状态;如果房间宝箱开启可直接更新。 |
|
||||
| `lucky_gift` | object | 幸运礼物抽奖结果;建议新增。 |
|
||||
|
||||
`lucky_gift` 字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | bool | 本次是否执行了幸运礼物抽奖。 |
|
||||
| `draw_id` | string | 抽奖记录 ID。 |
|
||||
| `command_id` | string | 和送礼请求一致的幂等键。 |
|
||||
| `pool_id` | string | 本次使用的奖池 ID。 |
|
||||
| `gift_id` | string | 礼物 ID。 |
|
||||
| `rule_version` | int64 | 抽奖时使用的规则版本。 |
|
||||
| `experience_pool` | string | `novice`、`intermediate`、`advanced`。 |
|
||||
| `selected_tier_id` | string | 服务端选中的奖档 ID。 |
|
||||
| `multiplier_ppm` | int64 | 建议新增;用于直接展示倍率。后端暂未返回时客户端不要本地解析倍率。 |
|
||||
| `base_reward_coins` | int64 | 基础 RTP 返奖。 |
|
||||
| `room_atmosphere_reward_coins` | int64 | 房间气氛补贴奖励。 |
|
||||
| `activity_subsidy_coins` | int64 | 活动补贴奖励。 |
|
||||
| `effective_reward_coins` | int64 | 用户可见总奖励。 |
|
||||
| `reward_status` | string | `pending`、`granted`、`failed`。 |
|
||||
| `stage_feedback` | bool | 是否触发阶段反馈。可能没有金币奖励。 |
|
||||
| `high_multiplier` | bool | 是否属于高倍结果,可用于强化动效。 |
|
||||
| `created_at_ms` | int64 | 抽奖结果生成时间。 |
|
||||
|
||||
展示规则:
|
||||
|
||||
| 场景 | 处理 |
|
||||
| --- | --- |
|
||||
| `lucky_gift` 为空 | 按普通礼物成功展示。 |
|
||||
| `effective_reward_coins=0` 且 `stage_feedback=false` | 展示未中奖或普通送礼效果。 |
|
||||
| `stage_feedback=true` | 展示阶段进度、幸运值或轻量动画;不要本地加金币。 |
|
||||
| `effective_reward_coins>0` 且 `reward_status=granted` | 展示中奖金额和到账效果;余额仍以钱包 IM 为准。 |
|
||||
| `effective_reward_coins>0` 且 `reward_status=pending` | 展示“奖励处理中”,等待钱包 IM 或余额刷新。 |
|
||||
| `reward_status=failed` | 不本地加金币;提示稍后刷新或走补偿查询。 |
|
||||
| `high_multiplier=true` | 播放高倍中奖动效,并等待房间 IM 同步其他用户。 |
|
||||
|
||||
## 错误处理
|
||||
|
||||
通用错误:
|
||||
|
||||
| HTTP | `code` | 说明 | Flutter 处理 |
|
||||
| --- | --- | --- | --- |
|
||||
| 400 | `INVALID_JSON` | JSON 解析失败。 | 修正客户端请求体。 |
|
||||
| 400 | `INVALID_ARGUMENT` | 参数不合法,例如缺 `room_id`、`gift_id`、`command_id`。 | 不重试,提示操作失败并打点。 |
|
||||
| 401 | `UNAUTHORIZED` | token 无效或过期。 | 走登录刷新或重新登录。 |
|
||||
| 403 | `PROFILE_REQUIRED` | 用户未完成资料。 | 引导补全资料。 |
|
||||
| 403 | `PERMISSION_DENIED` | 没有权限。 | 提示不可操作。 |
|
||||
| 409 | `INSUFFICIENT_BALANCE` | 金币不足。 | 弹充值或余额不足提示。 |
|
||||
| 409 | `ROOM_CLOSED` | 房间已关闭。 | 退出或刷新房间状态。 |
|
||||
| 409 | `RULE_NOT_ACTIVE` | 幸运礼物奖池未启用。 | 置灰幸运玩法,不本地开奖。 |
|
||||
| 409 | `IDEMPOTENCY_CONFLICT` | 同一 `command_id` 对应的请求内容不一致。 | 生成新的 `command_id` 重新发起新动作;旧动作不再重放。 |
|
||||
| 409 | `CONFLICT` | 规则、奖池、水位或风控状态冲突。 | 提示稍后再试,必要时刷新面板。 |
|
||||
| 502 | `UPSTREAM_ERROR` | room、wallet、activity 等依赖暂不可用。 | 可保留同一个 `command_id` 重试同一次送礼。 |
|
||||
| 500 | `INTERNAL_ERROR` | 服务端内部异常。 | 记录 `request_id`,提示稍后重试。 |
|
||||
|
||||
幂等和重试规则:
|
||||
|
||||
| 场景 | 处理 |
|
||||
| --- | --- |
|
||||
| HTTP 超时、断网、502 | 使用同一个 `command_id` 重试。 |
|
||||
| 409 `IDEMPOTENCY_CONFLICT` | 不要复用该 `command_id`;说明客户端把同一个幂等键用于不同请求。 |
|
||||
| 收到成功响应且有 `billing_receipt_id` | 扣费已成功,不要再次生成新命令补发。 |
|
||||
| 成功响应丢失但钱包余额已变化 | 以钱包余额和房间 IM 为准,避免重复扣费。 |
|
||||
| App 进程重启 | 本地持久化最近未完成的 `command_id`,优先用同一键查询或重试。 |
|
||||
|
||||
## IM:房间送礼消息
|
||||
|
||||
已存在房间 IM:
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| 腾讯消息类型 | `TIMCustomElem` |
|
||||
| `Desc` | `room_gift_sent` |
|
||||
| `Ext` / `extension` | `room_system_message` |
|
||||
| 群 ID | 当前 `room_id` |
|
||||
| `CloudCustomData` | 与 `TIMCustomElem.Data` 相同的 JSON 字符串 |
|
||||
|
||||
Payload 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_xxx",
|
||||
"room_id": "lalu_xxx",
|
||||
"event_type": "room_gift_sent",
|
||||
"actor_user_id": 10001,
|
||||
"target_user_id": 10002,
|
||||
"gift_value": 100,
|
||||
"room_heat": 123456,
|
||||
"room_version": 14,
|
||||
"attributes": {
|
||||
"gift_id": "lucky_star_100",
|
||||
"pool_id": "pool_95",
|
||||
"gift_count": "1",
|
||||
"billing_receipt_id": "br_xxx",
|
||||
"coin_spent": "100",
|
||||
"gift_point_added": "10",
|
||||
"price_version": "pv_xxx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
处理规则:
|
||||
|
||||
| 场景 | 处理 |
|
||||
| --- | --- |
|
||||
| `Ext` 不是 `room_system_message` | 忽略。 |
|
||||
| `event_type` 不是 `room_gift_sent` | 交给其他房间消息处理器。 |
|
||||
| `event_id` 已处理 | 忽略。 |
|
||||
| `room_version` 小于本地当前版本 | 忽略或只展示轻量动画,不回滚房间状态。 |
|
||||
| `attributes.pool_id` 为空 | 按普通礼物处理。 |
|
||||
| `attributes.pool_id` 非空 | 展示幸运礼物送出效果,但中奖结果仍等 `lucky_gift_drawn` 或 HTTP `lucky_gift`。 |
|
||||
|
||||
## IM:幸运礼物开奖结果
|
||||
|
||||
建议新增房间 IM:
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| 腾讯消息类型 | `TIMCustomElem` |
|
||||
| `Desc` | `lucky_gift_drawn` |
|
||||
| `Ext` / `extension` | `room_system_message` |
|
||||
| 群 ID | 当前 `room_id` |
|
||||
| `CloudCustomData` | 与 `TIMCustomElem.Data` 相同的 JSON 字符串 |
|
||||
|
||||
Payload 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "lucky_draw_event_xxx",
|
||||
"room_id": "lalu_xxx",
|
||||
"event_type": "lucky_gift_drawn",
|
||||
"room_version": 14,
|
||||
"draw_id": "lucky_draw_xxx",
|
||||
"command_id": "gift_1779259000000_10001_01",
|
||||
"pool_id": "pool_95",
|
||||
"gift_id": "lucky_star_100",
|
||||
"gift_count": 1,
|
||||
"sender_user_id": 10001,
|
||||
"target_user_id": 10002,
|
||||
"coin_spent": 100,
|
||||
"selected_tier_id": "novice_2x",
|
||||
"multiplier_ppm": 2000000,
|
||||
"effective_reward_coins": 200,
|
||||
"reward_status": "granted",
|
||||
"stage_feedback": false,
|
||||
"high_multiplier": false,
|
||||
"created_at_ms": 1779259000000
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `event_id` | string | IM 事件幂等键。 |
|
||||
| `event_type` | string | 固定 `lucky_gift_drawn`。 |
|
||||
| `room_id` | string | 房间 ID。 |
|
||||
| `room_version` | int64 | 房间版本。 |
|
||||
| `draw_id` | string | 抽奖记录 ID。 |
|
||||
| `command_id` | string | 送礼幂等键。 |
|
||||
| `pool_id` | string | 奖池 ID。 |
|
||||
| `gift_id` | string | 礼物 ID。 |
|
||||
| `gift_count` | int32 | 礼物数量。 |
|
||||
| `sender_user_id` | int64 | 送礼用户。 |
|
||||
| `target_user_id` | int64 | 收礼用户。 |
|
||||
| `coin_spent` | int64 | 本次实际扣费。 |
|
||||
| `selected_tier_id` | string | 中奖奖档。 |
|
||||
| `multiplier_ppm` | int64 | 中奖倍率,建议新增。 |
|
||||
| `effective_reward_coins` | int64 | 用户可见奖励金币。 |
|
||||
| `reward_status` | string | `pending`、`granted`、`failed`。 |
|
||||
| `stage_feedback` | bool | 是否阶段反馈。 |
|
||||
| `high_multiplier` | bool | 是否高倍结果。 |
|
||||
| `created_at_ms` | int64 | 事件创建时间。 |
|
||||
|
||||
展示规则:
|
||||
|
||||
| 场景 | 处理 |
|
||||
| --- | --- |
|
||||
| 当前用户是 `sender_user_id` | 如果 HTTP 已展示过同一 `draw_id`,只更新状态,不重复播中奖动画。 |
|
||||
| 当前用户不是 `sender_user_id` | 展示房间内中奖横幅、飘屏或公屏提示。 |
|
||||
| `effective_reward_coins=0` | 不展示大奖飘屏,只保留普通送礼效果或阶段反馈。 |
|
||||
| `high_multiplier=true` | 展示高倍动画。 |
|
||||
| `reward_status=pending` | 文案使用“奖励处理中”,不要展示已到账。 |
|
||||
| JSON 解析失败或字段缺失 | 忽略并记录客户端日志。 |
|
||||
|
||||
## IM:钱包余额变更
|
||||
|
||||
幸运礼物扣费和返奖都应通过钱包私有 IM 同步余额。该 IM 已在钱包文档中定义。
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| 腾讯消息类型 | `TIMCustomElem` |
|
||||
| `Desc` | `WalletBalanceChanged` |
|
||||
| `Ext` / `extension` | `wallet_notice` |
|
||||
| 通道 | C2C 私有消息 |
|
||||
|
||||
Payload 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "WalletBalanceChanged",
|
||||
"event_id": "wev_xxx",
|
||||
"app_code": "lalu",
|
||||
"transaction_id": "tx_xxx",
|
||||
"command_id": "gift_1779259000000_10001_01",
|
||||
"user_id": "10001",
|
||||
"asset_type": "COIN",
|
||||
"available_delta": -100,
|
||||
"frozen_delta": 0,
|
||||
"available_after": 999900,
|
||||
"frozen_after": 0,
|
||||
"balance_version": 18,
|
||||
"created_at_ms": 1779259000000,
|
||||
"source_created_at_ms": 1779259000000,
|
||||
"metadata": {
|
||||
"biz_type": "gift_debit",
|
||||
"gift_id": "lucky_star_100",
|
||||
"pool_id": "pool_95"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
返奖到账时 `available_delta` 为正数,`metadata.biz_type` 建议使用 `lucky_gift_reward`。
|
||||
|
||||
处理规则:
|
||||
|
||||
| 场景 | 处理 |
|
||||
| --- | --- |
|
||||
| `Ext` 不是 `wallet_notice` | 忽略。 |
|
||||
| `event_type` 不是 `WalletBalanceChanged` | 忽略。 |
|
||||
| `event_id` 已处理 | 忽略。 |
|
||||
| 本地 `asset_type` 的版本 `>= balance_version` | 忽略。 |
|
||||
| 长时间没有收到余额 IM | 调 `GET /api/v1/wallet/me/balances` 兜底刷新。 |
|
||||
|
||||
## IM:高倍全站或区域播报
|
||||
|
||||
建议新增播报 IM,用于高倍中奖或运营配置的大额中奖展示。
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| 腾讯消息类型 | `TIMCustomElem` |
|
||||
| `Desc` | `lucky_gift_big_win` |
|
||||
| `Ext` / `extension` | `im_broadcast` |
|
||||
| 群 ID | 全站播报群或区域播报群 |
|
||||
|
||||
Payload 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "lucky_big_win_xxx",
|
||||
"broadcast_type": "lucky_gift_big_win",
|
||||
"scope": "region",
|
||||
"app_code": "lalu",
|
||||
"region_id": 1001,
|
||||
"room_id": "lalu_xxx",
|
||||
"sender_user_id": 10001,
|
||||
"target_user_id": 10002,
|
||||
"pool_id": "pool_95",
|
||||
"gift_id": "lucky_star_100",
|
||||
"multiplier_ppm": 100000000,
|
||||
"effective_reward_coins": 10000,
|
||||
"sent_at_ms": 1779259000000,
|
||||
"action": {
|
||||
"type": "enter_room",
|
||||
"room_id": "lalu_xxx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
处理规则:
|
||||
|
||||
| 场景 | 处理 |
|
||||
| --- | --- |
|
||||
| 当前用户已在该房间且已收到 `lucky_gift_drawn` | 可以只展示一次,避免重复刷屏。 |
|
||||
| 当前用户不在该房间 | 展示全站或区域飘屏,点击 `action` 进房。 |
|
||||
| `event_id` 已处理 | 忽略。 |
|
||||
|
||||
## App 最小对接顺序
|
||||
|
||||
1. 登录后调用 `GET /api/v1/im/usersig`,登录腾讯云 IM。
|
||||
2. 进入房间后加入房间 IM 群,并调用 `GET /api/v1/rooms/{room_id}/gift-panel`。
|
||||
3. 从礼物面板识别幸运礼物和对应 `pool_id`。
|
||||
4. 用户点击送礼时生成 `command_id`,调用 `POST /api/v1/rooms/gift/send`。
|
||||
5. 使用 HTTP 响应里的 `lucky_gift` 做当前用户即时反馈。
|
||||
6. 监听房间 IM `room_gift_sent`、`lucky_gift_drawn`,同步其他用户看到的表现。
|
||||
7. 监听 C2C IM `WalletBalanceChanged`,更新金币余额。
|
||||
8. 任意 IM 丢失、页面重进或余额不一致时,重新拉取礼物面板和钱包余额纠正状态。
|
||||
|
||||
497
docs/flutter对接/用户排行榜Flutter对接.md
Normal file
497
docs/flutter对接/用户排行榜Flutter对接.md
Normal file
@ -0,0 +1,497 @@
|
||||
# 用户排行榜 Flutter 对接
|
||||
|
||||
本文描述 Flutter App 对接用户排行榜、送礼榜、收礼榜和房间榜的 HTTP 接口。榜单事实来自 `wallet-service` 已成功的礼物扣费流水;客户端只展示 gateway 返回的榜单投影,不在本地重算排名、礼物价值或统计窗口。
|
||||
|
||||
## 基础约定
|
||||
|
||||
本地开发地址:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:13000
|
||||
```
|
||||
|
||||
业务接口统一响应 envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `code` | 成功固定 `OK`;失败为稳定错误码。 |
|
||||
| `message` | 短错误文案,不用于客户端分支判断。 |
|
||||
| `request_id` | 服务端链路追踪 ID;客户端日志要记录。 |
|
||||
| `data` | 成功业务数据;失败时通常不存在。 |
|
||||
|
||||
通用请求头:
|
||||
|
||||
| Header | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `Authorization` | 是 | `Bearer <access_token>`。 |
|
||||
| `X-App-Code` | 否 | App 编码,默认 `lalu`;登录态接口最终以 token 内 app_code 为准。 |
|
||||
|
||||
所有 `*_ms` 都是 Unix epoch milliseconds。榜单统计窗口按 UTC 计算,不按用户本地时区、设备时区或 IP 推断时区计算。
|
||||
|
||||
## 榜单接口
|
||||
|
||||
```http
|
||||
GET /api/v1/activities/user-leaderboards
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
```
|
||||
|
||||
Query 参数:
|
||||
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `board_type` | string | 否 | `sent` | 榜单类型。`sent` 是送礼榜;`received` 是收礼榜;`room` 是房间收礼榜。 |
|
||||
| `period` | string | 否 | `today` | 统计周期。`today`、`week`、`month`。 |
|
||||
| `page` | int32 | 否 | `1` | 页码,从 1 开始。必须大于 0。 |
|
||||
| `page_size` | int32 | 否 | `20` | 每页数量。必须大于 0;服务端最大返回 `100`。 |
|
||||
|
||||
`board_type` 兼容别名:
|
||||
|
||||
| 标准值 | 兼容传参 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `sent` | 空字符串、`send`、`sender`、`gift_sent`、`user_sent` | 按送礼用户聚合,展示用户送礼贡献。 |
|
||||
| `received` | `receive`、`receiver`、`gift_received`、`user_received` | 按收礼用户聚合,展示用户收礼魅力。 |
|
||||
| `room` | `rooms`、`room_gift`、`room_gifts` | 按房间聚合,展示房间礼物流水。 |
|
||||
|
||||
`period` 兼容别名:
|
||||
|
||||
| 标准值 | 兼容传参 | 统计窗口 |
|
||||
| --- | --- | --- |
|
||||
| `today` | 空字符串、`day`、`daily` | UTC 当日 00:00:00 到当前服务端时间。 |
|
||||
| `week` | `weekly` | UTC 本周周一 00:00:00 到当前服务端时间。 |
|
||||
| `month` | `monthly` | UTC 本月 1 日 00:00:00 到当前服务端时间。 |
|
||||
|
||||
服务端实际查询范围是 `[start_at_ms, end_at_ms)`,其中 `end_at_ms` 是本次请求的当前服务端时间。排序固定为 `gift_value DESC, last_gift_at_ms DESC, subject_id ASC`;礼物价值相同的情况下,最近送礼时间更晚的排名更靠前,再用用户 ID 或房间 ID 保证稳定顺序。
|
||||
|
||||
### 送礼榜
|
||||
|
||||
```http
|
||||
GET /api/v1/activities/user-leaderboards?board_type=sent&period=today&page=1&page_size=20
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
```
|
||||
|
||||
送礼榜按 `sender_user_id` 聚合。返回项里的 `user_id` 和 `user` 表示送礼用户。
|
||||
|
||||
### 收礼榜
|
||||
|
||||
```http
|
||||
GET /api/v1/activities/user-leaderboards?board_type=received&period=week&page=1&page_size=20
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
```
|
||||
|
||||
收礼榜按 `target_user_id` 聚合。返回项里的 `user_id` 和 `user` 表示收礼用户。
|
||||
|
||||
### 房间榜
|
||||
|
||||
```http
|
||||
GET /api/v1/activities/user-leaderboards?board_type=room&period=month&page=1&page_size=20
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
```
|
||||
|
||||
房间榜按 `room_id` 聚合。返回项里不会返回 `user` 和 `my_rank`;`room` 会补充房间短号、名称和头像字段,客户端头像优先读 `cover_url`,兼容字段为 `room_avatar`。
|
||||
|
||||
## 房间内贡献榜边界
|
||||
|
||||
本接口是全局活动榜,按 `board_type + period` 查询成功送礼流水。房间页里的当前房间送礼贡献榜不是通过本接口读取:
|
||||
|
||||
| 场景 | 接口 | 榜单字段 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 首次进房 | `POST /api/v1/rooms/join` | `data.contribution_rank` | JoinRoom 成功后返回当前房间贡献榜前 10。 |
|
||||
| 房间页重拉快照 | `GET /api/v1/rooms/snapshot?room_id={room_id}` | `data.contribution_rank` | 只读 Room Cell/snapshot,不刷新 presence。 |
|
||||
| 房间详情首屏 | `GET /api/v1/rooms/{room_id}/detail` | `data.rank_top` | 房间详情聚合版,额外补 viewer 权限、IM/RTC 和资料。 |
|
||||
|
||||
房间内贡献榜字段是 `user_id`、`score`、`gift_value`、`updated_at_ms`,只代表当前房间内的本地贡献排名;它不等同于用户送礼榜或收礼榜,也不能和本接口的 `my_rank` 混用。
|
||||
|
||||
## 成功返回
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_abc",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"rank": 1,
|
||||
"user_id": "10001",
|
||||
"gift_value": 188800,
|
||||
"gift_count": 236,
|
||||
"transaction_count": 42,
|
||||
"last_gift_at_ms": 1779259000000,
|
||||
"user": {
|
||||
"user_id": "10001",
|
||||
"display_user_id": "888888",
|
||||
"username": "Luna",
|
||||
"avatar": "https://cdn.example/avatar/10001.png"
|
||||
}
|
||||
}
|
||||
],
|
||||
"total": 168,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"board_type": "sent",
|
||||
"period": "today",
|
||||
"start_at_ms": 1779206400000,
|
||||
"end_at_ms": 1779259000000,
|
||||
"server_time_ms": 1779259000000,
|
||||
"my_rank": {
|
||||
"rank": 12,
|
||||
"user_id": "10099",
|
||||
"gift_value": 9200,
|
||||
"gift_count": 18,
|
||||
"transaction_count": 6,
|
||||
"last_gift_at_ms": 1779258600000,
|
||||
"user": {
|
||||
"user_id": "10099",
|
||||
"display_user_id": "10099",
|
||||
"username": "Me",
|
||||
"avatar": "https://cdn.example/avatar/me.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
房间榜返回示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_room",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"rank": 1,
|
||||
"room_id": "lalu_534d076a-7cbd-4b23-a297-0c10a3c2fa72",
|
||||
"gift_value": 660000,
|
||||
"gift_count": 580,
|
||||
"transaction_count": 95,
|
||||
"last_gift_at_ms": 1779259000000,
|
||||
"room": {
|
||||
"room_id": "lalu_534d076a-7cbd-4b23-a297-0c10a3c2fa72"
|
||||
}
|
||||
}
|
||||
],
|
||||
"total": 20,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"board_type": "room",
|
||||
"period": "month",
|
||||
"start_at_ms": 1777564800000,
|
||||
"end_at_ms": 1779259000000,
|
||||
"server_time_ms": 1779259000000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`data` 字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `items` | array | 当前页榜单项。空数组表示当前页无数据。 |
|
||||
| `total` | int64 | 当前榜单和周期下的总上榜主体数。 |
|
||||
| `page` | int32 | 服务端解析后的页码。 |
|
||||
| `page_size` | int32 | 服务端实际使用的每页数量;请求超过 100 时返回 100。 |
|
||||
| `board_type` | string | 服务端归一化后的榜单类型:`sent`、`received`、`room`。 |
|
||||
| `period` | string | 服务端归一化后的统计周期:`today`、`week`、`month`。 |
|
||||
| `start_at_ms` | int64 | 本次统计窗口开始时间,UTC。 |
|
||||
| `end_at_ms` | int64 | 本次统计窗口结束时间,等于请求时的当前服务端时间。 |
|
||||
| `server_time_ms` | int64 | 服务端当前时间,可用于前端展示“更新时间”。 |
|
||||
| `my_rank` | object? | 当前登录用户在送礼榜或收礼榜中的排名;未上榜或 `board_type=room` 时不返回。 |
|
||||
|
||||
`items[]` / `my_rank` 字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `rank` | int64 | 排名,从 1 开始。 |
|
||||
| `user_id` | string | 用户榜单主体 ID;`sent`、`received` 才有。 |
|
||||
| `room_id` | string | 房间榜单主体 ID;`room` 才有。 |
|
||||
| `gift_value` | int64 | 统计窗口内礼物价值总和,用于排名和主数值展示。 |
|
||||
| `gift_count` | int64 | 统计窗口内礼物数量总和。 |
|
||||
| `transaction_count` | int64 | 统计窗口内成功送礼扣费流水数。 |
|
||||
| `last_gift_at_ms` | int64 | 该主体最近一次成功送礼流水时间。 |
|
||||
| `user` | object? | 用户资料;正常会补充展示 ID、昵称和头像;本地未注入用户资料 client 时至少返回 `user_id`。 |
|
||||
| `room` | object? | 房间基础投影;房间榜返回 `room_id`、`room_short_id`、`title`、`cover_url`、`room_avatar`。 |
|
||||
|
||||
`user` 字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `user_id` | string | 用户长 ID,字符串格式返回,避免 Flutter/JS 精度问题。 |
|
||||
| `display_user_id` | string | 展示 ID,可优先用于 UI 展示。 |
|
||||
| `username` | string | 用户昵称。 |
|
||||
| `avatar` | string | 头像 URL。 |
|
||||
|
||||
## Flutter 解析示例
|
||||
|
||||
```dart
|
||||
class ApiEnvelope<T> {
|
||||
ApiEnvelope({
|
||||
required this.code,
|
||||
required this.message,
|
||||
required this.requestId,
|
||||
this.data,
|
||||
});
|
||||
|
||||
final String code;
|
||||
final String message;
|
||||
final String requestId;
|
||||
final T? data;
|
||||
|
||||
factory ApiEnvelope.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
T Function(Object? raw) parseData,
|
||||
) {
|
||||
return ApiEnvelope<T>(
|
||||
code: json['code'] as String? ?? '',
|
||||
message: json['message'] as String? ?? '',
|
||||
requestId: json['request_id'] as String? ?? '',
|
||||
data: json.containsKey('data') ? parseData(json['data']) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UserLeaderboardPage {
|
||||
UserLeaderboardPage({
|
||||
required this.items,
|
||||
required this.total,
|
||||
required this.page,
|
||||
required this.pageSize,
|
||||
required this.boardType,
|
||||
required this.period,
|
||||
required this.startAtMs,
|
||||
required this.endAtMs,
|
||||
required this.serverTimeMs,
|
||||
this.myRank,
|
||||
});
|
||||
|
||||
final List<UserLeaderboardItem> items;
|
||||
final int total;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
final String boardType;
|
||||
final String period;
|
||||
final int startAtMs;
|
||||
final int endAtMs;
|
||||
final int serverTimeMs;
|
||||
final UserLeaderboardItem? myRank;
|
||||
|
||||
factory UserLeaderboardPage.fromJson(Map<String, dynamic> json) {
|
||||
return UserLeaderboardPage(
|
||||
items: (json['items'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(UserLeaderboardItem.fromJson)
|
||||
.toList(),
|
||||
total: (json['total'] as num?)?.toInt() ?? 0,
|
||||
page: (json['page'] as num?)?.toInt() ?? 1,
|
||||
pageSize: (json['page_size'] as num?)?.toInt() ?? 20,
|
||||
boardType: json['board_type'] as String? ?? 'sent',
|
||||
period: json['period'] as String? ?? 'today',
|
||||
startAtMs: (json['start_at_ms'] as num?)?.toInt() ?? 0,
|
||||
endAtMs: (json['end_at_ms'] as num?)?.toInt() ?? 0,
|
||||
serverTimeMs: (json['server_time_ms'] as num?)?.toInt() ?? 0,
|
||||
myRank: json['my_rank'] is Map<String, dynamic>
|
||||
? UserLeaderboardItem.fromJson(json['my_rank'] as Map<String, dynamic>)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UserLeaderboardItem {
|
||||
UserLeaderboardItem({
|
||||
required this.rank,
|
||||
required this.giftValue,
|
||||
required this.giftCount,
|
||||
required this.transactionCount,
|
||||
required this.lastGiftAtMs,
|
||||
this.userId,
|
||||
this.roomId,
|
||||
this.user,
|
||||
this.room,
|
||||
});
|
||||
|
||||
final int rank;
|
||||
final String? userId;
|
||||
final String? roomId;
|
||||
final int giftValue;
|
||||
final int giftCount;
|
||||
final int transactionCount;
|
||||
final int lastGiftAtMs;
|
||||
final LeaderboardUser? user;
|
||||
final LeaderboardRoom? room;
|
||||
|
||||
factory UserLeaderboardItem.fromJson(Map<String, dynamic> json) {
|
||||
return UserLeaderboardItem(
|
||||
rank: (json['rank'] as num?)?.toInt() ?? 0,
|
||||
userId: json['user_id'] as String?,
|
||||
roomId: json['room_id'] as String?,
|
||||
giftValue: (json['gift_value'] as num?)?.toInt() ?? 0,
|
||||
giftCount: (json['gift_count'] as num?)?.toInt() ?? 0,
|
||||
transactionCount: (json['transaction_count'] as num?)?.toInt() ?? 0,
|
||||
lastGiftAtMs: (json['last_gift_at_ms'] as num?)?.toInt() ?? 0,
|
||||
user: json['user'] is Map<String, dynamic>
|
||||
? LeaderboardUser.fromJson(json['user'] as Map<String, dynamic>)
|
||||
: null,
|
||||
room: json['room'] is Map<String, dynamic>
|
||||
? LeaderboardRoom.fromJson(json['room'] as Map<String, dynamic>)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LeaderboardUser {
|
||||
LeaderboardUser({
|
||||
required this.userId,
|
||||
required this.displayUserId,
|
||||
required this.username,
|
||||
required this.avatar,
|
||||
});
|
||||
|
||||
final String userId;
|
||||
final String displayUserId;
|
||||
final String username;
|
||||
final String avatar;
|
||||
|
||||
factory LeaderboardUser.fromJson(Map<String, dynamic> json) {
|
||||
return LeaderboardUser(
|
||||
userId: json['user_id'] as String? ?? '',
|
||||
displayUserId: json['display_user_id'] as String? ?? '',
|
||||
username: json['username'] as String? ?? '',
|
||||
avatar: json['avatar'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LeaderboardRoom {
|
||||
LeaderboardRoom({
|
||||
required this.roomId,
|
||||
required this.roomShortId,
|
||||
required this.title,
|
||||
required this.coverUrl,
|
||||
required this.roomAvatar,
|
||||
});
|
||||
|
||||
final String roomId;
|
||||
final String roomShortId;
|
||||
final String title;
|
||||
final String coverUrl;
|
||||
final String roomAvatar;
|
||||
|
||||
factory LeaderboardRoom.fromJson(Map<String, dynamic> json) {
|
||||
return LeaderboardRoom(
|
||||
roomId: json['room_id'] as String? ?? '',
|
||||
roomShortId: json['room_short_id'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
coverUrl: json['cover_url'] as String? ?? '',
|
||||
roomAvatar: json['room_avatar'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
请求封装示例:
|
||||
|
||||
```dart
|
||||
enum LeaderboardType { sent, received, room }
|
||||
enum LeaderboardPeriod { today, week, month }
|
||||
|
||||
extension on LeaderboardType {
|
||||
String get wireName => name;
|
||||
}
|
||||
|
||||
extension on LeaderboardPeriod {
|
||||
String get wireName => name;
|
||||
}
|
||||
|
||||
Future<UserLeaderboardPage> fetchUserLeaderboard({
|
||||
required Dio dio,
|
||||
required LeaderboardType type,
|
||||
required LeaderboardPeriod period,
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
}) async {
|
||||
final response = await dio.get<Map<String, dynamic>>(
|
||||
'/api/v1/activities/user-leaderboards',
|
||||
queryParameters: {
|
||||
'board_type': type.wireName,
|
||||
'period': period.wireName,
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
},
|
||||
);
|
||||
final envelope = ApiEnvelope<UserLeaderboardPage>.fromJson(
|
||||
response.data ?? const <String, dynamic>{},
|
||||
(raw) => UserLeaderboardPage.fromJson(raw as Map<String, dynamic>),
|
||||
);
|
||||
if (response.statusCode != 200 || envelope.code != 'OK' || envelope.data == null) {
|
||||
throw ApiException(
|
||||
code: envelope.code,
|
||||
message: envelope.message,
|
||||
requestId: envelope.requestId,
|
||||
);
|
||||
}
|
||||
return envelope.data!;
|
||||
}
|
||||
|
||||
class ApiException implements Exception {
|
||||
ApiException({
|
||||
required this.code,
|
||||
required this.message,
|
||||
required this.requestId,
|
||||
});
|
||||
|
||||
final String code;
|
||||
final String message;
|
||||
final String requestId;
|
||||
|
||||
@override
|
||||
String toString() => 'ApiException($code, requestId: $requestId)';
|
||||
}
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
所有响应先解析外层 envelope。只有 HTTP status 为 `200` 且 `code == "OK"` 时读取 `data`;其他情况不要使用 `data`。
|
||||
|
||||
错误响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "INVALID_ARGUMENT",
|
||||
"message": "invalid argument",
|
||||
"request_id": "req_bad"
|
||||
}
|
||||
```
|
||||
|
||||
| HTTP | `code` | 场景 | Flutter 处理 |
|
||||
| --- | --- | --- | --- |
|
||||
| `400` | `INVALID_ARGUMENT` | `board_type`、`period` 不支持;`page` 或 `page_size` 不是正整数。 | 不重试;修正本地入参,客户端只发送枚举值。 |
|
||||
| `401` | `UNAUTHORIZED` | access token 缺失、过期或非法。 | 跳登录或刷新登录态;记录 `request_id`。 |
|
||||
| `401` | `SESSION_REVOKED` | 会话已被服务端撤销。 | 清理本地登录态并重新登录。 |
|
||||
| `403` | `PROFILE_REQUIRED` | 当前 token 对应用户未完成资料。 | 跳资料补全流程。 |
|
||||
| `405` | `INVALID_ARGUMENT` | 使用了非 GET 方法。 | 修正客户端请求方法。 |
|
||||
| `502` | `UPSTREAM_ERROR` | 钱包库、用户资料服务或上游依赖异常。 | 展示通用失败,可保留旧榜单并允许下拉重试;记录 `request_id`。 |
|
||||
| `503` | `UPSTREAM_ERROR` | gateway 未配置榜单钱包只读库。 | 展示通用失败,记录 `request_id`。 |
|
||||
|
||||
## 客户端展示规则
|
||||
|
||||
榜单页建议用 `board_type + period + page + page_size` 做缓存 key。用户切换榜单类型或周期时重新请求第一页;上拉加载下一页时用返回的 `total` 判断是否还有更多:`page * page_size < total`。
|
||||
|
||||
`my_rank` 只表示当前登录用户在当前用户榜单中的排名。送礼榜里是“我的送礼排名”,收礼榜里是“我的收礼排名”;房间榜没有个人排名。未上榜时 `my_rank` 不存在,客户端展示“未上榜”即可,不要自行用 `items` 推断。
|
||||
|
||||
时间展示用 `server_time_ms` 或 `end_at_ms` 标注“更新于”。`today`、`week`、`month` 的统计口径固定为 UTC,展示文案如果需要本地化,只能改时间格式,不能改变请求周期含义。
|
||||
|
||||
榜单数据当前没有单独 IM 推送。用户送礼后如果当前页面正在展示对应榜单,可以延迟刷新第一页或由用户手动下拉刷新;不要依赖房间公屏 IM 来本地累加全局榜单。
|
||||
554
docs/flutter对接/礼物TabFlutter对接.md
Normal file
554
docs/flutter对接/礼物TabFlutter对接.md
Normal file
@ -0,0 +1,554 @@
|
||||
# 礼物 Tab Flutter 对接
|
||||
|
||||
本文描述 Flutter App 在语音房内接入礼物 Tab 的接口、字段、交互和 IM 同步规则。礼物配置、价格、扣费、房间表现和统计事实都以后端为准;App 只展示 gateway 返回的数据,不在本地推算价格、余额、热度或礼物榜。
|
||||
|
||||
## 边界
|
||||
|
||||
- 礼物 Tab 基础配置入口是 `GET /api/v1/gift-tabs`,不需要 `room_id`,只返回后台启用的 Tab 配置。
|
||||
- `GET /api/v1/gift-tabs` 不按礼物数量过滤;只要后台启用了 Tab,即使该 Tab 下当前没有礼物也必须返回给 App 展示。
|
||||
- `GET /api/v1/gift-tabs` 会按 Tab 返回当前 active 礼物列表,App 可以用它提前预加载礼物资源;无礼物的 Tab 返回 `gifts: []`。
|
||||
- 房间送礼面板入口是 `GET /api/v1/rooms/{room_id}/gift-panel`,返回当前房间可送礼物、可收礼人、金币余额和房间内可展示 Tab。
|
||||
- `GET /api/v1/gift-tabs` 不能替代 `GET /api/v1/rooms/{room_id}/gift-panel`;App 真正送礼前必须以房间面板返回的数据为准。
|
||||
- 送礼只调用 `POST /api/v1/rooms/gift/send`。
|
||||
- 金币余额以礼物面板、送礼响应后的钱包通知或 `GET /api/v1/wallet/me/balances` 为准。
|
||||
- 房间内其他用户看到的礼物表现以腾讯云 IM 自定义消息 `room_gift_sent` 为准。
|
||||
- `command_id` 是送礼幂等键;同一次用户点击重试必须复用同一个值。
|
||||
- 当前 App 送礼主流程只按单个用户收礼处理:`target_type=user`,`target_user_ids` 只传 1 个用户。
|
||||
|
||||
## 交互流程
|
||||
|
||||
1. 用户进入房间并完成 `JoinRoom`。
|
||||
2. App 可在启动或进入房间前调用 `GET /api/v1/gift-tabs` 预加载 Tab 配置。
|
||||
3. 打开礼物 Tab 时调用 `GET /api/v1/rooms/{room_id}/gift-panel`。
|
||||
4. 用 `tabs` 渲染当前房间分类,用 `gifts` 渲染礼物网格,用 `recipients` 渲染收礼人。
|
||||
5. 用户选择收礼人、礼物和数量。
|
||||
6. 点击发送时生成稳定 `command_id`,调用 `POST /api/v1/rooms/gift/send`。
|
||||
7. 发送成功后用响应里的 `room_heat`、`gift_rank`、`treasure` 更新房间 UI。
|
||||
8. 监听房间 IM `room_gift_sent`,同步播放礼物动效。
|
||||
9. 监听私有钱包通知或主动刷新余额,不本地扣减余额作为最终值。
|
||||
|
||||
## 获取礼物 Tab 配置
|
||||
|
||||
```http
|
||||
GET /api/v1/gift-tabs
|
||||
X-App-Code: lalu
|
||||
```
|
||||
|
||||
查询参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `region_id` | int64 | 否 | 不传时预加载全部 active 礼物;传入时只返回 GLOBAL 和该区域可用礼物。 |
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_abc",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"key": "normal",
|
||||
"gift_type_code": "normal",
|
||||
"name": "普通礼物",
|
||||
"label": "Gift",
|
||||
"tab_key": "Gift",
|
||||
"status": "active",
|
||||
"order": 10,
|
||||
"sort_order": 10,
|
||||
"created_at_ms": 1710000000000,
|
||||
"updated_at_ms": 1710000000000,
|
||||
"gifts": [
|
||||
{
|
||||
"gift_id": "rose",
|
||||
"resource_id": 11,
|
||||
"gift_type_code": "normal",
|
||||
"name": "Rose",
|
||||
"coin_price": 100,
|
||||
"resource": {
|
||||
"resource_id": 11,
|
||||
"resource_code": "gift_rose",
|
||||
"resource_type": "gift",
|
||||
"name": "Rose",
|
||||
"status": "active",
|
||||
"asset_url": "https://cdn.example/rose.png"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `key` | string | Tab 稳定 key,等同于礼物类型编码。 |
|
||||
| `gift_type_code` | string | 礼物类型编码,用于和 `gifts[].gift_type_code` 对齐。 |
|
||||
| `name` | string | 后台配置的中文显示名。 |
|
||||
| `label` | string | App Tab 展示名,来源于后台 `tab_key`。 |
|
||||
| `tab_key` | string | 后台配置的 Tab Name。 |
|
||||
| `status` | string | 当前只返回启用中的配置,正常为 `active`。 |
|
||||
| `order` | int | App 排序值,等同于 `sort_order`。 |
|
||||
| `sort_order` | int | 后台配置排序值。 |
|
||||
| `created_at_ms` | int64 | 创建时间,Unix epoch milliseconds。 |
|
||||
| `updated_at_ms` | int64 | 更新时间,Unix epoch milliseconds。 |
|
||||
| `gifts` | array | 当前 Tab 下的 active 礼物列表;没有礼物时返回空数组。 |
|
||||
|
||||
## 获取礼物面板
|
||||
|
||||
```http
|
||||
GET /api/v1/rooms/{room_id}/gift-panel
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
```
|
||||
|
||||
路径参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `room_id` | string | 是 | 当前语音房 ID。 |
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_abc",
|
||||
"data": {
|
||||
"coin_balance": 1000000,
|
||||
"recipients": [
|
||||
{
|
||||
"target_type": "user",
|
||||
"user_id": "10002",
|
||||
"seat_no": 1,
|
||||
"label": "Alice",
|
||||
"profile": {
|
||||
"user_id": "10002",
|
||||
"username": "Alice",
|
||||
"avatar": "https://cdn.example/avatar.png",
|
||||
"display_user_id": "10002",
|
||||
"vip": {},
|
||||
"level": {},
|
||||
"badges": [],
|
||||
"avatar_frame": {},
|
||||
"vehicle": {},
|
||||
"charm": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"tabs": [
|
||||
{
|
||||
"key": "all",
|
||||
"order": 0
|
||||
},
|
||||
{
|
||||
"key": "normal",
|
||||
"gift_type_code": "normal",
|
||||
"label": "normal",
|
||||
"order": 10
|
||||
}
|
||||
],
|
||||
"gifts": [
|
||||
{
|
||||
"gift_id": "rose",
|
||||
"resource_id": 11,
|
||||
"resource": {
|
||||
"resource_id": 11,
|
||||
"resource_code": "gift_rose",
|
||||
"resource_type": "gift",
|
||||
"name": "Rose",
|
||||
"status": "active",
|
||||
"grantable": true,
|
||||
"grant_strategy": "increase_quantity",
|
||||
"usage_scopes": ["gift"],
|
||||
"asset_url": "https://cdn.example/rose.png",
|
||||
"preview_url": "",
|
||||
"animation_url": "https://cdn.example/rose.svga",
|
||||
"metadata_json": "{}",
|
||||
"sort_order": 10,
|
||||
"created_at_ms": 1710000000000,
|
||||
"updated_at_ms": 1710000000000
|
||||
},
|
||||
"status": "active",
|
||||
"name": "Rose",
|
||||
"sort_order": 10,
|
||||
"presentation_json": "{}",
|
||||
"price_version": "v1",
|
||||
"gift_type_code": "normal",
|
||||
"charge_asset_type": "COIN",
|
||||
"coin_price": 100,
|
||||
"gift_point_amount": 100,
|
||||
"heat_value": 100,
|
||||
"effect_types": ["static", "svga"],
|
||||
"region_ids": [688],
|
||||
"created_at_ms": 1710000000000,
|
||||
"updated_at_ms": 1710000000000
|
||||
}
|
||||
],
|
||||
"quantity_presets": [1, 9, 99, 999]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `coin_balance` | 当前登录用户 COIN 可用余额快照。只用于打开面板时展示,最终余额仍以后续钱包事实为准。 |
|
||||
| `recipients` | 当前房间可收礼人。当前主流程只启用 `target_type=user` 的单个用户。 |
|
||||
| `tabs` | 礼物分类。`key=all` 是全量分类;其他 tab 通常对应 `gift_type_code`。 |
|
||||
| `gifts` | 当前房间国家维度可用的 active 礼物列表。 |
|
||||
| `quantity_presets` | 数量快捷档位,当前固定 `[1,9,99,999]`。 |
|
||||
|
||||
礼物字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `gift_id` | 送礼请求使用的礼物 ID。 |
|
||||
| `name` | 展示名称。 |
|
||||
| `gift_type_code` | 分类编码,例如 `normal`、`lucky`、`super_lucky`。 |
|
||||
| `charge_asset_type` | 当前收费资产;普通金币礼物为 `COIN`。 |
|
||||
| `coin_price` | 单个礼物金币价格。仅用于展示和按钮预估,扣费以后端为准。 |
|
||||
| `gift_point_amount` | 主播收到的礼物积分增量口径。 |
|
||||
| `heat_value` | 房间热度增量口径。 |
|
||||
| `price_version` | 后端价格版本。App 不需要上传。 |
|
||||
| `presentation_json` | 展示扩展 JSON,例如幸运礼物奖池 ID、角标、动效配置;解析失败时按空对象处理。 |
|
||||
| `effect_types` | 可用动效类型。App 根据本地能力选择图片、SVGA、Lottie 或兜底静态图。 |
|
||||
| `resource.asset_url` | 礼物图标。 |
|
||||
| `resource.preview_url` | 预览图,可为空。 |
|
||||
| `resource.animation_url` | 礼物动效资源,可为空。 |
|
||||
| `region_ids` | 后端配置的可用区域;面板接口已经按房间区域过滤,App 不再二次过滤。 |
|
||||
|
||||
## Tab 渲染规则
|
||||
|
||||
- `tabs` 按 `order` 升序展示。
|
||||
- `key=all` 展示全部 `gifts`。
|
||||
- 非 all tab 用 `tab.gift_type_code` 或 `tab.key` 过滤 `gift.gift_type_code`。
|
||||
- 礼物网格按 `gift.sort_order` 升序展示;相同排序保持接口返回顺序。
|
||||
- `coin_price * selected_count > coin_balance` 时发送按钮置灰或引导充值,但最终仍以送礼接口错误为准。
|
||||
- `resource.animation_url` 只在发送成功或收到 IM 后播放,不要在列表滚动中自动播放重动效。
|
||||
|
||||
## 送礼
|
||||
|
||||
```http
|
||||
POST /api/v1/rooms/gift/send
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"room_id": "lalu_room_001",
|
||||
"command_id": "gift_1779259000000_10001_rose_01",
|
||||
"target_type": "user",
|
||||
"target_user_ids": [10002],
|
||||
"gift_id": "rose",
|
||||
"gift_count": 9
|
||||
}
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `room_id` | string | 是 | 当前房间 ID。 |
|
||||
| `command_id` | string | 是 | 本次送礼动作幂等键。同一次失败重试必须复用。 |
|
||||
| `target_type` | string | 否 | 当前传 `user`;为空后端按 `user`。 |
|
||||
| `target_user_ids` | int64[] | 是 | 当前只能传 1 个收礼用户。 |
|
||||
| `target_user_id` | int64 | 否 | 旧单目标兼容字段;新代码优先用 `target_user_ids`。 |
|
||||
| `gift_id` | string | 是 | 礼物 ID。 |
|
||||
| `gift_count` | int32 | 是 | 礼物数量,必须大于 0。 |
|
||||
| `pool_id` | string | 否 | 幸运礼物奖池 ID;普通礼物不传。 |
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_abc",
|
||||
"data": {
|
||||
"result": {
|
||||
"applied": true,
|
||||
"room_version": 14,
|
||||
"server_time_ms": 1779259000000
|
||||
},
|
||||
"billing_receipt_id": "br_abc",
|
||||
"room_heat": 123456,
|
||||
"gift_rank": [
|
||||
{
|
||||
"user_id": "10001",
|
||||
"score": 900,
|
||||
"gift_value": 900,
|
||||
"updated_at_ms": 1779259000000
|
||||
}
|
||||
],
|
||||
"room": {},
|
||||
"treasure": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
响应字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `result.applied` | 本次命令是否实际落地。重复提交同一个 `command_id` 时可能返回 `false`,App 不要重复播放发送者本地扣费动画。 |
|
||||
| `result.room_version` | 房间版本,可用于丢弃旧房间快照。 |
|
||||
| `result.server_time_ms` | 服务端时间。 |
|
||||
| `billing_receipt_id` | 钱包扣费回执;非空表示扣费和房间表现已提交。 |
|
||||
| `room_heat` | 最新房间热度。 |
|
||||
| `gift_rank` | 最新房间礼物榜。 |
|
||||
| `room` | 房间快照,字段较大;App 可按已有房间快照解析逻辑局部更新。 |
|
||||
| `treasure` | 房间宝箱状态;宝箱 UI 以该字段或宝箱 IM 事件更新。 |
|
||||
|
||||
发送后处理:
|
||||
|
||||
- 成功后立即关闭 loading,保留礼物面板或按产品交互关闭。
|
||||
- 用 `room_heat` 和 `gift_rank` 更新房间顶部和贡献榜。
|
||||
- 播放当前用户送礼动效时按 `command_id` 去重,避免 HTTP 响应和 IM 各播放一次。
|
||||
- 不要本地直接写最终余额;等待 `WalletBalanceChanged` 私有 IM 或主动刷新余额。
|
||||
|
||||
## 房间 IM 同步
|
||||
|
||||
送礼成功后,房间群会收到腾讯云 IM 自定义消息:
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| `MsgContent.Desc` | `room_gift_sent` |
|
||||
| `Data.event_type` | `room_gift_sent` |
|
||||
|
||||
当前消息核心字段来自 room outbox:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "room_gift_sent",
|
||||
"event_id": "room_event_xxx",
|
||||
"room_id": "lalu_room_001",
|
||||
"room_version": 14,
|
||||
"occurred_at_ms": 1779259000000,
|
||||
"actor_user_id": "10001",
|
||||
"target_user_id": "10002",
|
||||
"gift_value": 900,
|
||||
"attributes": {
|
||||
"gift_id": "rose",
|
||||
"gift_count": "9",
|
||||
"billing_receipt_id": "br_abc"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
处理规则:
|
||||
|
||||
- 按 `event_id` 去重。
|
||||
- `event_type != room_gift_sent` 时交给其他房间消息处理器。
|
||||
- `actor_user_id` 是送礼人,`target_user_id` 是收礼人。
|
||||
- `attributes.gift_id` 对应面板里的礼物 ID;本地找不到配置时展示兜底礼物图标。
|
||||
- `attributes.gift_count` 当前是字符串,Flutter 解析时需要 `int.tryParse`。
|
||||
- `gift_value` 是本次房间价值/热度口径,不等同于客户端自己算的 `coin_price * count`。
|
||||
- 自己发送的礼物如果已经由 HTTP 成功响应播放过,收到同一 `command_id` 或短时间同一礼物 IM 时要做播放去重。
|
||||
|
||||
## 错误处理
|
||||
|
||||
外层 envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "INVALID_ARGUMENT",
|
||||
"message": "invalid argument",
|
||||
"request_id": "req_abc",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
处理规则:
|
||||
|
||||
| 场景 | 处理 |
|
||||
| --- | --- |
|
||||
| `UNAUTHENTICATED` | 重新登录或刷新 token。 |
|
||||
| `INVALID_ARGUMENT` | 参数错误;记录 `request_id`,检查 `room_id`、`gift_id`、数量和收礼人。 |
|
||||
| `NOT_FOUND` | 房间或礼物不存在;刷新房间和礼物面板。 |
|
||||
| `FAILED_PRECONDITION` | 用户不在房间、房间不可送礼或状态不允许;刷新房间详情。 |
|
||||
| `INSUFFICIENT_BALANCE` | 金币不足;跳转充值或刷新余额。 |
|
||||
| `CONFLICT` | 幂等冲突或命令状态冲突;复用原 `command_id` 查询/重试,不生成新命令导致重复送礼。 |
|
||||
| `UPSTREAM_ERROR` / 5xx | 保留原 `command_id` 允许用户重试。 |
|
||||
|
||||
## Flutter 数据模型示例
|
||||
|
||||
```dart
|
||||
class GiftPanel {
|
||||
GiftPanel({
|
||||
required this.coinBalance,
|
||||
required this.recipients,
|
||||
required this.tabs,
|
||||
required this.gifts,
|
||||
required this.quantityPresets,
|
||||
});
|
||||
|
||||
final int coinBalance;
|
||||
final List<GiftRecipient> recipients;
|
||||
final List<GiftTab> tabs;
|
||||
final List<GiftItem> gifts;
|
||||
final List<int> quantityPresets;
|
||||
|
||||
factory GiftPanel.fromJson(Map<String, dynamic> json) {
|
||||
return GiftPanel(
|
||||
coinBalance: (json['coin_balance'] as num?)?.toInt() ?? 0,
|
||||
recipients: (json['recipients'] as List<dynamic>? ?? const [])
|
||||
.map((item) => GiftRecipient.fromJson(item as Map<String, dynamic>))
|
||||
.toList(),
|
||||
tabs: (json['tabs'] as List<dynamic>? ?? const [])
|
||||
.map((item) => GiftTab.fromJson(item as Map<String, dynamic>))
|
||||
.toList(),
|
||||
gifts: (json['gifts'] as List<dynamic>? ?? const [])
|
||||
.map((item) => GiftItem.fromJson(item as Map<String, dynamic>))
|
||||
.toList(),
|
||||
quantityPresets: (json['quantity_presets'] as List<dynamic>? ?? const [])
|
||||
.map((item) => (item as num).toInt())
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GiftRecipient {
|
||||
GiftRecipient({
|
||||
required this.targetType,
|
||||
required this.userId,
|
||||
required this.seatNo,
|
||||
required this.label,
|
||||
});
|
||||
|
||||
final String targetType;
|
||||
final String userId;
|
||||
final int seatNo;
|
||||
final String label;
|
||||
|
||||
factory GiftRecipient.fromJson(Map<String, dynamic> json) {
|
||||
return GiftRecipient(
|
||||
targetType: json['target_type'] as String? ?? '',
|
||||
userId: json['user_id'] as String? ?? '',
|
||||
seatNo: (json['seat_no'] as num?)?.toInt() ?? 0,
|
||||
label: json['label'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GiftTab {
|
||||
GiftTab({
|
||||
required this.key,
|
||||
required this.giftTypeCode,
|
||||
required this.label,
|
||||
required this.order,
|
||||
});
|
||||
|
||||
final String key;
|
||||
final String giftTypeCode;
|
||||
final String label;
|
||||
final int order;
|
||||
|
||||
factory GiftTab.fromJson(Map<String, dynamic> json) {
|
||||
return GiftTab(
|
||||
key: json['key'] as String? ?? '',
|
||||
giftTypeCode: json['gift_type_code'] as String? ?? '',
|
||||
label: json['label'] as String? ?? '',
|
||||
order: (json['order'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GiftItem {
|
||||
GiftItem({
|
||||
required this.giftId,
|
||||
required this.name,
|
||||
required this.giftTypeCode,
|
||||
required this.coinPrice,
|
||||
required this.assetUrl,
|
||||
required this.animationUrl,
|
||||
required this.presentationJson,
|
||||
});
|
||||
|
||||
final String giftId;
|
||||
final String name;
|
||||
final String giftTypeCode;
|
||||
final int coinPrice;
|
||||
final String assetUrl;
|
||||
final String animationUrl;
|
||||
final String presentationJson;
|
||||
|
||||
factory GiftItem.fromJson(Map<String, dynamic> json) {
|
||||
final resource = json['resource'] as Map<String, dynamic>? ?? const {};
|
||||
return GiftItem(
|
||||
giftId: json['gift_id'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
giftTypeCode: json['gift_type_code'] as String? ?? '',
|
||||
coinPrice: (json['coin_price'] as num?)?.toInt() ?? 0,
|
||||
assetUrl: resource['asset_url'] as String? ?? '',
|
||||
animationUrl: resource['animation_url'] as String? ?? '',
|
||||
presentationJson: json['presentation_json'] as String? ?? '{}',
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Flutter 调用示例
|
||||
|
||||
```dart
|
||||
Future<GiftPanel> fetchGiftPanel({
|
||||
required Dio dio,
|
||||
required String roomId,
|
||||
}) async {
|
||||
final response = await dio.get('/api/v1/rooms/$roomId/gift-panel');
|
||||
final body = response.data as Map<String, dynamic>;
|
||||
if (body['code'] != 'OK') {
|
||||
throw StateError('${body['code']}: ${body['message']}');
|
||||
}
|
||||
return GiftPanel.fromJson(body['data'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> sendGift({
|
||||
required Dio dio,
|
||||
required String roomId,
|
||||
required String commandId,
|
||||
required int targetUserId,
|
||||
required String giftId,
|
||||
required int giftCount,
|
||||
String? poolId,
|
||||
}) async {
|
||||
final response = await dio.post(
|
||||
'/api/v1/rooms/gift/send',
|
||||
data: {
|
||||
'room_id': roomId,
|
||||
'command_id': commandId,
|
||||
'target_type': 'user',
|
||||
'target_user_ids': [targetUserId],
|
||||
'gift_id': giftId,
|
||||
'gift_count': giftCount,
|
||||
if (poolId != null && poolId.isNotEmpty) 'pool_id': poolId,
|
||||
},
|
||||
);
|
||||
final body = response.data as Map<String, dynamic>;
|
||||
if (body['code'] != 'OK') {
|
||||
throw StateError('${body['code']}: ${body['message']}');
|
||||
}
|
||||
return body['data'] as Map<String, dynamic>;
|
||||
}
|
||||
```
|
||||
|
||||
## 客户端状态建议
|
||||
|
||||
- 礼物面板缓存以 `room_id` 为粒度;切房间必须重新拉取。
|
||||
- 房间 `visible_region_id` 变化或用户重新 JoinRoom 后重新拉取礼物面板。
|
||||
- 礼物图片可以缓存;`animation_url` 建议按需预加载当前 tab 前几项。
|
||||
- 发送按钮只防重复点击当前 `command_id`,不要全局锁死礼物面板。
|
||||
- HTTP 超时后不要自动生成新 `command_id`;让用户点击重试时复用原值。
|
||||
- 礼物墙和礼物 Tab 是两个不同接口:礼物 Tab 是可发送配置,礼物墙是当前用户收到过的礼物聚合。
|
||||
611
docs/flutter对接/语音房宝箱Flutter对接.md
Normal file
611
docs/flutter对接/语音房宝箱Flutter对接.md
Normal file
@ -0,0 +1,611 @@
|
||||
# 语音房宝箱 Flutter 对接
|
||||
|
||||
本文描述 Flutter App 对接语音房宝箱所需的 HTTP 接口、请求参数、返回值、错误处理和腾讯云 IM 自定义消息 key。宝箱进度、开箱和发奖事实由 `room-service` Room Cell 持有;客户端只展示服务端返回或 IM 推送的状态,不能本地计算能量、奖励或开箱结果。
|
||||
|
||||
## 基础约定
|
||||
|
||||
本地开发地址:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:13000
|
||||
```
|
||||
|
||||
业务接口统一响应 envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `code` | 成功固定 `OK`;失败为稳定错误码。 |
|
||||
| `message` | 短错误文案,不用于客户端分支判断。 |
|
||||
| `request_id` | 服务端链路追踪 ID;客户端日志要记录。 |
|
||||
| `data` | 成功业务数据;失败时通常不存在。 |
|
||||
|
||||
通用请求头:
|
||||
|
||||
| Header | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `Authorization` | 是 | `Bearer <access_token>`。 |
|
||||
| `X-App-Code` | 否 | App 编码,默认 `lalu`;登录态接口最终以 token 内 app_code 为准。 |
|
||||
| `Content-Type` | POST 必填 | `application/json`。 |
|
||||
|
||||
所有 `*_ms` 都是 Unix epoch milliseconds。业务切日按 UTC,不按用户本地时区。
|
||||
|
||||
## 客户端流程
|
||||
|
||||
1. 用户成功 `JoinRoom` 后调用 `GET /api/v1/rooms/{room_id}/treasure` 初始化 7 级物料和当前进度。
|
||||
2. 用户送礼调用 `POST /api/v1/rooms/gift/send`;成功响应里的 `data.treasure` 可立即更新当前宝箱状态。
|
||||
3. 房间内监听腾讯 IM 群 `TIMCustomElem.Ext=room_system_message`,处理宝箱进度、倒计时、开箱和发奖。
|
||||
4. App 全局或区域播报监听 `TIMCustomElem.Ext=im_broadcast` 且 `broadcast_type=room_treasure`。
|
||||
5. 任意 IM 丢失、字段解析失败、版本倒退或页面重进时,重新调用 `GET /api/v1/rooms/{room_id}/treasure` 纠正本地状态。
|
||||
|
||||
客户端必须用 `event_id` 去重,用 `room_version` 丢弃旧房间消息。
|
||||
|
||||
## 获取宝箱信息
|
||||
|
||||
```http
|
||||
GET /api/v1/rooms/{room_id}/treasure
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
```
|
||||
|
||||
路径参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `room_id` | string | 是 | 当前语音房 ID,例如 `lalu_534d076a-7cbd-4b23-a297-0c10a3c2fa72`。 |
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {
|
||||
"enabled": true,
|
||||
"levels": [
|
||||
{
|
||||
"level": 1,
|
||||
"energy_threshold": 1000,
|
||||
"cover_url": "https://cdn.example/chest/lv1/cover.png",
|
||||
"animation_url": "https://cdn.example/chest/lv1/idle.svga",
|
||||
"opening_animation_url": "https://cdn.example/chest/lv1/opening.svga",
|
||||
"opened_image_url": "https://cdn.example/chest/lv1/opened.png",
|
||||
"in_room_rewards": [
|
||||
{
|
||||
"reward_item_id": "lv1_in_room_1",
|
||||
"resource_group_id": 1001,
|
||||
"weight": 10000,
|
||||
"display_name": "Gold Pack",
|
||||
"icon_url": "https://cdn.example/reward/gold.png"
|
||||
}
|
||||
],
|
||||
"top1_rewards": [],
|
||||
"igniter_rewards": []
|
||||
}
|
||||
],
|
||||
"state": {
|
||||
"current_level": 1,
|
||||
"current_progress": 0,
|
||||
"energy_threshold": 1000,
|
||||
"status": "idle",
|
||||
"reset_at_ms": 1779321600000,
|
||||
"top1_user_id": "",
|
||||
"igniter_user_id": "",
|
||||
"box_id": "",
|
||||
"config_version": 3,
|
||||
"last_rewards": []
|
||||
},
|
||||
"broadcast_scope": "region",
|
||||
"open_delay_ms": 30000,
|
||||
"broadcast_delay_ms": 0,
|
||||
"reward_stack_policy": "allow_stack",
|
||||
"server_time_ms": 1779259000000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`data` 字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | bool | 宝箱总开关。`false` 时客户端隐藏或置灰宝箱入口。 |
|
||||
| `levels` | array | 固定 7 个等级的物料、阈值和奖励池。 |
|
||||
| `state` | object | 当前房间当前 UTC 日的宝箱状态。 |
|
||||
| `broadcast_scope` | string | `none`、`region`、`global`。 |
|
||||
| `open_delay_ms` | int64 | 满能量后到开箱的倒计时配置。 |
|
||||
| `broadcast_delay_ms` | int64 | 后台配置字段;当前房间事件会立即进入播报链路。 |
|
||||
| `reward_stack_policy` | string | `allow_stack` 或 `priority_only`。 |
|
||||
| `server_time_ms` | int64 | 服务端当前时间,倒计时要以它校准本地时钟偏差。 |
|
||||
|
||||
`levels[]` 字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `level` | int32 | 等级,1 到 7。 |
|
||||
| `energy_threshold` | int64 | 当前等级满能量阈值。 |
|
||||
| `cover_url` | string | 静态封面图。 |
|
||||
| `animation_url` | string | 未开箱/蓄能态动效。 |
|
||||
| `opening_animation_url` | string | 开箱动效。 |
|
||||
| `opened_image_url` | string | 开箱后展示图。 |
|
||||
| `in_room_rewards` | array | 开箱时仍在房用户的奖励候选展示。 |
|
||||
| `top1_rewards` | array | 倒计时开始时贡献第一用户奖励候选展示。 |
|
||||
| `igniter_rewards` | array | 点火人奖励候选展示。 |
|
||||
|
||||
奖励候选字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `reward_item_id` | string | 后台配置的奖励项 ID。 |
|
||||
| `resource_group_id` | int64 | 钱包资源组 ID。 |
|
||||
| `weight` | int64 | 抽奖权重,只用于展示或调试,不参与客户端抽奖。 |
|
||||
| `display_name` | string | 奖励展示名称。 |
|
||||
| `icon_url` | string | 奖励图标。 |
|
||||
|
||||
`state` 字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `current_level` | int32 | 当前等级,1 到 7。 |
|
||||
| `current_progress` | int64 | 当前等级已累计有效能量。 |
|
||||
| `energy_threshold` | int64 | 当前等级阈值。 |
|
||||
| `status` | string | 当前返回 `idle` 或 `countdown`。 |
|
||||
| `countdown_started_at_ms` | int64 | 倒计时开始时间;非倒计时可为空或 0。 |
|
||||
| `open_at_ms` | int64 | 预计开箱时间;`status=countdown` 时有效。 |
|
||||
| `opened_at_ms` | int64 | 最近一次开箱时间。 |
|
||||
| `reset_at_ms` | int64 | 下一个 UTC 0 点;到点后进度按新 UTC 日重置。 |
|
||||
| `top1_user_id` | string | 倒计时开始时锁定的贡献第一;GET 接口中按字符串返回。 |
|
||||
| `igniter_user_id` | string | 点火人;GET 接口中按字符串返回。 |
|
||||
| `box_id` | string | 当前倒计时宝箱或最近状态的单轮幂等 ID。 |
|
||||
| `config_version` | int64 | 当前状态关联的后台配置版本。 |
|
||||
| `last_rewards` | array | 最近一次开箱发奖结果;客户端可用于展示开箱结果兜底。 |
|
||||
|
||||
`last_rewards[]` 字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `reward_role` | string | `in_room`、`top1`、`igniter`。 |
|
||||
| `user_id` | string | 获奖用户 ID 字符串。 |
|
||||
| `reward_item_id` | string | 奖励项 ID。 |
|
||||
| `resource_group_id` | int64 | 资源组 ID。 |
|
||||
| `display_name` | string | 奖励展示名称。 |
|
||||
| `icon_url` | string | 奖励图标。 |
|
||||
| `grant_id` | string | 钱包发放记录 ID。 |
|
||||
| `status` | string | 当前成功发放为 `succeeded`。 |
|
||||
|
||||
展示规则:
|
||||
|
||||
- 进度条使用 `current_progress / energy_threshold`,并且最大显示 100%。
|
||||
- 倒计时使用 `open_at_ms - server_time_ms` 初始化,再用本地 timer 驱动。
|
||||
- 不要把送礼溢出能量展示到下一等级;服务端规则是溢出作废。
|
||||
- 如果 `status=countdown`,继续送礼不会改变宝箱进度、点火人、贡献第一或 `open_at_ms`。
|
||||
|
||||
## 送礼并更新宝箱
|
||||
|
||||
```http
|
||||
POST /api/v1/rooms/gift/send
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"room_id": "lalu_xxx",
|
||||
"command_id": "cmd_send_gift_lalu_xxx_12345678_1779259000000",
|
||||
"target_type": "user",
|
||||
"target_user_ids": [12345678],
|
||||
"gift_id": "rocket",
|
||||
"gift_count": 1
|
||||
}
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `room_id` | string | 是 | 当前房间 ID。 |
|
||||
| `command_id` | string | 是 | 本次送礼动作幂等键;同一次 HTTP 重试必须复用。 |
|
||||
| `target_type` | string | 否 | 当前只支持 `user`;为空按 `user` 处理。 |
|
||||
| `target_user_ids` | int64[] | 是 | 收礼用户列表;`target_type=user` 时必须且只能 1 个。 |
|
||||
| `target_user_id` | int64 | 否 | 旧客户端兼容字段;新客户端优先用 `target_user_ids`。 |
|
||||
| `gift_id` | string | 是 | 礼物 ID。 |
|
||||
| `gift_count` | int32 | 是 | 礼物数量,必须大于 0。 |
|
||||
|
||||
成功响应核心字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {
|
||||
"result": {
|
||||
"applied": true,
|
||||
"room_version": 7,
|
||||
"server_time_ms": 1779259502200
|
||||
},
|
||||
"billing_receipt_id": "bill_xxx",
|
||||
"room_heat": 500,
|
||||
"gift_rank": [
|
||||
{
|
||||
"user_id": 12345678,
|
||||
"score": 500,
|
||||
"gift_value": 500,
|
||||
"updated_at_ms": 1779259502104
|
||||
}
|
||||
],
|
||||
"room": {},
|
||||
"treasure": {
|
||||
"current_level": 2,
|
||||
"current_progress": 200,
|
||||
"energy_threshold": 200,
|
||||
"status": "countdown",
|
||||
"countdown_started_at_ms": 1779259502104,
|
||||
"open_at_ms": 1779259512104,
|
||||
"reset_at_ms": 1779321600000,
|
||||
"top1_user_id": 12345678,
|
||||
"igniter_user_id": 12345678,
|
||||
"box_id": "room_treasure_box_xxx",
|
||||
"config_version": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `data.result.applied` | bool | 命令是否产生新状态;同一 `command_id` 重试可能返回历史结果。 |
|
||||
| `data.result.room_version` | int64 | 房间版本,用于和 IM 消息去重、排序。 |
|
||||
| `data.billing_receipt_id` | string | 钱包扣费回执。 |
|
||||
| `data.room_heat` | int64 | 送礼后的房间热度。 |
|
||||
| `data.gift_rank` | array | 送礼后的本地贡献榜快照。 |
|
||||
| `data.room` | object | 房间快照。 |
|
||||
| `data.treasure` | object | 送礼后宝箱状态;送礼人可立即用它刷新 UI。 |
|
||||
|
||||
注意:`GET /treasure` 的用户 ID 字段按字符串返回,`POST /gift/send` 透出的 protobuf 状态里部分用户 ID 可能是数字。Flutter 侧建议用统一 helper 兼容 `String` 和 `num`。
|
||||
|
||||
```dart
|
||||
String readId(dynamic value) {
|
||||
if (value == null) return '';
|
||||
if (value is String) return value;
|
||||
if (value is num) return value.toInt().toString();
|
||||
return '';
|
||||
}
|
||||
```
|
||||
|
||||
幂等规则:
|
||||
|
||||
| 场景 | 处理 |
|
||||
| --- | --- |
|
||||
| HTTP 超时后重试同一次送礼 | 复用原 `command_id`。 |
|
||||
| 用户点了第二次送礼按钮 | 生成新的 `command_id`。 |
|
||||
| 同一个 `command_id` 换了礼物、数量或目标 | 服务端返回冲突错误。 |
|
||||
|
||||
## 错误处理
|
||||
|
||||
失败响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "PERMISSION_DENIED",
|
||||
"message": "permission denied",
|
||||
"request_id": "req_xxx"
|
||||
}
|
||||
```
|
||||
|
||||
通用错误:
|
||||
|
||||
| HTTP | `code` | 场景 | Flutter 处理 |
|
||||
| --- | --- | --- | --- |
|
||||
| 400 | `INVALID_JSON` | 请求体不是合法 JSON。 | 修正客户端请求,不重试。 |
|
||||
| 400 | `INVALID_ARGUMENT` | 缺字段、数量非法、room_id 格式非法、暂不支持的 `target_type`。 | 修正参数,不自动重试。 |
|
||||
| 401 | `UNAUTHORIZED` | token 缺失、过期或无效。 | 走登录刷新或重新登录。 |
|
||||
| 403 | `PROFILE_REQUIRED` | 用户资料未完成。 | 跳转资料补全流程。 |
|
||||
| 403 | `PERMISSION_DENIED` | 不在房间、被 ban、无权限读取或操作。 | 退出房间页或刷新房间状态。 |
|
||||
| 404 | `NOT_FOUND` | 房间不存在、送礼人不在房、收礼人不在房、礼物不可用。 | 刷新房间状态;礼物不可用时刷新礼物面板。 |
|
||||
| 409 | `ROOM_CLOSED` | 房间已关闭。 | 退出房间页并刷新列表。 |
|
||||
| 409 | `CONFLICT` | 状态或唯一约束冲突。 | 刷新后重试。 |
|
||||
| 409 | `IDEMPOTENCY_CONFLICT` | `command_id` 复用但请求内容不同。 | 新用户动作生成新 `command_id`;重试旧动作必须保持原请求体。 |
|
||||
| 409 | `INSUFFICIENT_BALANCE` | 钱包余额不足。 | 弹充值/余额不足提示。 |
|
||||
| 409 | `LEDGER_CONFLICT` | 钱包并发账本冲突。 | 可短延迟重试一次,仍失败则刷新余额。 |
|
||||
| 502 | `UPSTREAM_ERROR` | room/wallet 等依赖暂不可用。 | 提示稍后重试,可保留原 `command_id` 重试同一次送礼。 |
|
||||
| 500 | `INTERNAL_ERROR` | 服务端内部错误。 | 提示失败并记录 `request_id`。 |
|
||||
|
||||
宝箱相关额外规则:
|
||||
|
||||
- 获取宝箱信息要求当前用户仍在房间业务 presence 内;离房后调用会返回 `PERMISSION_DENIED` 或 `NOT_FOUND`。
|
||||
- 宝箱关闭时,`GET /treasure` 仍可能返回 `enabled=false` 和 7 级配置;客户端不展示进度入口即可。
|
||||
- 发奖自动完成,没有 App 领取接口;客户端不要补发或重复调用钱包接口。
|
||||
|
||||
## 房间群 IM 消息
|
||||
|
||||
房间内宝箱进度、倒计时、开箱和发奖通过腾讯云 IM 房间群 `TIMCustomElem` 投递。
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| `MsgType` | `TIMCustomElem` |
|
||||
| `MsgContent.Ext` | `room_system_message` |
|
||||
| `MsgContent.Desc` | 等于 `Data.event_type` |
|
||||
| `MsgContent.Data` | JSON 字符串 |
|
||||
| `CloudCustomData` | 与 `MsgContent.Data` 相同的 JSON 字符串 |
|
||||
|
||||
`Data` 外层结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_xxx",
|
||||
"room_id": "lalu_xxx",
|
||||
"event_type": "room_treasure_progress_changed",
|
||||
"actor_user_id": 12345678,
|
||||
"target_user_id": 0,
|
||||
"room_version": 8,
|
||||
"attributes": {
|
||||
"box_id": "room_treasure_box_xxx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
通用字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `event_id` | string | IM 去重键。 |
|
||||
| `room_id` | string | 房间 ID。 |
|
||||
| `event_type` | string | 事件类型,见下表。 |
|
||||
| `actor_user_id` | int64 | 触发用户;倒计时/开箱通常为点火人。 |
|
||||
| `target_user_id` | int64 | 目标用户;倒计时/开箱通常为 top1。 |
|
||||
| `room_version` | int64 | 房间版本,本地版本更高时丢弃旧消息。 |
|
||||
| `attributes` | map<string,string> | 业务字段;值全部按字符串传输,Flutter 侧按需转 int/bool/JSON。 |
|
||||
|
||||
宝箱房间群事件:
|
||||
|
||||
| `event_type` | `Desc` | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `room_treasure_progress_changed` | `room_treasure_progress_changed` | 有效宝箱能量增加。 |
|
||||
| `room_treasure_countdown_started` | `room_treasure_countdown_started` | 当前等级满能量,进入倒计时。 |
|
||||
| `room_treasure_opened` | `room_treasure_opened` | 宝箱已打开,包含公开奖励摘要。 |
|
||||
| `room_treasure_reward_granted` | `room_treasure_reward_granted` | 发奖结果事件,包含奖励列表。 |
|
||||
|
||||
### room_treasure_progress_changed
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_progress",
|
||||
"room_id": "lalu_xxx",
|
||||
"event_type": "room_treasure_progress_changed",
|
||||
"actor_user_id": 12345678,
|
||||
"room_version": 6,
|
||||
"attributes": {
|
||||
"box_id": "room_treasure_box_xxx",
|
||||
"level": "2",
|
||||
"added_energy": "300",
|
||||
"effective_added_energy": "200",
|
||||
"overflow_energy": "100",
|
||||
"current_progress": "200",
|
||||
"energy_threshold": "200",
|
||||
"status": "countdown",
|
||||
"reset_at_ms": "1779321600000",
|
||||
"gift_id": "rocket",
|
||||
"gift_count": "3",
|
||||
"command_id": "cmd_send_gift_xxx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
处理:
|
||||
|
||||
- 更新当前等级进度为 `current_progress`。
|
||||
- `effective_added_energy` 是本次实际计入宝箱的能量。
|
||||
- `overflow_energy` 是作废能量,不能加到下一等级。
|
||||
- 如果 `status=countdown`,立即切换倒计时态,等待 `room_treasure_countdown_started` 补齐 `open_at_ms`;如该消息丢失,调用 GET 兜底。
|
||||
|
||||
### room_treasure_countdown_started
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_countdown",
|
||||
"room_id": "lalu_xxx",
|
||||
"event_type": "room_treasure_countdown_started",
|
||||
"actor_user_id": 12345678,
|
||||
"target_user_id": 12345678,
|
||||
"room_version": 6,
|
||||
"attributes": {
|
||||
"box_id": "room_treasure_box_xxx",
|
||||
"level": "2",
|
||||
"current_progress": "200",
|
||||
"energy_threshold": "200",
|
||||
"countdown_start_ms": "1779259502104",
|
||||
"open_at_ms": "1779259512104",
|
||||
"broadcast_scope": "region",
|
||||
"visible_region_id": "1001",
|
||||
"top1_user_id": "12345678",
|
||||
"igniter_user_id": "12345678",
|
||||
"command_id": "cmd_send_gift_xxx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
处理:
|
||||
|
||||
- 按 `open_at_ms` 展示开箱倒计时。
|
||||
- `top1_user_id` 和 `igniter_user_id` 在此刻锁定;后续离房或下线也不影响这两类奖励发放。
|
||||
- 倒计时期间再收到普通 `room_gift_sent`,只更新礼物表现和热度,不改变宝箱倒计时。
|
||||
|
||||
注意:房间群 IM 字段名是 `countdown_start_ms`,HTTP state 字段名是 `countdown_started_at_ms`。
|
||||
|
||||
### room_treasure_opened
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_opened",
|
||||
"room_id": "lalu_xxx",
|
||||
"event_type": "room_treasure_opened",
|
||||
"actor_user_id": 12345678,
|
||||
"target_user_id": 12345678,
|
||||
"room_version": 8,
|
||||
"attributes": {
|
||||
"box_id": "room_treasure_box_xxx",
|
||||
"level": "2",
|
||||
"next_level": "3",
|
||||
"opened_at_ms": "1779259512668",
|
||||
"reset_at_ms": "1779321600000",
|
||||
"top1_user_id": "12345678",
|
||||
"igniter_user_id": "12345678",
|
||||
"rewards_json": "[{\"reward_role\":\"in_room\",\"user_id\":12345678,\"reward_item_id\":\"lv2_in_room_1\",\"resource_group_id\":1001,\"display_name\":\"Gold Pack\",\"icon_url\":\"https://cdn.example/reward/gold.png\",\"grant_id\":\"rgr_xxx\",\"status\":\"succeeded\"}]"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
处理:
|
||||
|
||||
- 播放当前等级 `opening_animation_url`,再展示 `opened_image_url` 或奖励弹窗。
|
||||
- 解析 `rewards_json` 得到奖励列表;如果解析失败,调用 GET 获取 `state.last_rewards`。
|
||||
- 本地状态进入 `next_level`,进度归 0,状态归 `idle`。
|
||||
|
||||
### room_treasure_reward_granted
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_reward",
|
||||
"room_id": "lalu_xxx",
|
||||
"event_type": "room_treasure_reward_granted",
|
||||
"room_version": 8,
|
||||
"attributes": {
|
||||
"box_id": "room_treasure_box_xxx",
|
||||
"level": "2",
|
||||
"rewards_json": "[{\"reward_role\":\"top1\",\"user_id\":12345678,\"reward_item_id\":\"lv2_top1_1\",\"resource_group_id\":1002,\"display_name\":\"Top Reward\",\"icon_url\":\"https://cdn.example/reward/top.png\",\"grant_id\":\"rgr_xxx\",\"status\":\"succeeded\"}]"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
处理:
|
||||
|
||||
- 解析 `rewards_json`。
|
||||
- 当前登录用户在奖励列表中时,展示“获得奖励”提示。
|
||||
- 同一用户可能同时命中 `in_room`、`top1`、`igniter`,是否叠加由 `reward_stack_policy` 决定;客户端按服务端发下来的列表展示。
|
||||
|
||||
当前没有单独的 C2C 宝箱中奖私信,也没有 App 消息 Tab 宝箱入箱消息。后续如新增私有通知,应以新的 C2C `Ext` 文档为准,不复用房间群消息假装私信。
|
||||
|
||||
## 区域/全局播报 IM
|
||||
|
||||
满能量进入倒计时后,如果后台配置允许播报,`activity-service` 会向区域或全局播报群发送 `TIMCustomElem`。
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| `MsgType` | `TIMCustomElem` |
|
||||
| `MsgContent.Ext` | `im_broadcast` |
|
||||
| `MsgContent.Desc` | `room_treasure` |
|
||||
| `MsgContent.Data` | JSON 字符串 |
|
||||
| `CloudCustomData` | 与 `MsgContent.Data` 相同的 JSON 字符串 |
|
||||
|
||||
`Data` 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "room_treasure_broadcast:lalu_xxx:room_treasure_box_xxx",
|
||||
"broadcast_type": "room_treasure",
|
||||
"scope": "region",
|
||||
"app_code": "lalu",
|
||||
"region_id": 1001,
|
||||
"room_id": "lalu_xxx",
|
||||
"box_id": "room_treasure_box_xxx",
|
||||
"level": 2,
|
||||
"open_at_ms": 1779259512104,
|
||||
"top1_user_id": 12345678,
|
||||
"igniter_user_id": 12345678,
|
||||
"sent_at_ms": 1779259502200,
|
||||
"room_version": 6,
|
||||
"source_event_id": "evt_countdown",
|
||||
"current_progress": 200,
|
||||
"energy_threshold": 200,
|
||||
"countdown_started_at_ms": 1779259502104,
|
||||
"action": {
|
||||
"type": "enter_room",
|
||||
"room_id": "lalu_xxx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
处理:
|
||||
|
||||
- `broadcast_type=room_treasure` 时展示宝箱倒计时播报。
|
||||
- 点击播报按 `action.type=enter_room` 跳转或进房。
|
||||
- `scope=region` 表示区域播报群;`scope=global` 表示全局播报群。
|
||||
- 播报只负责引导和展示,不代替房间内宝箱状态;进房后仍调用 GET 初始化。
|
||||
|
||||
## 普通送礼房间 IM
|
||||
|
||||
送礼成功还会产生普通房间群消息:
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| `MsgContent.Ext` | `room_system_message` |
|
||||
| `MsgContent.Desc` | `room_gift_sent` |
|
||||
| `Data.event_type` | `room_gift_sent` |
|
||||
|
||||
宝箱 UI 不应该只靠 `room_gift_sent` 推算能量;能量以 `room_treasure_progress_changed`、`room_treasure_countdown_started`、`POST /gift/send.data.treasure` 或 GET 结果为准。
|
||||
|
||||
## Flutter 解析建议
|
||||
|
||||
IM `attributes` 的值都是字符串,建议统一封装解析:
|
||||
|
||||
```dart
|
||||
int readInt(dynamic value, [int fallback = 0]) {
|
||||
if (value == null) return fallback;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value) ?? fallback;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
String readString(dynamic value) {
|
||||
if (value == null) return '';
|
||||
if (value is String) return value;
|
||||
if (value is num) return value.toInt().toString();
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> readRewardsJson(dynamic value) {
|
||||
final raw = readString(value);
|
||||
if (raw.isEmpty) return const [];
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) return const [];
|
||||
return decoded.whereType<Map>().map((item) {
|
||||
return item.map((key, val) => MapEntry(key.toString(), val));
|
||||
}).toList();
|
||||
}
|
||||
```
|
||||
|
||||
推荐本地状态更新策略:
|
||||
|
||||
| 输入 | 处理 |
|
||||
| --- | --- |
|
||||
| `GET /treasure` | 覆盖本地完整宝箱状态和 7 级物料。 |
|
||||
| `POST /gift/send.data.treasure` | 送礼人立即刷新当前状态。 |
|
||||
| `room_treasure_progress_changed` | 同房间、版本不旧时更新进度。 |
|
||||
| `room_treasure_countdown_started` | 切换倒计时,并锁定 top1/点火人展示。 |
|
||||
| `room_treasure_opened` | 播放开箱动效,状态切到下一等级 idle。 |
|
||||
| `room_treasure_reward_granted` | 展示当前用户中奖提示;按 `event_id` 去重。 |
|
||||
| `im_broadcast + room_treasure` | 展示区域/全局播报入口,不直接改当前房间状态。 |
|
||||
|
||||
如果客户端发现本地状态和服务端事件无法合并,例如当前 `box_id` 不一致、`level` 倒退、`room_version` 缺失,直接调用 `GET /api/v1/rooms/{room_id}/treasure` 重新同步。
|
||||
@ -155,6 +155,33 @@ MQ 和 MySQL 扫描是同一事实的两种入口:
|
||||
|
||||
这套规则同时覆盖普通送礼扣费、幸运礼物中奖返奖、VIP 购买扣费、资源组发金币、币商转币等余额变化。
|
||||
|
||||
`VipActivated` 用于通知客户端用户已获得或续期 VIP,C2C 自定义消息 `Ext=vip_notice`:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "VipActivated",
|
||||
"event_id": "wev_xxx",
|
||||
"app_code": "lalu",
|
||||
"transaction_id": "wtx_xxx",
|
||||
"command_id": "vip_purchase_10001_6_1790000000000",
|
||||
"user_id": "10001",
|
||||
"asset_type": "VIP",
|
||||
"source": "vip_purchase",
|
||||
"action": "upgrade",
|
||||
"previous_level": 3,
|
||||
"level": 6,
|
||||
"vip_name": "VIP6",
|
||||
"started_at_ms": 1790000000000,
|
||||
"expires_at_ms": 1792592000000,
|
||||
"reward_resource_group_id": 101,
|
||||
"resource_grant_id": "rgr_xxx",
|
||||
"reward_items": [],
|
||||
"source_created_at_ms": 1790000000000
|
||||
}
|
||||
```
|
||||
|
||||
客户端按 `event_id` 去重,收到后刷新全局 VIP 状态;`source` 取值为 `vip_purchase`、`admin_grant` 或 `activity_grant`。
|
||||
|
||||
## 多实例和重启语义
|
||||
|
||||
`notice_delivery_events` 是 worker 的投递位点表:
|
||||
|
||||
@ -1898,6 +1898,26 @@ paths:
|
||||
$ref: "#/responses/BadRequest"
|
||||
"502":
|
||||
$ref: "#/responses/UpstreamError"
|
||||
/api/v1/gift-tabs:
|
||||
get:
|
||||
tags:
|
||||
- resources
|
||||
summary: App 礼物 Tab 配置列表
|
||||
operationId: listGiftTabs
|
||||
parameters:
|
||||
- name: region_id
|
||||
in: query
|
||||
required: false
|
||||
type: integer
|
||||
format: int64
|
||||
description: 不传时预加载全部 active 礼物;传入时只返回 GLOBAL 和该区域可用礼物。
|
||||
responses:
|
||||
"200":
|
||||
description: 返回后台启用的礼物 Tab 配置和当前 active 礼物;不按礼物数量过滤 Tab,即使 Tab 下当前没有礼物也返回 gifts 空数组。
|
||||
schema:
|
||||
$ref: "#/definitions/GiftTabListEnvelope"
|
||||
"502":
|
||||
$ref: "#/responses/UpstreamError"
|
||||
/api/v1/users/me/resources:
|
||||
get:
|
||||
tags:
|
||||
@ -2567,6 +2587,11 @@ definitions:
|
||||
updated_at_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
gifts:
|
||||
type: array
|
||||
description: 当前 Tab 下的 active 礼物列表;没有礼物时返回空数组。
|
||||
items:
|
||||
$ref: "#/definitions/GiftConfigData"
|
||||
DeletePushTokenData:
|
||||
type: object
|
||||
required:
|
||||
@ -2692,7 +2717,10 @@ definitions:
|
||||
description: 用户是否拥有 active host profile。
|
||||
is_agent:
|
||||
type: boolean
|
||||
description: 用户是否拥有 active agency owner 身份。
|
||||
description: 用户是否拥有 Agency 入口身份;active Agency owner 或 Agency 来源主播都可能为 true。
|
||||
is_manager:
|
||||
type: boolean
|
||||
description: 用户是否拥有经理中心身份;首版等价于 active Agency owner。
|
||||
is_bd:
|
||||
type: boolean
|
||||
description: 用户是否拥有 active BD 或 BD Leader 身份。
|
||||
@ -2707,6 +2735,7 @@ definitions:
|
||||
required:
|
||||
- is_host
|
||||
- is_agency
|
||||
- is_manager
|
||||
- is_bd
|
||||
- is_bd_leader
|
||||
- is_coin_seller
|
||||
@ -2716,6 +2745,8 @@ definitions:
|
||||
type: boolean
|
||||
is_agency:
|
||||
type: boolean
|
||||
is_manager:
|
||||
type: boolean
|
||||
is_bd:
|
||||
type: boolean
|
||||
is_bd_leader:
|
||||
@ -3145,6 +3176,7 @@ definitions:
|
||||
type: string
|
||||
enum:
|
||||
- host-center
|
||||
- manager-center
|
||||
- bd-center
|
||||
- bd-leader-center
|
||||
- agency-center
|
||||
@ -3622,6 +3654,49 @@ definitions:
|
||||
page_size:
|
||||
type: integer
|
||||
format: int32
|
||||
GiftTabData:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: Tab 稳定 key,等同于礼物类型编码。
|
||||
gift_type_code:
|
||||
type: string
|
||||
description: 礼物类型编码,用于和 gifts[].gift_type_code 对齐。
|
||||
name:
|
||||
type: string
|
||||
description: 后台配置的中文显示名。
|
||||
label:
|
||||
type: string
|
||||
description: App Tab 展示名,来源于后台 tab_key。
|
||||
tab_key:
|
||||
type: string
|
||||
description: 后台配置的 Tab Name。
|
||||
status:
|
||||
type: string
|
||||
order:
|
||||
type: integer
|
||||
format: int32
|
||||
description: App 排序值,等同于 sort_order。
|
||||
sort_order:
|
||||
type: integer
|
||||
format: int32
|
||||
created_at_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
updated_at_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
GiftTabListData:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/definitions/GiftTabData"
|
||||
total:
|
||||
type: integer
|
||||
format: int32
|
||||
UserResourceData:
|
||||
type: object
|
||||
properties:
|
||||
@ -4838,6 +4913,13 @@ definitions:
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/definitions/GiftListData"
|
||||
GiftTabListEnvelope:
|
||||
allOf:
|
||||
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/definitions/GiftTabListData"
|
||||
UserResourceListEnvelope:
|
||||
allOf:
|
||||
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||
|
||||
@ -10,8 +10,8 @@
|
||||
| --- | --- | --- |
|
||||
| `LuckyGiftService.CheckLuckyGift` | done | 房间送礼前检查礼物是否启用,并返回当前体验池 |
|
||||
| `LuckyGiftService.ExecuteLuckyGiftDraw` | done | 扣费成功后执行抽奖决策,写抽奖事实和 outbox |
|
||||
| `AdminLuckyGiftService.GetLuckyGiftConfig` | done | 后台读取全局幸运礼物规则;未配置时返回默认草稿 |
|
||||
| `AdminLuckyGiftService.UpsertLuckyGiftConfig` | done | 后台发布全局幸运礼物规则版本 |
|
||||
| `AdminLuckyGiftService.GetLuckyGiftConfig` | done | 后台按 `pool_id` 读取幸运礼物奖池规则;未配置时返回默认草稿 |
|
||||
| `AdminLuckyGiftService.UpsertLuckyGiftConfig` | done | 后台按 `pool_id` 发布独立幸运礼物奖池规则版本 |
|
||||
| `AdminLuckyGiftService.ListLuckyGiftDraws` | done | 后台审计抽奖记录 |
|
||||
| 三层基础奖池 | done | platform / room / gift |
|
||||
| RTP 双窗口 | done | global + gift |
|
||||
@ -25,23 +25,23 @@
|
||||
|
||||
```text
|
||||
room-service SendGift
|
||||
1. CheckLuckyGift(meta, user_id, room_id, gift_id)
|
||||
1. CheckLuckyGift(meta, user_id, room_id, gift_id, pool_id)
|
||||
2. wallet-service 扣费成功
|
||||
3. ExecuteLuckyGiftDraw(command_id, user_id, device_id, room_id, anchor_id, gift_id, coin_spent, paid_at_ms)
|
||||
3. ExecuteLuckyGiftDraw(command_id, user_id, device_id, room_id, anchor_id, gift_id, pool_id, coin_spent, paid_at_ms)
|
||||
4. activity-service 本地事务写 draw fact + outbox
|
||||
5. 事务后由钱包/房间表现链路处理 reward_status=pending 的结果
|
||||
```
|
||||
|
||||
`command_id` 是抽奖幂等键。重复 `ExecuteLuckyGiftDraw` 会返回同一条 `lucky_draw_records`,不会重复消耗 RTP、奖池或预算。
|
||||
|
||||
后台只维护一份全局幸运礼物规则,实际抽奖仍记录用户发送的真实 `gift_id`。后台不暴露单抽消耗;服务端保存固定内部换算单位,不同幸运礼物实际消耗不同,抽奖事务按实际 `coin_spent` 等比缩放基础奖档,并按实际 `coin_spent` 累计 RTP 目标和奖池入账。
|
||||
后台按 `pool_id` 维护多份互相独立的幸运礼物奖池规则,实际抽奖仍记录用户发送的真实 `gift_id`。后台不暴露单抽消耗;服务端保存固定内部换算单位,不同幸运礼物实际消耗不同,抽奖事务按实际 `coin_spent` 等比缩放基础奖档,并按实际 `coin_spent` 累计 RTP 目标和奖池入账。同一个 `gift_id` 可以请求不同 `pool_id`,不同 `pool_id` 的 RTP 窗口、基础奖池、房间气氛池、活动预算、用户阶段和风控计数互不共享。
|
||||
|
||||
## Transaction Boundary
|
||||
|
||||
`ExecuteLuckyGiftDraw` 使用一个 MySQL 本地事务,事务内必须完成:
|
||||
|
||||
1. 按 `command_id` 锁定幂等记录。
|
||||
2. 锁定 `lucky_gift_rules` 全局规则。
|
||||
2. 锁定 `lucky_gift_rules` 中 `pool_id` 对应规则。
|
||||
3. 锁定或创建用户体验状态 `lucky_user_states`。
|
||||
4. 锁定或创建全站 RTP 窗口、幸运礼物整体子窗口。
|
||||
5. 锁定或创建平台、房间、幸运礼物整体三层基础奖池。
|
||||
@ -82,16 +82,17 @@ effective_reward_coins = base_reward_coins
|
||||
|
||||
| Method | Path | Permission | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET` | `/api/v1/admin/activity/lucky-gifts/config` | `lucky-gift:view` | 读取全局幸运礼物规则;未配置返回默认草稿 |
|
||||
| `PUT` | `/api/v1/admin/activity/lucky-gifts/config` | `lucky-gift:update` | 发布新的全局幸运礼物规则版本 |
|
||||
| `GET` | `/api/v1/admin/activity/lucky-gifts/config?pool_id=pool_95` | `lucky-gift:view` | 读取指定奖池规则;未配置返回默认草稿 |
|
||||
| `PUT` | `/api/v1/admin/activity/lucky-gifts/config?pool_id=pool_95` | `lucky-gift:update` | 发布指定奖池的新规则版本 |
|
||||
| `GET` | `/api/v1/admin/activity/lucky-gifts/draws` | `lucky-gift:view` | 查询抽奖审计记录 |
|
||||
|
||||
| Group | Fields | Meaning |
|
||||
| --- | --- | --- |
|
||||
| RTP 成本 | `target_rtp_ppm`, `pool_rate_ppm`, `global_window_draws`, `gift_window_draws` | 控制基础 RTP 和收敛窗口 |
|
||||
| 三层池 | `platform_pool_weight_ppm`, `room_pool_weight_ppm`, `gift_pool_weight_ppm`, `initial_*_pool`, `*_reserve` | 控制平台、房间、礼物各自承担的基础返奖能力 |
|
||||
| 体验池 | `novice_draw_limit`, `intermediate_draw_limit`, `tiers` | 控制新手/中级/高级奖档结构 |
|
||||
| 高倍 | `high_multiplier`, `high_water_pool_multiple`, `large_tier_enabled`, tier `high_water_only` | 控制 100x/500x 等高倍是否可进入候选 |
|
||||
| 倍率配置 | `multiplier_ppms`, generated `tiers` | 运营只配置用户可能命中的倍率;服务按 RTP 窗口、水位和风控自动生成实时概率 |
|
||||
| 体验池 | `novice_draw_limit`, `intermediate_draw_limit` | 控制新手/中级/高级阶段,不直接决定成本 |
|
||||
| 高倍 | `high_multiplier`, `high_water_pool_multiple`, `large_tier_enabled` | 控制 100x/500x 等高倍倍率是否可进入候选 |
|
||||
| 风控 | `max_single_payout`, `user_hourly_payout_cap`, `user_daily_payout_cap`, `device_daily_payout_cap`, `room_hourly_payout_cap`, `anchor_daily_payout_cap` | 控制单用户、设备、房间、主播关联风险敞口 |
|
||||
| 娱乐补贴 | `room_atmosphere_rate_ppm`, `room_atmosphere_initial`, `room_atmosphere_reserve`, `activity_budget`, `activity_daily_limit` | 控制房间爆点和活动补贴 |
|
||||
|
||||
@ -100,8 +101,8 @@ effective_reward_coins = base_reward_coins
|
||||
| Goal | Primary Change |
|
||||
| --- | --- |
|
||||
| 降成本 | 降 `target_rtp_ppm`,降补贴预算,提高高水位倍数 |
|
||||
| 增刺激 | 增房间气氛和活动预算,提高中高倍权重 |
|
||||
| 新手更友好 | 提高新手小奖权重,延长 `novice_draw_limit` |
|
||||
| 增刺激 | 增加高倍倍率,增房间气氛和活动预算 |
|
||||
| 新手更友好 | 增加 0.2x/0.5x/1x 等小倍率,延长 `novice_draw_limit` |
|
||||
| 防套利 | 降低用户/设备/房间/主播上限,关闭高倍 |
|
||||
| RTP 更稳 | 缩小 RTP 窗口 |
|
||||
| 体验更自然 | 放大 RTP 窗口,但接受短周期波动 |
|
||||
@ -110,13 +111,13 @@ effective_reward_coins = base_reward_coins
|
||||
|
||||
| Table | Owner Data |
|
||||
| --- | --- |
|
||||
| `lucky_gift_rules` | 全局幸运礼物规则和后台可调参数;规则作用域固定为 `__global__` |
|
||||
| `lucky_rtp_windows` | 全站/幸运礼物整体 RTP 窗口 |
|
||||
| `lucky_pools` | platform / room / lucky-gift-global 三层基础奖池 |
|
||||
| `lucky_room_atmosphere_pools` | 房间气氛预算 |
|
||||
| `lucky_activity_budgets` | 活动补贴预算;`budget_day=__total__` 控总额,UTC 日期行控日释放 |
|
||||
| `lucky_user_states` | 用户在幸运礼物整体上的累计付费抽数 |
|
||||
| `lucky_risk_counters` | 风控窗口计数 |
|
||||
| `lucky_gift_rules` | 按 `pool_id` 保存幸运礼物奖池规则和后台可调参数 |
|
||||
| `lucky_rtp_windows` | 按 `pool_id` 保存 RTP 控制窗口 |
|
||||
| `lucky_pools` | 按 `pool_id` 保存 platform / room / gift 三层基础奖池 |
|
||||
| `lucky_room_atmosphere_pools` | 按 `pool_id + room_id` 保存房间气氛预算 |
|
||||
| `lucky_activity_budgets` | 按 `pool_id` 保存活动补贴预算;`budget_day=__total__` 控总额,UTC 日期行控日释放 |
|
||||
| `lucky_user_states` | 用户在单个 `pool_id` 内的累计付费抽数 |
|
||||
| `lucky_risk_counters` | 按 `pool_id` 保存风控窗口计数 |
|
||||
| `lucky_draw_records` | 每次抽奖事实和审计快照 |
|
||||
|
||||
## Verification
|
||||
|
||||
@ -49,6 +49,7 @@ Authorization: Bearer <access_token>
|
||||
"roles": {
|
||||
"is_host": true,
|
||||
"is_agency": false,
|
||||
"is_manager": false,
|
||||
"is_bd": false,
|
||||
"is_bd_leader": false,
|
||||
"is_coin_seller": false,
|
||||
@ -116,6 +117,7 @@ Authorization: Bearer <access_token>
|
||||
| `apply_host` | `roles.is_host == false` |
|
||||
| `host_center` | `roles.is_host == true` |
|
||||
| `agency_center` | `roles.is_agency == true` |
|
||||
| `manager_center` | `roles.is_manager == true` |
|
||||
| `bd_center` | `roles.is_bd == true` |
|
||||
| `bd_leader_center` | `roles.is_bd_leader == true` |
|
||||
| `coin_seller_center` | `roles.is_coin_seller == true` |
|
||||
|
||||
@ -84,6 +84,7 @@
|
||||
| GET | `/api/v1/resources` | resources | `listResources` | App 资源列表 |
|
||||
| GET | `/api/v1/resource-groups/{group_id}` | resources | `getResourceGroup` | App 资源组详情 |
|
||||
| GET | `/api/v1/gifts` | resources | `listGifts` | App 礼物列表 |
|
||||
| GET | `/api/v1/gift-tabs` | resources | `listGiftTabs` | App 礼物 Tab 配置和礼物预加载列表,不依赖房间 |
|
||||
| GET | `/api/v1/users/me/resources` | resources | `listMyResources` | 我的资源权益 |
|
||||
| POST | `/api/v1/users/me/resources/{resource_id}/equip` | resources | `equipMyResource` | 佩戴我的资源 |
|
||||
| GET | `/api/v1/messages/tabs` | messages | `listMessageTabs` | 消息 tab 分区摘要 |
|
||||
|
||||
898
docs/红包功能技术设计.md
Normal file
898
docs/红包功能技术设计.md
Normal file
@ -0,0 +1,898 @@
|
||||
# 红包功能技术设计
|
||||
|
||||
本文定义普通红包和延迟红包的产品规则、服务边界、账务模型、后台配置、App 接口、区域飘屏、过期退款和验收边界。红包是房间内用户主动发起的金币互动玩法,资金事实必须由 `wallet-service` 持久化,区域飘屏只是已提交红包事实的实时展示入口。所有业务时间统一使用 UTC,跨服务字段统一使用 Unix epoch milliseconds,字段名保持 `*_ms`。
|
||||
|
||||
相关现有文档:
|
||||
|
||||
```text
|
||||
docs/钱包服务架构.md
|
||||
docs/IM全局与区域广播架构.md
|
||||
docs/资源组与礼物架构.md
|
||||
docs/语音房客户端接口流程.md
|
||||
```
|
||||
|
||||
## 当前实现依据
|
||||
|
||||
| 能力 | 当前事实 | 红包设计决策 |
|
||||
| --- | --- | --- |
|
||||
| 账务 owner | `wallet-service` 已拥有 `wallet_accounts`、`wallet_transactions`、`wallet_entries`、`wallet_outbox` | 红包扣款、领取入账和过期退款全部放在 `wallet-service` |
|
||||
| 账务幂等 | `wallet_transactions.command_id` 和 `request_hash` 已作为命令幂等模型 | 发红包、抢红包和退款都必须有稳定 `command_id` |
|
||||
| 事件投递 | 钱包文档约束 `wallet_outbox` 必须和账务交易同事务写入 | 红包创建成功后写 wallet outbox,不让 MQ 成为资金事实来源 |
|
||||
| 区域播报 | `activity-service` 已有 `BroadcastService.PublishRegionBroadcast` 和 `im_broadcast_outbox` | 红包飘屏由 activity 消费红包创建事件后写区域播报 outbox |
|
||||
| App HTTP | `gateway-service` `/api/v1` 响应使用 `{code,message,request_id,data}` envelope | 红包 App 接口只在 gateway 暴露,内部走 gRPC |
|
||||
| 后台入口 | `server/admin` 只能通过 protobuf client 或明确集成接口访问 app 后端 | 后台红包配置通过 admin server 调 wallet-service 管理 RPC |
|
||||
| 时间规则 | 全局业务时间统一 UTC,时间范围使用 `[start_ms,end_ms)` | 每日发送次数按 UTC 日期统计,过期和延迟打开都用 UTC epoch ms |
|
||||
|
||||
## Goals
|
||||
|
||||
- 支持普通红包和延迟红包。
|
||||
- 后台可配置红包数量档位、红包金额档位、延迟红包秒数、每用户每日发送次数上限和红包过期秒数。
|
||||
- 发红包时立即扣除发送者金币,并把红包金额拆成服务端随机份额。
|
||||
- 每个用户每个红包最多领取一次,领取金额随机且总和严格等于红包总金额。
|
||||
- 红包过期后自动退回未领取金额,退款必须幂等。
|
||||
- 发红包成功后触发区域飘屏,飘屏失败不能回滚已提交账务事实。
|
||||
- Flutter 只对接接口和 IM 飘屏消息,不在客户端计算红包金额、过期、限次或随机结果。
|
||||
|
||||
## Non-Goals
|
||||
|
||||
| 不做 | 原因 |
|
||||
| --- | --- |
|
||||
| 客户端提交随机拆分结果 | 金额分配必须由服务端生成并持久化 |
|
||||
| 只用 Redis 保存红包剩余金额 | 红包资金事实需要 MySQL 事务和账务分录恢复 |
|
||||
| 把红包状态放进 `room-service` | 红包不是 Room Cell 核心状态,房间只提供入口上下文 |
|
||||
| 用 MQ 直接表示扣款或退款成功 | MQ 可以投递事实,不能成为账务事实来源 |
|
||||
| 为每个红包创建 IM 群 | 红包是业务消息,不是会话边界 |
|
||||
| 后台直接写 wallet 业务表 | 后台必须调用 owner service 管理接口并写审计 |
|
||||
|
||||
## 产品规则
|
||||
|
||||
| 规则 | 决策 |
|
||||
| --- | --- |
|
||||
| 红包类型 | `normal` 普通红包,`delayed` 延迟红包 |
|
||||
| 普通红包 | 创建成功后立即可领取,`open_at_ms = created_at_ms` |
|
||||
| 延迟红包 | 创建成功时立即扣款,`open_at_ms = created_at_ms + delay_seconds * 1000` |
|
||||
| 扣款资产 | 只允许 `COIN` 金币 |
|
||||
| 金额单位 | 整数金币,不使用小数或浮点数 |
|
||||
| 档位校验 | `total_amount` 必须命中金额档位,`packet_count` 必须命中数量档位 |
|
||||
| 最小金额 | `total_amount >= packet_count`,保证每份至少 1 金币 |
|
||||
| 每日次数 | 按 UTC 日期统计 `sender_user_id` 发红包次数 |
|
||||
| 过期退款 | 过期后未领取金额退回发送者金币账户 |
|
||||
| 可见范围 | 红包绑定 `room_id`,飘屏发送到房间所在 `region_id` |
|
||||
| 发送者自领 | 默认允许发送者领取自己的红包;如果产品要求禁止,只需要在领取校验加 `sender_user_id != claimer_user_id` |
|
||||
|
||||
## 服务边界
|
||||
|
||||
| 模块 | 拥有 | 不拥有 |
|
||||
| --- | --- | --- |
|
||||
| `wallet-service` | 红包配置、发红包扣款、随机份额、领取入账、过期退款、每日发送次数、红包账务 outbox | 房间核心状态、IM 群生命周期、用户资料展示 |
|
||||
| `gateway-service` | App HTTP envelope、鉴权、协议转换、从 room/user 上下文补齐 `room_id/region_id` 后调用 wallet | 红包金额随机、账务分录、过期扫描 |
|
||||
| `activity-service` | 消费红包创建事实并写 `im_broadcast_outbox`,调用 `PublishRegionBroadcast` | 红包扣款、领取、退款和剩余金额 |
|
||||
| `server/admin` | 后台菜单、配置表单、审计、调用 wallet 管理 RPC | 直接修改红包业务表或钱包账本 |
|
||||
| `hyapp-admin-platform` | `活动管理 / 红包配置` 页面和红包记录查询 | 业务校验最终裁决 |
|
||||
| `hyapp-flutter` | 展示配置档位、发红包、抢红包、展示领取结果和飘屏入口 | 本地计算可抢状态之外的账务结果 |
|
||||
|
||||
## 状态机
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> created
|
||||
created --> active: normal created
|
||||
created --> waiting_open: delayed created
|
||||
waiting_open --> active: now_ms >= open_at_ms
|
||||
active --> finished: remaining_count = 0
|
||||
active --> expired: now_ms >= expires_at_ms
|
||||
waiting_open --> expired: now_ms >= expires_at_ms
|
||||
expired --> refunded: refund remaining_amount
|
||||
finished --> [*]
|
||||
refunded --> [*]
|
||||
```
|
||||
|
||||
| 状态 | 语义 |
|
||||
| --- | --- |
|
||||
| `created` | 红包创建命令开始,通常不作为长期状态暴露 |
|
||||
| `waiting_open` | 延迟红包已扣款,但未到领取时间 |
|
||||
| `active` | 可领取 |
|
||||
| `finished` | 所有份额已领取 |
|
||||
| `expired` | 已过期,等待或正在执行退款 |
|
||||
| `refunded` | 未领取金额已退回发送者 |
|
||||
|
||||
## 数据库设计
|
||||
|
||||
红包表放在 `wallet-service` 所属 MySQL schema,和账务交易同库,便于在同一事务中维护金额、分录和 outbox。
|
||||
|
||||
### 配置表
|
||||
|
||||
```sql
|
||||
CREATE TABLE red_packet_configs (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
delayed_open_seconds INT NOT NULL DEFAULT 0,
|
||||
expire_seconds INT NOT NULL DEFAULT 300,
|
||||
daily_send_limit INT NOT NULL DEFAULT 0,
|
||||
updated_by_admin_id BIGINT NOT NULL DEFAULT 0,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code)
|
||||
);
|
||||
|
||||
CREATE TABLE red_packet_count_tiers (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
tier_id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
packet_count INT NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (tier_id),
|
||||
UNIQUE KEY uk_red_packet_count_tier (app_code, packet_count)
|
||||
);
|
||||
|
||||
CREATE TABLE red_packet_amount_tiers (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
tier_id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
total_amount BIGINT NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (tier_id),
|
||||
UNIQUE KEY uk_red_packet_amount_tier (app_code, total_amount)
|
||||
);
|
||||
```
|
||||
|
||||
`daily_send_limit = 0` 表示禁止发送,不表示无限制。需要无限制时必须用显式大值,避免运营误配置。
|
||||
|
||||
### 红包事实表
|
||||
|
||||
```sql
|
||||
CREATE TABLE red_packets (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
packet_id VARCHAR(96) NOT NULL,
|
||||
command_id VARCHAR(128) NOT NULL,
|
||||
sender_user_id BIGINT NOT NULL,
|
||||
room_id VARCHAR(96) NOT NULL,
|
||||
region_id BIGINT NOT NULL,
|
||||
packet_type VARCHAR(32) NOT NULL,
|
||||
total_amount BIGINT NOT NULL,
|
||||
packet_count INT NOT NULL,
|
||||
remaining_amount BIGINT NOT NULL,
|
||||
remaining_count INT NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
open_at_ms BIGINT NOT NULL,
|
||||
expires_at_ms BIGINT NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, packet_id),
|
||||
UNIQUE KEY uk_red_packets_command (app_code, command_id),
|
||||
KEY idx_red_packets_room_time (app_code, room_id, created_at_ms),
|
||||
KEY idx_red_packets_expire (app_code, status, expires_at_ms)
|
||||
);
|
||||
|
||||
CREATE TABLE red_packet_allocations (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
packet_id VARCHAR(96) NOT NULL,
|
||||
allocation_no INT NOT NULL,
|
||||
amount BIGINT NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
claimed_by_user_id BIGINT NOT NULL DEFAULT 0,
|
||||
claim_id VARCHAR(96) NOT NULL DEFAULT '',
|
||||
claimed_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (app_code, packet_id, allocation_no),
|
||||
KEY idx_red_packet_alloc_claimable (app_code, packet_id, status, allocation_no),
|
||||
KEY idx_red_packet_alloc_user (app_code, claimed_by_user_id, claimed_at_ms)
|
||||
);
|
||||
|
||||
CREATE TABLE red_packet_claims (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
claim_id VARCHAR(96) NOT NULL,
|
||||
command_id VARCHAR(128) NOT NULL,
|
||||
packet_id VARCHAR(96) NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
amount BIGINT NOT NULL,
|
||||
wallet_transaction_id VARCHAR(96) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
failure_reason VARCHAR(64) NOT NULL DEFAULT '',
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, claim_id),
|
||||
UNIQUE KEY uk_red_packet_claim_command (app_code, command_id),
|
||||
UNIQUE KEY uk_red_packet_claim_user (app_code, packet_id, user_id),
|
||||
KEY idx_red_packet_claim_packet (app_code, packet_id, created_at_ms)
|
||||
);
|
||||
```
|
||||
|
||||
### 退款和次数表
|
||||
|
||||
```sql
|
||||
CREATE TABLE red_packet_refunds (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
refund_id VARCHAR(96) NOT NULL,
|
||||
command_id VARCHAR(128) NOT NULL,
|
||||
packet_id VARCHAR(96) NOT NULL,
|
||||
sender_user_id BIGINT NOT NULL,
|
||||
refund_amount BIGINT NOT NULL,
|
||||
wallet_transaction_id VARCHAR(96) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, refund_id),
|
||||
UNIQUE KEY uk_red_packet_refund_command (app_code, command_id),
|
||||
UNIQUE KEY uk_red_packet_refund_packet (app_code, packet_id)
|
||||
);
|
||||
|
||||
CREATE TABLE red_packet_user_daily_counters (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
user_id BIGINT NOT NULL,
|
||||
send_day CHAR(10) NOT NULL,
|
||||
sent_count INT NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, user_id, send_day)
|
||||
);
|
||||
```
|
||||
|
||||
`send_day` 使用 UTC 日期字符串,例如 `2026-05-22`。不要使用服务器本地时区或客户端时区。
|
||||
|
||||
## 账务分录
|
||||
|
||||
红包使用 `COIN` 资产,建议新增账务类型:
|
||||
|
||||
| `biz_type` | 场景 | 分录 |
|
||||
| --- | --- | --- |
|
||||
| `red_packet_create` | 发送红包 | sender `COIN` `available_delta = -total_amount` |
|
||||
| `red_packet_claim` | 抢红包成功 | claimer `COIN` `available_delta = +amount` |
|
||||
| `red_packet_refund` | 过期退款 | sender `COIN` `available_delta = +remaining_amount` |
|
||||
|
||||
发红包扣款不是冻结余额,而是实际扣除并形成红包负债。未领取金额由 `red_packets.remaining_amount` 表达,过期后通过退款交易回到发送者账户。这样账本只保留用户余额事实,红包池余额由红包领域表负责审计,避免给用户账户引入复杂冻结语义。
|
||||
|
||||
## 随机金额拆分
|
||||
|
||||
红包创建时一次性生成所有份额,写入 `red_packet_allocations`。领取时只分配已经生成的未领取份额,不再动态计算金额。
|
||||
|
||||
约束:
|
||||
|
||||
- 每份最小 `1` 金币。
|
||||
- 份额数量等于 `packet_count`。
|
||||
- 份额总和严格等于 `total_amount`。
|
||||
- 使用服务端安全随机数。
|
||||
- 生成后打乱 `allocation_no`,避免领取顺序和金额排序相关。
|
||||
|
||||
推荐算法:
|
||||
|
||||
```text
|
||||
remaining_amount = total_amount
|
||||
remaining_count = packet_count
|
||||
|
||||
for i in 1..packet_count:
|
||||
if remaining_count == 1:
|
||||
amount = remaining_amount
|
||||
else:
|
||||
min_amount = 1
|
||||
max_amount = remaining_amount - (remaining_count - 1)
|
||||
avg = remaining_amount / remaining_count
|
||||
draw_max = min(max_amount, max(1, avg * 2))
|
||||
amount = secure_random_int(min_amount, draw_max)
|
||||
|
||||
append amount
|
||||
remaining_amount -= amount
|
||||
remaining_count -= 1
|
||||
|
||||
shuffle amounts
|
||||
```
|
||||
|
||||
这个算法保留随机波动,同时保证最后不会出现剩余金额不足以分配最小份额的情况。后续如果产品需要更平滑分布,可以把 `avg * 2` 改成配置项,但不能让客户端控制。
|
||||
|
||||
## 发红包流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Flutter
|
||||
participant G as gateway-service
|
||||
participant R as room-service
|
||||
participant W as wallet-service
|
||||
participant DB as wallet MySQL
|
||||
participant A as activity-service
|
||||
participant IM as Tencent IM
|
||||
|
||||
C->>G: POST /api/v1/rooms/{room_id}/red-packets
|
||||
G->>R: GetRoomContext(room_id)
|
||||
R-->>G: room_id, visible_region_id
|
||||
G->>W: CreateRedPacket(command_id, sender, room, region, type, amount, count)
|
||||
W->>DB: validate config + daily counter + debit + allocations + wallet_outbox
|
||||
W-->>G: packet_id, open_at_ms, expires_at_ms
|
||||
G-->>C: red packet created
|
||||
A->>DB: scan wallet_outbox red_packet.created
|
||||
A->>A: PublishRegionBroadcast
|
||||
A->>IM: region floating broadcast
|
||||
```
|
||||
|
||||
关键规则:
|
||||
|
||||
- `gateway-service` 不能信任客户端提交的 `region_id`,必须从房间上下文取房间可见区域。
|
||||
- `wallet-service` 在同一个 MySQL 事务内完成配置校验、每日次数递增、sender 扣款、红包记录、份额记录、钱包交易、钱包分录和 `wallet_outbox`。
|
||||
- 同一个 `app_code + command_id` 重复调用,payload 相同返回原红包,payload 不同返回 `REQUEST_CONFLICT`。
|
||||
- 延迟红包创建成功后立即扣款和飘屏,客户端根据 `open_at_ms` 展示倒计时。
|
||||
|
||||
## 抢红包流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Flutter
|
||||
participant G as gateway-service
|
||||
participant W as wallet-service
|
||||
participant DB as wallet MySQL
|
||||
|
||||
C->>G: POST /api/v1/red-packets/{packet_id}/claim
|
||||
G->>W: ClaimRedPacket(command_id, packet_id, user_id)
|
||||
W->>DB: lock packet
|
||||
W->>DB: check open/expire/already claimed
|
||||
W->>DB: lock one unclaimed allocation
|
||||
W->>DB: credit claimer + claim + update remaining
|
||||
W-->>G: amount, claim_id
|
||||
G-->>C: claim result
|
||||
```
|
||||
|
||||
并发控制:
|
||||
|
||||
- `red_packets` 行先加锁,保证剩余金额和数量更新一致。
|
||||
- `red_packet_claims` 上 `uk_red_packet_claim_user(app_code, packet_id, user_id)` 保证每人每红包最多领取一次。
|
||||
- 未领取份额使用 `SELECT ... FOR UPDATE SKIP LOCKED` 或等价行锁策略获取一条 `unclaimed` allocation。
|
||||
- 领取成功必须和 claimer 钱包入账、红包剩余数量更新在同一个事务中提交。
|
||||
- 如果没有未领取份额,返回 `RED_PACKET_SOLD_OUT`。
|
||||
|
||||
## 过期退款流程
|
||||
|
||||
过期退款由 `wallet-service` worker 执行。`cron-service` 可以通过内部 gRPC 触发批处理,但不能直接修改红包或钱包表。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Cron as cron-service or wallet worker
|
||||
participant W as wallet-service
|
||||
participant DB as wallet MySQL
|
||||
|
||||
Cron->>W: ExpireRedPackets(batch_size)
|
||||
W->>DB: select active/waiting packets expires_at_ms <= now for update
|
||||
W->>DB: sum unclaimed allocations
|
||||
W->>DB: credit sender refund
|
||||
W->>DB: mark allocations expired
|
||||
W->>DB: mark packet refunded
|
||||
W->>DB: insert wallet_outbox red_packet.refunded
|
||||
```
|
||||
|
||||
退款命令号固定:
|
||||
|
||||
```text
|
||||
red_packet_refund:<packet_id>
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 退款金额等于未领取 allocation 金额总和,不由客户端或外部任务传入。
|
||||
- `remaining_amount = 0` 时只把红包改成 `finished` 或 `expired`,不产生 0 金额退款交易。
|
||||
- 重复扫描同一个过期红包必须返回同一退款结果,不能重复加钱。
|
||||
- 过期判断使用 `expires_at_ms <= now_ms`。
|
||||
|
||||
## 区域飘屏与 MQ
|
||||
|
||||
红包创建成功后,`wallet-service` 写 `wallet_outbox`:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "red_packet.created",
|
||||
"packet_id": "rp_xxx",
|
||||
"packet_type": "normal",
|
||||
"sender_user_id": 123,
|
||||
"room_id": "100001",
|
||||
"region_id": 86,
|
||||
"total_amount": 1000,
|
||||
"packet_count": 10,
|
||||
"open_at_ms": 1780000000000,
|
||||
"expires_at_ms": 1780000300000,
|
||||
"created_at_ms": 1780000000000
|
||||
}
|
||||
```
|
||||
|
||||
`activity-service` 消费该事实后调用现有播报能力:
|
||||
|
||||
```text
|
||||
BroadcastService.PublishRegionBroadcast(
|
||||
region_id = event.region_id,
|
||||
broadcast_type = "red_packet_created",
|
||||
payload_json = event payload
|
||||
)
|
||||
```
|
||||
|
||||
MQ 设计结论:
|
||||
|
||||
- 可以把 `wallet_outbox` relay 到 MQ,提高实时性。
|
||||
- 不能让 MQ 成为扣款、领取或退款事实来源。
|
||||
- activity 消费失败只影响飘屏实时性,不回滚红包创建。
|
||||
- activity 必须按 `event_id` 幂等,避免 outbox 重投导致重复飘屏。
|
||||
- 如果 MQ 不可用,activity 可以像首冲奖励 worker 一样轮询 `wallet_outbox` 作为补偿路径。
|
||||
|
||||
## 内部 gRPC 契约
|
||||
|
||||
建议在 `api/proto/wallet/v1/wallet.proto` 增加红包 RPC。
|
||||
|
||||
```proto
|
||||
message RedPacketConfig {
|
||||
bool enabled = 1;
|
||||
repeated int32 count_tiers = 2;
|
||||
repeated int64 amount_tiers = 3;
|
||||
int32 delayed_open_seconds = 4;
|
||||
int32 expire_seconds = 5;
|
||||
int32 daily_send_limit = 6;
|
||||
int64 updated_at_ms = 7;
|
||||
}
|
||||
|
||||
message CreateRedPacketRequest {
|
||||
common.v1.RequestMeta meta = 1;
|
||||
string command_id = 2;
|
||||
string app_code = 3;
|
||||
int64 sender_user_id = 4;
|
||||
string room_id = 5;
|
||||
int64 region_id = 6;
|
||||
string packet_type = 7;
|
||||
int64 total_amount = 8;
|
||||
int32 packet_count = 9;
|
||||
}
|
||||
|
||||
message CreateRedPacketResponse {
|
||||
string packet_id = 1;
|
||||
string status = 2;
|
||||
int64 total_amount = 3;
|
||||
int32 packet_count = 4;
|
||||
int64 open_at_ms = 5;
|
||||
int64 expires_at_ms = 6;
|
||||
int64 balance_after = 7;
|
||||
}
|
||||
|
||||
message ClaimRedPacketRequest {
|
||||
common.v1.RequestMeta meta = 1;
|
||||
string command_id = 2;
|
||||
string app_code = 3;
|
||||
string packet_id = 4;
|
||||
int64 user_id = 5;
|
||||
}
|
||||
|
||||
message ClaimRedPacketResponse {
|
||||
string claim_id = 1;
|
||||
string packet_id = 2;
|
||||
int64 amount = 3;
|
||||
int64 claimed_at_ms = 4;
|
||||
int64 balance_after = 5;
|
||||
}
|
||||
```
|
||||
|
||||
管理侧 RPC:
|
||||
|
||||
```proto
|
||||
rpc GetRedPacketConfig(GetRedPacketConfigRequest) returns (GetRedPacketConfigResponse);
|
||||
rpc UpdateRedPacketConfig(UpdateRedPacketConfigRequest) returns (UpdateRedPacketConfigResponse);
|
||||
rpc ListRedPackets(ListRedPacketsRequest) returns (ListRedPacketsResponse);
|
||||
rpc GetRedPacket(GetRedPacketRequest) returns (GetRedPacketResponse);
|
||||
rpc ExpireRedPackets(ExpireRedPacketsRequest) returns (ExpireRedPacketsResponse);
|
||||
```
|
||||
|
||||
## App HTTP 接口
|
||||
|
||||
### 获取红包配置
|
||||
|
||||
```http
|
||||
GET /api/v1/red-packets/config
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
Response `data`:
|
||||
|
||||
```json
|
||||
{
|
||||
"config": {
|
||||
"enabled": true,
|
||||
"count_tiers": [5, 10, 20],
|
||||
"amount_tiers": [100, 500, 1000],
|
||||
"delayed_open_seconds": 30,
|
||||
"expire_seconds": 300,
|
||||
"daily_send_limit": 5
|
||||
},
|
||||
"server_time_ms": 1780000000000
|
||||
}
|
||||
```
|
||||
|
||||
### 发送红包
|
||||
|
||||
```http
|
||||
POST /api/v1/rooms/{room_id}/red-packets/create
|
||||
Authorization: Bearer <access_token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"command_id": "uuid",
|
||||
"packet_type": "normal",
|
||||
"total_amount": 1000,
|
||||
"packet_count": 10
|
||||
}
|
||||
```
|
||||
|
||||
`packet_type`:
|
||||
|
||||
| 值 | 语义 |
|
||||
| --- | --- |
|
||||
| `normal` | 普通红包 |
|
||||
| `delayed` | 延迟红包,打开时间由后台 `delayed_open_seconds` 决定 |
|
||||
|
||||
Response `data`:
|
||||
|
||||
```json
|
||||
{
|
||||
"packet": {
|
||||
"packet_id": "rp_xxx",
|
||||
"packet_type": "normal",
|
||||
"status": "active",
|
||||
"total_amount": 1000,
|
||||
"packet_count": 10,
|
||||
"remaining_count": 10,
|
||||
"open_at_ms": 1780000000000,
|
||||
"expires_at_ms": 1780000300000
|
||||
},
|
||||
"server_time_ms": 1780000000000,
|
||||
"balance_after": 99000
|
||||
}
|
||||
```
|
||||
|
||||
### 房间红包列表
|
||||
|
||||
```http
|
||||
GET /api/v1/rooms/{room_id}/red-packets?status=active&page=1&page_size=20
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
Response `data`:
|
||||
|
||||
```json
|
||||
{
|
||||
"packets": [
|
||||
{
|
||||
"packet_id": "rp_xxx",
|
||||
"packet_type": "delayed",
|
||||
"sender_user_id": 123,
|
||||
"total_amount": 1000,
|
||||
"packet_count": 10,
|
||||
"remaining_count": 10,
|
||||
"status": "waiting_open",
|
||||
"open_at_ms": 1780000030000,
|
||||
"expires_at_ms": 1780000300000,
|
||||
"created_at_ms": 1780000000000,
|
||||
"claimed_by_viewer": false
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"server_time_ms": 1780000001000
|
||||
}
|
||||
```
|
||||
|
||||
### 红包详情
|
||||
|
||||
```http
|
||||
GET /api/v1/red-packets/{packet_id}
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
Response `data`:
|
||||
|
||||
```json
|
||||
{
|
||||
"packet": {
|
||||
"packet_id": "rp_xxx",
|
||||
"packet_type": "normal",
|
||||
"sender_user_id": 123,
|
||||
"room_id": "100001",
|
||||
"region_id": 86,
|
||||
"total_amount": 1000,
|
||||
"packet_count": 10,
|
||||
"remaining_amount": 420,
|
||||
"remaining_count": 4,
|
||||
"status": "active",
|
||||
"open_at_ms": 1780000000000,
|
||||
"expires_at_ms": 1780000300000,
|
||||
"claimed_by_viewer": true,
|
||||
"viewer_claim_amount": 88,
|
||||
"claims": [
|
||||
{
|
||||
"user_id": 456,
|
||||
"amount": 120,
|
||||
"created_at_ms": 1780000005000
|
||||
}
|
||||
]
|
||||
},
|
||||
"server_time_ms": 1780000010000
|
||||
}
|
||||
```
|
||||
|
||||
领取列表是否展示完整金额由产品决定;如果需要弱化刺激,可以只展示自己的领取金额和已领取人数。
|
||||
|
||||
### 抢红包
|
||||
|
||||
```http
|
||||
POST /api/v1/red-packets/{packet_id}/claim
|
||||
Authorization: Bearer <access_token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"command_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
Response `data`:
|
||||
|
||||
```json
|
||||
{
|
||||
"claim": {
|
||||
"claim_id": "rpc_xxx",
|
||||
"packet_id": "rp_xxx",
|
||||
"amount": 88,
|
||||
"created_at_ms": 1780000010000
|
||||
},
|
||||
"server_time_ms": 1780000010000,
|
||||
"balance_after": 100088
|
||||
}
|
||||
```
|
||||
|
||||
## 错误码
|
||||
|
||||
| code | HTTP | 场景 | Flutter 处理 |
|
||||
| --- | --- | --- | --- |
|
||||
| `RED_PACKET_DISABLED` | 409 | 红包功能未启用 | 隐藏入口或提示暂不可用 |
|
||||
| `INVALID_ARGUMENT` | 400 | 类型不是 `normal/delayed` 或请求字段缺失 | 不重试,刷新配置 |
|
||||
| `INVALID_AMOUNT_TIER` | 400 | 金额未命中后台档位 | 不重试,刷新配置 |
|
||||
| `INVALID_COUNT_TIER` | 400 | 数量未命中后台档位 | 不重试,刷新配置 |
|
||||
| `RED_PACKET_AMOUNT_TOO_SMALL` | 400 | 总金额小于红包数量 | 不重试,前端校验兜底 |
|
||||
| `DAILY_SEND_LIMIT_REACHED` | 429 | 达到 UTC 当日发送上限 | 禁用发送入口到下一 UTC 日 |
|
||||
| `INSUFFICIENT_BALANCE` | 409 | 金币不足 | 引导充值或选择低档 |
|
||||
| `NOT_FOUND` | 404 | 红包不存在 | 关闭详情页并刷新列表 |
|
||||
| `RED_PACKET_NOT_OPEN` | 409 | 延迟红包未到打开时间 | 展示倒计时,不重试抢 |
|
||||
| `RED_PACKET_EXPIRED` | 409 | 红包已过期 | 刷新详情和列表 |
|
||||
| `RED_PACKET_ALREADY_CLAIMED` | 409 | 当前用户已领取 | 展示已领取金额 |
|
||||
| `RED_PACKET_SOLD_OUT` | 409 | 已被抢完 | 展示已抢完 |
|
||||
| `REQUEST_CONFLICT` | 409 | 同一 `command_id` payload 不一致 | 重新生成命令号后由用户重新发起 |
|
||||
|
||||
## 后台设计
|
||||
|
||||
菜单:
|
||||
|
||||
```text
|
||||
活动管理
|
||||
红包配置
|
||||
红包记录
|
||||
```
|
||||
|
||||
### 红包配置页
|
||||
|
||||
字段:
|
||||
|
||||
| 字段 | 控件 | 校验 |
|
||||
| --- | --- | --- |
|
||||
| 是否启用 | Switch | 关闭后 App 获取配置仍返回档位,但发送接口拒绝 |
|
||||
| 红包数量档位 | 可增删数字列表 | 每项 `> 0`,去重,建议排序 |
|
||||
| 红包金额档位 | 可增删数字列表 | 每项 `> 0`,去重,金额单位金币 |
|
||||
| 延迟红包秒数 | 数字输入 | `>= 0`,`0` 表示延迟红包立即可抢,但仍标记为 delayed |
|
||||
| 每人每日最多发送次数 | 数字输入 | `>= 0`,`0` 表示禁止发送 |
|
||||
| 红包过期秒数 | 数字输入 | `> 0`,必须大于延迟秒数,否则延迟红包可能还没打开就过期 |
|
||||
|
||||
后台提交时 admin server 生成审计字段:
|
||||
|
||||
```text
|
||||
admin_id
|
||||
request_id
|
||||
operation
|
||||
before_config
|
||||
after_config
|
||||
created_at_ms
|
||||
```
|
||||
|
||||
### 红包记录页
|
||||
|
||||
筛选:
|
||||
|
||||
```text
|
||||
packet_id
|
||||
sender_user_id
|
||||
room_id
|
||||
region_id
|
||||
packet_type
|
||||
status
|
||||
created_start_ms / created_end_ms
|
||||
```
|
||||
|
||||
列表字段:
|
||||
|
||||
```text
|
||||
packet_id
|
||||
sender_user_id
|
||||
room_id
|
||||
region_id
|
||||
packet_type
|
||||
total_amount
|
||||
packet_count
|
||||
remaining_amount
|
||||
remaining_count
|
||||
status
|
||||
open_at_ms
|
||||
expires_at_ms
|
||||
created_at_ms
|
||||
```
|
||||
|
||||
详情页展示领取明细、退款记录和钱包交易 ID,只读,不允许后台手动改红包状态。需要人工补偿时走现有后台调账或补偿命令,不直接改红包表。
|
||||
|
||||
Admin HTTP:
|
||||
|
||||
```http
|
||||
GET /api/v1/admin/activity/red-packets/config
|
||||
PUT /api/v1/admin/activity/red-packets/config
|
||||
GET /api/v1/admin/activity/red-packets
|
||||
GET /api/v1/admin/activity/red-packets/{packet_id}
|
||||
```
|
||||
|
||||
## Flutter 对接方案
|
||||
|
||||
Flutter 不实现红包金额随机、发送次数、过期退款或账务判断,只消费服务端结果。
|
||||
|
||||
### 初始化
|
||||
|
||||
进入房间时并行拉取:
|
||||
|
||||
```text
|
||||
GET /api/v1/red-packets/config
|
||||
GET /api/v1/rooms/{room_id}/red-packets?status=active&page=1&page_size=20
|
||||
```
|
||||
|
||||
UI 使用 `server_time_ms` 计算倒计时偏移:
|
||||
|
||||
```text
|
||||
client_offset_ms = server_time_ms - local_now_ms
|
||||
effective_now_ms = local_now_ms + client_offset_ms
|
||||
```
|
||||
|
||||
倒计时只用于展示,最终可抢状态以服务端 `ClaimRedPacket` 结果为准。
|
||||
|
||||
### 发红包
|
||||
|
||||
用户只能从 `count_tiers` 和 `amount_tiers` 选择。提交前生成一次性 `command_id`,网络超时可用同一个 `command_id` 重试同一请求。
|
||||
|
||||
普通红包:
|
||||
|
||||
```json
|
||||
{
|
||||
"command_id": "uuid",
|
||||
"packet_type": "normal",
|
||||
"total_amount": 1000,
|
||||
"packet_count": 10
|
||||
}
|
||||
```
|
||||
|
||||
延迟红包:
|
||||
|
||||
```json
|
||||
{
|
||||
"command_id": "uuid",
|
||||
"packet_type": "delayed",
|
||||
"total_amount": 1000,
|
||||
"packet_count": 10
|
||||
}
|
||||
```
|
||||
|
||||
延迟秒数不由 Flutter 传入,只使用后台配置。
|
||||
|
||||
### 抢红包
|
||||
|
||||
每次点击抢红包生成新的 `command_id`。如果返回超时,可以用同一个 `command_id` 查询或重试。收到 `RED_PACKET_ALREADY_CLAIMED` 时需要拉详情展示已领取金额。
|
||||
|
||||
### IM 飘屏
|
||||
|
||||
区域播报消息:
|
||||
|
||||
```json
|
||||
{
|
||||
"broadcast_type": "red_packet_created",
|
||||
"packet_id": "rp_xxx",
|
||||
"packet_type": "delayed",
|
||||
"sender_user_id": 123,
|
||||
"room_id": "100001",
|
||||
"region_id": 86,
|
||||
"total_amount": 1000,
|
||||
"packet_count": 10,
|
||||
"open_at_ms": 1780000030000,
|
||||
"expires_at_ms": 1780000300000,
|
||||
"created_at_ms": 1780000000000
|
||||
}
|
||||
```
|
||||
|
||||
Flutter 收到飘屏后:
|
||||
|
||||
- 当前在同区域时展示飘屏。
|
||||
- 点击飘屏进入对应房间或打开红包详情。
|
||||
- 如果 `effective_now_ms < open_at_ms`,展示倒计时。
|
||||
- 如果 `effective_now_ms >= expires_at_ms`,点击后直接拉详情,以服务端状态为准。
|
||||
|
||||
## 实现顺序
|
||||
|
||||
1. `wallet-service` 增加红包表、配置表、领域模型和 MySQL repository。
|
||||
2. `api/proto/wallet/v1` 增加红包 App RPC、Admin RPC 和 `ExpireRedPackets` 批处理 RPC,运行 `make proto`。
|
||||
3. `wallet-service` 实现 `CreateRedPacket`,覆盖档位、余额、每日次数、随机拆分、幂等和 outbox。
|
||||
4. `wallet-service` 实现 `ClaimRedPacket`,覆盖延迟未开、过期、重复领取、并发抢完和入账。
|
||||
5. `wallet-service` 实现过期退款 worker 或批处理 RPC。
|
||||
6. `activity-service` 增加红包 outbox consumer,调用 `PublishRegionBroadcast` 写区域播报。
|
||||
7. `gateway-service` 增加 App HTTP 接口,保持 `{code,message,request_id,data}` envelope。
|
||||
8. `server/admin` 增加配置和记录查询接口,写后台审计。
|
||||
9. `hyapp-admin-platform` 增加 `活动管理 / 红包配置` 和红包记录页。
|
||||
10. 更新 Flutter 对接文档,不改 Flutter 代码,等 App 侧按接口接入。
|
||||
|
||||
## 验证计划
|
||||
|
||||
### wallet-service 单元和 MySQL 集成
|
||||
|
||||
| 场景 | 预期 |
|
||||
| --- | --- |
|
||||
| 创建普通红包 | sender 扣款,红包 active,allocation 总和等于总金额 |
|
||||
| 创建延迟红包 | sender 扣款,红包 waiting_open,`open_at_ms` 正确 |
|
||||
| 非档位金额 | 返回 `INVALID_AMOUNT_TIER`,余额不变 |
|
||||
| 非档位数量 | 返回 `INVALID_COUNT_TIER`,余额不变 |
|
||||
| 总金额小于数量 | 返回 `RED_PACKET_AMOUNT_TOO_SMALL` |
|
||||
| 金币不足 | 返回 `INSUFFICIENT_BALANCE`,不写红包 |
|
||||
| 每日次数超过 | 返回 `DAILY_SEND_LIMIT_REACHED`,按 UTC 日期切换 |
|
||||
| 同 command 重试 | 返回同一 `packet_id`,余额只扣一次 |
|
||||
| 同 command 不同 payload | 返回 `REQUEST_CONFLICT` |
|
||||
| 未到打开时间领取 | 返回 `RED_PACKET_NOT_OPEN` |
|
||||
| 重复领取 | 第二次返回 `RED_PACKET_ALREADY_CLAIMED` |
|
||||
| 并发领取 | 不超发,领取总额等于红包总额 |
|
||||
| 抢完后领取 | 返回 `RED_PACKET_SOLD_OUT` |
|
||||
| 过期退款 | 未领取金额退回 sender,重复扫描不重复退款 |
|
||||
|
||||
### activity-service 验证
|
||||
|
||||
| 场景 | 预期 |
|
||||
| --- | --- |
|
||||
| 消费 `red_packet.created` | 写入一条 `im_broadcast_outbox` |
|
||||
| outbox 重投 | 不重复写同一飘屏 |
|
||||
| 区域 ID 无效 | 记录失败原因,不影响红包账务 |
|
||||
|
||||
### gateway/admin 验证
|
||||
|
||||
| 场景 | 预期 |
|
||||
| --- | --- |
|
||||
| App 接口 envelope | 所有业务响应符合 `{code,message,request_id,data}` |
|
||||
| gateway 不信任 region_id | 客户端无法伪造区域飘屏 |
|
||||
| admin 更新配置 | wallet 配置生效,写后台审计 |
|
||||
| admin 记录查询 | 可按用户、房间、状态和时间范围查询 |
|
||||
|
||||
### 本地真实数据流程
|
||||
|
||||
```text
|
||||
1. admin 配置数量档位、金额档位、延迟秒数、过期秒数和每日次数。
|
||||
2. 用户 A 充值金币或后台入账金币。
|
||||
3. 用户 A 在房间发普通红包,确认钱包扣款、红包 active、区域飘屏 outbox 产生。
|
||||
4. 用户 B/C/D 抢红包,确认每人金额随机、总和不超过总额、钱包入账。
|
||||
5. 用户 A 发延迟红包,open_at_ms 前抢返回 RED_PACKET_NOT_OPEN。
|
||||
6. 到 open_at_ms 后领取成功。
|
||||
7. 创建一个短过期红包,等待过期 worker 执行,确认未领取金额退回用户 A。
|
||||
8. 同一 UTC 日连续发送到上限,再发送返回 DAILY_SEND_LIMIT_REACHED。
|
||||
```
|
||||
|
||||
## 待确认产品点
|
||||
|
||||
| 问题 | 默认建议 |
|
||||
| --- | --- |
|
||||
| 发送者是否可以抢自己的红包 | 默认允许,减少用户困惑;如需禁止,加领取校验即可 |
|
||||
| 红包详情是否展示所有领取金额 | 默认展示自己的金额和领取人数,完整明细可配置 |
|
||||
| 飘屏点击是否跨房间跳转 | 默认点击进入对应房间并打开红包详情 |
|
||||
| 延迟红包倒计时期间是否展示总额 | 默认展示总额、数量、打开时间,不展示任何随机份额 |
|
||||
268
docs/统计服务技术方案.md
Normal file
268
docs/统计服务技术方案.md
Normal 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。
|
||||
13
hyapp-prod-497009-876bc686e4b6.json
Normal file
13
hyapp-prod-497009-876bc686e4b6.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"type": "service_account",
|
||||
"project_id": "hyapp-prod-497009",
|
||||
"private_key_id": "876bc686e4b6aef7d654bde2811d4e5982cd8f76",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDSaVTeu9Zov44W\nZEnpnY+5wYeVyVYEX/N0Z2dOpihTq3lTvt8aH5b7yKM33R31PjUS2kcylp8610jc\n2Y3Eyt1LH2ckYjbskm+D0JRxlm3GlJjbvI9grBgzMdCGs53Fojkie1RxfQ7OiqbJ\nd3x9Mg48KM4ECYG7m7NCujKSjSEyDCA5O3TPX+MOZkUTgQ4LjGjUsChvxPaGqgnf\nFgs9CUoMAv/a2vEK5aTprd6N5a3HZk54Dk3Z8pnJsElNxuw2y+uGSLqaAsFxnFYG\nK4EK7B0hAcTZJ+4wBDQbs9xgDMxmnXPY5Olgj9IZu79jQYjRUJaI9Is6EeD/n3N5\nDTTejF83AgMBAAECggEAN8SHJAmxCE5NAI5QMMT8BQXQQ9j7HWm5Aw8tCGFtqs91\n9sZOJ4Rb5hwFdpb6i7eTJY4I9NWLGnNY8oAO3SvPf0uxcKk/WXBucgdkcL6oFcvo\nv8S4U60JNPhtBmaw2GLotcTuWq4EsKV7E0bCDgSoVTWTdOp4IhtgZb0oDkOtmq+0\n3saRqPMN+x/iHPI4dAh2nMO6eXmqA+7Fh8fFAVToRDrrYduACG5judRdrt3p0wDq\nUSlt69BHUmv6fV57eY3/+wmwkwQKFR6F0CSfkxpyZVNUarFi33KwF0iD6c9V/IRG\nBDK+AfOJgIVeZcA4MiqGyFClE2c1C/2mHwSwNby08QKBgQD0yrhyVRiRaXAaygu0\np/KgL4Q4FWEQ337D4bgnEbiETABA/ZFn9GcO0hpO1Bn3zKVTlAaGxKo1QeIgLm2s\noP3Np4xo6+2tRsipRuXzA9EKKSax0tZLa1qdRQmqPihhIx3pRRUJpOSIiC3cTBiz\nXMjlcDlYMj2KTn10Zs2FgPCUkwKBgQDcC6CvifqUMuQbiWMcwMLdLAUSNBG5pq+R\n8QbOqjYhyYOXYmr2iJ6na7q2f4cFdormweaZ4MDf0Zgln9miRUi+f+LotIdP3mp1\n+IIDs04LsOGfLukPnHapSn87Q+On1thu84oKwgrqelByAOzo38/7kgEgKkhoryXM\nEVpBHmX1TQKBgF8JpXqeFx+9c8yyzMCiw8v1JrwvcLQAUVLze2+PrbePWjnhOGbH\nuItEfvpD1qEiTr2YJsCD8iEjJwufeu0ew+roNdc2Ydx4MselwvkKbkonl+JKHPDD\nCTct6oayAzNTUvWR4I0R+7gWRtfUo3Jff4+0dk8LkmD0ADkFAJ3oZX8JAoGAHJ+S\nlmL/anm4PHDBqMLWVkjcnAKT769kFTriJM31Xq3E5VNTEKCy6ppT3lt2Z9qEtQGy\nCoA29qZgATzq5XXXwZgHHzgkhdorxQ6/ctXHKDQpFTjX7kTvFpRvOnlZSsLiwoQB\nh+a33spsXngKWTyL5HCYnuOBKcTmyMXVFLVsk90CgYBRey8XqtLV/Yh7kc5DSXC4\nEHfF11m/C25CCD7jhGT6HpnnqAX7ujZldBQokEon1C97FhyErX8dit8ffPAc5+Ns\ntZVQcgJorlWW3gIwzz1ehCHzy8CYYzloTxI1m/vMkAoGuovzsyvZpsvVKXPD6F0D\n/q6rF/VaFOM83eSkSdTssQ==\n-----END PRIVATE KEY-----\n",
|
||||
"client_email": "hyapp-play-api@hyapp-prod-497009.iam.gserviceaccount.com",
|
||||
"client_id": "110702736389997467893",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/hyapp-play-api%40hyapp-prod-497009.iam.gserviceaccount.com",
|
||||
"universe_domain": "googleapis.com"
|
||||
}
|
||||
76
pkg/gamemq/messages.go
Normal file
76
pkg/gamemq/messages.go
Normal file
@ -0,0 +1,76 @@
|
||||
// Package gamemq defines RocketMQ payloads owned by game-service.
|
||||
package gamemq
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
MessageTypeGameOutboxEvent = "game_outbox_event"
|
||||
|
||||
TagGameOutboxEvent = "game_outbox_event"
|
||||
)
|
||||
|
||||
// GameOutboxMessage is the MQ representation of one committed game_outbox fact.
|
||||
type GameOutboxMessage struct {
|
||||
MessageType string `json:"message_type"`
|
||||
AppCode string `json:"app_code"`
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
OrderID string `json:"order_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
PlatformCode string `json:"platform_code"`
|
||||
GameID string `json:"game_id"`
|
||||
OpType string `json:"op_type"`
|
||||
CoinAmount int64 `json:"coin_amount"`
|
||||
PayloadJSON string `json:"payload_json"`
|
||||
OccurredAtMS int64 `json:"occurred_at_ms"`
|
||||
}
|
||||
|
||||
// EncodeGameOutboxMessage serializes a game fact for fanout through RocketMQ.
|
||||
func EncodeGameOutboxMessage(message GameOutboxMessage) ([]byte, error) {
|
||||
message.MessageType = MessageTypeGameOutboxEvent
|
||||
if strings.TrimSpace(message.AppCode) == "" ||
|
||||
strings.TrimSpace(message.EventID) == "" ||
|
||||
strings.TrimSpace(message.EventType) == "" ||
|
||||
strings.TrimSpace(message.OrderID) == "" ||
|
||||
message.UserID <= 0 ||
|
||||
strings.TrimSpace(message.GameID) == "" ||
|
||||
strings.TrimSpace(message.OpType) == "" ||
|
||||
message.CoinAmount <= 0 ||
|
||||
message.OccurredAtMS <= 0 {
|
||||
return nil, errors.New("game outbox message is incomplete")
|
||||
}
|
||||
if strings.TrimSpace(message.PayloadJSON) == "" {
|
||||
message.PayloadJSON = "{}"
|
||||
}
|
||||
return json.Marshal(message)
|
||||
}
|
||||
|
||||
// DecodeGameOutboxMessage validates the MQ body and restores the game fact.
|
||||
func DecodeGameOutboxMessage(body []byte) (GameOutboxMessage, error) {
|
||||
var message GameOutboxMessage
|
||||
if err := json.Unmarshal(body, &message); err != nil {
|
||||
return GameOutboxMessage{}, err
|
||||
}
|
||||
if message.MessageType != MessageTypeGameOutboxEvent {
|
||||
return GameOutboxMessage{}, errors.New("unexpected game outbox message_type")
|
||||
}
|
||||
if strings.TrimSpace(message.AppCode) == "" ||
|
||||
strings.TrimSpace(message.EventID) == "" ||
|
||||
strings.TrimSpace(message.EventType) == "" ||
|
||||
strings.TrimSpace(message.OrderID) == "" ||
|
||||
message.UserID <= 0 ||
|
||||
strings.TrimSpace(message.GameID) == "" ||
|
||||
strings.TrimSpace(message.OpType) == "" ||
|
||||
message.CoinAmount <= 0 ||
|
||||
message.OccurredAtMS <= 0 {
|
||||
return GameOutboxMessage{}, errors.New("game outbox message is incomplete")
|
||||
}
|
||||
if strings.TrimSpace(message.PayloadJSON) == "" {
|
||||
message.PayloadJSON = "{}"
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -51,6 +52,7 @@ type ConsumerConfig struct {
|
||||
ConsumeRetryDelay time.Duration
|
||||
ConsumePullBatch int32
|
||||
ConsumePullTimeout time.Duration
|
||||
ConsumeGoroutines int
|
||||
}
|
||||
|
||||
// Message is the stable publish shape used by services.
|
||||
@ -94,7 +96,11 @@ func NewProducer(cfg ProducerConfig) (*Producer, error) {
|
||||
return nil, errors.New("rocketmq producer group_name is required")
|
||||
}
|
||||
options := []producer.Option{producer.WithGroupName(group)}
|
||||
options = append(options, producerEndpointOptions(cfg.EndpointConfig)...)
|
||||
endpointOptions, err := producerEndpointOptions(cfg.EndpointConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options = append(options, endpointOptions...)
|
||||
if cfg.SendTimeout > 0 {
|
||||
options = append(options, producer.WithSendMsgTimeout(cfg.SendTimeout))
|
||||
}
|
||||
@ -180,7 +186,11 @@ func NewConsumer(cfg ConsumerConfig) (*Consumer, error) {
|
||||
return nil, errors.New("rocketmq consumer group_name is required")
|
||||
}
|
||||
options := []consumer.Option{consumer.WithGroupName(group)}
|
||||
options = append(options, consumerEndpointOptions(cfg.EndpointConfig)...)
|
||||
endpointOptions, err := consumerEndpointOptions(cfg.EndpointConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options = append(options, endpointOptions...)
|
||||
if cfg.MaxReconsumeTimes > 0 {
|
||||
options = append(options, consumer.WithMaxReconsumeTimes(cfg.MaxReconsumeTimes))
|
||||
}
|
||||
@ -193,6 +203,9 @@ func NewConsumer(cfg ConsumerConfig) (*Consumer, error) {
|
||||
if cfg.ConsumePullTimeout > 0 {
|
||||
options = append(options, consumer.WithConsumeTimeout(cfg.ConsumePullTimeout))
|
||||
}
|
||||
if cfg.ConsumeGoroutines > 0 {
|
||||
options = append(options, consumer.WithConsumeGoroutineNums(cfg.ConsumeGoroutines))
|
||||
}
|
||||
client, err := rocketmq.NewPushConsumer(options...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -262,9 +275,14 @@ func validateEndpoint(cfg EndpointConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func producerEndpointOptions(cfg EndpointConfig) []producer.Option {
|
||||
func producerEndpointOptions(cfg EndpointConfig) ([]producer.Option, error) {
|
||||
options := make([]producer.Option, 0, 5)
|
||||
if nameservers := normalizeStringSlice(cfg.NameServers); len(nameservers) > 0 {
|
||||
var err error
|
||||
nameservers, err = resolveNameServers(nameservers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options = append(options, producer.WithNsResolver(primitive.NewPassthroughResolver(nameservers)))
|
||||
} else if domain := strings.TrimSpace(cfg.NameServerDomain); domain != "" {
|
||||
options = append(options, producer.WithNameServerDomain(domain))
|
||||
@ -276,12 +294,17 @@ func producerEndpointOptions(cfg EndpointConfig) []producer.Option {
|
||||
if strings.TrimSpace(cfg.AccessKey) != "" || strings.TrimSpace(cfg.SecretKey) != "" {
|
||||
options = append(options, producer.WithCredentials(credentials(cfg)))
|
||||
}
|
||||
return options
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func consumerEndpointOptions(cfg EndpointConfig) []consumer.Option {
|
||||
func consumerEndpointOptions(cfg EndpointConfig) ([]consumer.Option, error) {
|
||||
options := make([]consumer.Option, 0, 5)
|
||||
if nameservers := normalizeStringSlice(cfg.NameServers); len(nameservers) > 0 {
|
||||
var err error
|
||||
nameservers, err = resolveNameServers(nameservers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options = append(options, consumer.WithNsResolver(primitive.NewPassthroughResolver(nameservers)))
|
||||
} else if domain := strings.TrimSpace(cfg.NameServerDomain); domain != "" {
|
||||
options = append(options, consumer.WithNameServerDomain(domain))
|
||||
@ -293,7 +316,30 @@ func consumerEndpointOptions(cfg EndpointConfig) []consumer.Option {
|
||||
if strings.TrimSpace(cfg.AccessKey) != "" || strings.TrimSpace(cfg.SecretKey) != "" {
|
||||
options = append(options, consumer.WithCredentials(credentials(cfg)))
|
||||
}
|
||||
return options
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func resolveNameServers(nameservers []string) ([]string, error) {
|
||||
resolved := make([]string, 0, len(nameservers))
|
||||
for _, nameserver := range nameservers {
|
||||
host, port, err := net.SplitHostPort(nameserver)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid rocketmq name_server %q: %w", nameserver, err)
|
||||
}
|
||||
if net.ParseIP(host) != nil {
|
||||
resolved = append(resolved, net.JoinHostPort(host, port))
|
||||
continue
|
||||
}
|
||||
ips, err := net.LookupHost(host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve rocketmq name_server %q: %w", nameserver, err)
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return nil, fmt.Errorf("resolve rocketmq name_server %q: empty result", nameserver)
|
||||
}
|
||||
resolved = append(resolved, net.JoinHostPort(ips[0], port))
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func credentials(cfg EndpointConfig) primitive.Credentials {
|
||||
|
||||
66
pkg/usermq/messages.go
Normal file
66
pkg/usermq/messages.go
Normal file
@ -0,0 +1,66 @@
|
||||
// Package usermq defines RocketMQ payloads owned by user-service.
|
||||
package usermq
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
MessageTypeUserOutboxEvent = "user_outbox_event"
|
||||
|
||||
TagUserOutboxEvent = "user_outbox_event"
|
||||
)
|
||||
|
||||
// UserOutboxMessage is the MQ representation of one committed user_outbox fact.
|
||||
type UserOutboxMessage struct {
|
||||
MessageType string `json:"message_type"`
|
||||
AppCode string `json:"app_code"`
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
AggregateType string `json:"aggregate_type"`
|
||||
AggregateID int64 `json:"aggregate_id"`
|
||||
PayloadJSON string `json:"payload_json"`
|
||||
OccurredAtMS int64 `json:"occurred_at_ms"`
|
||||
}
|
||||
|
||||
// EncodeUserOutboxMessage serializes a user fact for fanout through RocketMQ.
|
||||
func EncodeUserOutboxMessage(message UserOutboxMessage) ([]byte, error) {
|
||||
message.MessageType = MessageTypeUserOutboxEvent
|
||||
if strings.TrimSpace(message.AppCode) == "" ||
|
||||
strings.TrimSpace(message.EventID) == "" ||
|
||||
strings.TrimSpace(message.EventType) == "" ||
|
||||
strings.TrimSpace(message.AggregateType) == "" ||
|
||||
message.AggregateID <= 0 ||
|
||||
message.OccurredAtMS <= 0 {
|
||||
return nil, errors.New("user outbox message is incomplete")
|
||||
}
|
||||
if strings.TrimSpace(message.PayloadJSON) == "" {
|
||||
message.PayloadJSON = "{}"
|
||||
}
|
||||
return json.Marshal(message)
|
||||
}
|
||||
|
||||
// DecodeUserOutboxMessage validates the MQ body and restores the user fact.
|
||||
func DecodeUserOutboxMessage(body []byte) (UserOutboxMessage, error) {
|
||||
var message UserOutboxMessage
|
||||
if err := json.Unmarshal(body, &message); err != nil {
|
||||
return UserOutboxMessage{}, err
|
||||
}
|
||||
if message.MessageType != MessageTypeUserOutboxEvent {
|
||||
return UserOutboxMessage{}, errors.New("unexpected user outbox message_type")
|
||||
}
|
||||
if strings.TrimSpace(message.AppCode) == "" ||
|
||||
strings.TrimSpace(message.EventID) == "" ||
|
||||
strings.TrimSpace(message.EventType) == "" ||
|
||||
strings.TrimSpace(message.AggregateType) == "" ||
|
||||
message.AggregateID <= 0 ||
|
||||
message.OccurredAtMS <= 0 {
|
||||
return UserOutboxMessage{}, errors.New("user outbox message is incomplete")
|
||||
}
|
||||
if strings.TrimSpace(message.PayloadJSON) == "" {
|
||||
message.PayloadJSON = "{}"
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
70
pkg/walletmq/messages.go
Normal file
70
pkg/walletmq/messages.go
Normal file
@ -0,0 +1,70 @@
|
||||
// Package walletmq defines RocketMQ payloads owned by wallet-service.
|
||||
package walletmq
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
MessageTypeWalletOutboxEvent = "wallet_outbox_event"
|
||||
|
||||
TagWalletOutboxEvent = "wallet_outbox_event"
|
||||
)
|
||||
|
||||
// WalletOutboxMessage is the MQ representation of one committed wallet_outbox fact.
|
||||
type WalletOutboxMessage struct {
|
||||
MessageType string `json:"message_type"`
|
||||
AppCode string `json:"app_code"`
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
TransactionID string `json:"transaction_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
AssetType string `json:"asset_type"`
|
||||
AvailableDelta int64 `json:"available_delta"`
|
||||
FrozenDelta int64 `json:"frozen_delta"`
|
||||
PayloadJSON string `json:"payload_json"`
|
||||
OccurredAtMS int64 `json:"occurred_at_ms"`
|
||||
}
|
||||
|
||||
// EncodeWalletOutboxMessage serializes a wallet fact for fanout through RocketMQ.
|
||||
func EncodeWalletOutboxMessage(message WalletOutboxMessage) ([]byte, error) {
|
||||
message.MessageType = MessageTypeWalletOutboxEvent
|
||||
if strings.TrimSpace(message.AppCode) == "" ||
|
||||
strings.TrimSpace(message.EventID) == "" ||
|
||||
strings.TrimSpace(message.EventType) == "" ||
|
||||
strings.TrimSpace(message.TransactionID) == "" ||
|
||||
strings.TrimSpace(message.CommandID) == "" ||
|
||||
message.OccurredAtMS <= 0 {
|
||||
return nil, errors.New("wallet outbox message is incomplete")
|
||||
}
|
||||
if strings.TrimSpace(message.PayloadJSON) == "" {
|
||||
message.PayloadJSON = "{}"
|
||||
}
|
||||
return json.Marshal(message)
|
||||
}
|
||||
|
||||
// DecodeWalletOutboxMessage validates the MQ body and restores the wallet fact.
|
||||
func DecodeWalletOutboxMessage(body []byte) (WalletOutboxMessage, error) {
|
||||
var message WalletOutboxMessage
|
||||
if err := json.Unmarshal(body, &message); err != nil {
|
||||
return WalletOutboxMessage{}, err
|
||||
}
|
||||
if message.MessageType != MessageTypeWalletOutboxEvent {
|
||||
return WalletOutboxMessage{}, errors.New("unexpected wallet outbox message_type")
|
||||
}
|
||||
if strings.TrimSpace(message.AppCode) == "" ||
|
||||
strings.TrimSpace(message.EventID) == "" ||
|
||||
strings.TrimSpace(message.EventType) == "" ||
|
||||
strings.TrimSpace(message.TransactionID) == "" ||
|
||||
strings.TrimSpace(message.CommandID) == "" ||
|
||||
message.OccurredAtMS <= 0 {
|
||||
return WalletOutboxMessage{}, errors.New("wallet outbox message is incomplete")
|
||||
}
|
||||
if strings.TrimSpace(message.PayloadJSON) == "" {
|
||||
message.PayloadJSON = "{}"
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
29
pkg/walletmq/messages_test.go
Normal file
29
pkg/walletmq/messages_test.go
Normal file
@ -0,0 +1,29 @@
|
||||
package walletmq
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestWalletOutboxMessageRoundTrip(t *testing.T) {
|
||||
encoded, err := EncodeWalletOutboxMessage(WalletOutboxMessage{
|
||||
AppCode: "lalu",
|
||||
EventID: "event-1",
|
||||
EventType: "WalletRechargeRecorded",
|
||||
TransactionID: "tx-1",
|
||||
CommandID: "cmd-1",
|
||||
UserID: 1001,
|
||||
AssetType: "COIN",
|
||||
AvailableDelta: 100,
|
||||
PayloadJSON: `{"recharge_sequence":1}`,
|
||||
OccurredAtMS: 1_700_000_000_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeWalletOutboxMessage failed: %v", err)
|
||||
}
|
||||
|
||||
decoded, err := DecodeWalletOutboxMessage(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeWalletOutboxMessage failed: %v", err)
|
||||
}
|
||||
if decoded.MessageType != MessageTypeWalletOutboxEvent || decoded.EventID != "event-1" || decoded.PayloadJSON == "" {
|
||||
t.Fatalf("decoded message mismatch: %+v", decoded)
|
||||
}
|
||||
}
|
||||
@ -75,6 +75,17 @@ var catalog = map[Code]Spec{
|
||||
VIPLevelNotFound: spec(codes.NotFound, httpStatusNotFound, VIPLevelNotFound, "not found"),
|
||||
VIPLevelDisabled: spec(codes.FailedPrecondition, httpStatusConflict, VIPLevelDisabled, "conflict"),
|
||||
VIPDowngradeNotAllowed: spec(codes.FailedPrecondition, httpStatusConflict, VIPDowngradeNotAllowed, "conflict"),
|
||||
VIPRechargeRequired: spec(codes.FailedPrecondition, httpStatusConflict, VIPRechargeRequired, "conflict"),
|
||||
|
||||
RedPacketDisabled: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketDisabled, "conflict"),
|
||||
RedPacketInvalidAmountTier: spec(codes.InvalidArgument, httpStatusBadRequest, RedPacketInvalidAmountTier, "invalid argument"),
|
||||
RedPacketInvalidCountTier: spec(codes.InvalidArgument, httpStatusBadRequest, RedPacketInvalidCountTier, "invalid argument"),
|
||||
RedPacketAmountTooSmall: spec(codes.InvalidArgument, httpStatusBadRequest, RedPacketAmountTooSmall, "invalid argument"),
|
||||
RedPacketDailyLimitReached: spec(codes.ResourceExhausted, 429, RedPacketDailyLimitReached, "rate limited"),
|
||||
RedPacketNotOpen: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketNotOpen, "conflict"),
|
||||
RedPacketExpired: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketExpired, "conflict"),
|
||||
RedPacketAlreadyClaimed: spec(codes.AlreadyExists, httpStatusConflict, RedPacketAlreadyClaimed, "conflict"),
|
||||
RedPacketSoldOut: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketSoldOut, "conflict"),
|
||||
|
||||
RuleNotActive: spec(codes.FailedPrecondition, httpStatusConflict, RuleNotActive, "conflict"),
|
||||
EventAlreadyConsumed: spec(codes.AlreadyExists, httpStatusConflict, EventAlreadyConsumed, "conflict"),
|
||||
|
||||
@ -94,6 +94,27 @@ const (
|
||||
VIPLevelDisabled Code = "VIP_LEVEL_DISABLED"
|
||||
// VIPDowngradeNotAllowed 表示用户当前有效 VIP 等级高于本次购买目标等级。
|
||||
VIPDowngradeNotAllowed Code = "VIP_DOWNGRADE_NOT_ALLOWED"
|
||||
// VIPRechargeRequired 表示目标 VIP 等级需要用户先达到累计充值门槛。
|
||||
VIPRechargeRequired Code = "VIP_RECHARGE_REQUIRED"
|
||||
|
||||
// RedPacketDisabled 表示红包功能未启用。
|
||||
RedPacketDisabled Code = "RED_PACKET_DISABLED"
|
||||
// RedPacketInvalidAmountTier 表示红包金额不在后台配置档位内。
|
||||
RedPacketInvalidAmountTier Code = "INVALID_AMOUNT_TIER"
|
||||
// RedPacketInvalidCountTier 表示红包数量不在后台配置档位内。
|
||||
RedPacketInvalidCountTier Code = "INVALID_COUNT_TIER"
|
||||
// RedPacketAmountTooSmall 表示红包总金额小于红包份数,无法保证每份至少 1 金币。
|
||||
RedPacketAmountTooSmall Code = "RED_PACKET_AMOUNT_TOO_SMALL"
|
||||
// RedPacketDailyLimitReached 表示用户达到 UTC 当日发送次数上限。
|
||||
RedPacketDailyLimitReached Code = "DAILY_SEND_LIMIT_REACHED"
|
||||
// RedPacketNotOpen 表示延迟红包尚未到打开时间。
|
||||
RedPacketNotOpen Code = "RED_PACKET_NOT_OPEN"
|
||||
// RedPacketExpired 表示红包已经过期。
|
||||
RedPacketExpired Code = "RED_PACKET_EXPIRED"
|
||||
// RedPacketAlreadyClaimed 表示当前用户已领取过该红包。
|
||||
RedPacketAlreadyClaimed Code = "RED_PACKET_ALREADY_CLAIMED"
|
||||
// RedPacketSoldOut 表示红包已经被抢完。
|
||||
RedPacketSoldOut Code = "RED_PACKET_SOLD_OUT"
|
||||
|
||||
// RuleNotActive 表示活动规则当前不可用。
|
||||
RuleNotActive Code = "RULE_NOT_ACTIVE"
|
||||
|
||||
@ -22,6 +22,7 @@ SQL_FILES=(
|
||||
"services/cron-service/deploy/mysql/initdb/001_cron_service.sql"
|
||||
"services/game-service/deploy/mysql/initdb/001_game_service.sql"
|
||||
"services/notice-service/deploy/mysql/initdb/001_notice_service.sql"
|
||||
"services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql"
|
||||
"deploy/mysql/initdb/999_local_grants.sql"
|
||||
)
|
||||
|
||||
|
||||
@ -100,6 +100,9 @@ print_row "activity-service" "gRPC" "grpc" "activity-service" "13006" "13006"
|
||||
print_row "cron-service" "gRPC" "grpc" "cron-service" "13007" "13007"
|
||||
print_row "game-service" "gRPC" "grpc" "game-service" "13008" "13008"
|
||||
print_row "notice-service" "gRPC" "grpc" "notice-service" "13009" "13009"
|
||||
print_row "statistics-service" "Health" "http" "statistics-service" "13110" "13110" "/healthz/ready"
|
||||
print_row "rocketmq-namesrv" "NameServer" "tcp" "rocketmq-namesrv" "9876" "9876"
|
||||
print_row "rocketmq-broker" "Broker" "tcp" "rocketmq-broker" "10911" "10911"
|
||||
print_row "mysql" "MySQL" "tcp" "mysql" "3306" "23306"
|
||||
print_row "redis" "Redis" "tcp" "redis" "6379" "13379"
|
||||
|
||||
|
||||
@ -34,6 +34,7 @@ import (
|
||||
countryregionmodule "hyapp-admin-server/internal/modules/countryregion"
|
||||
dailytaskmodule "hyapp-admin-server/internal/modules/dailytask"
|
||||
dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
|
||||
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
|
||||
gamemanagementmodule "hyapp-admin-server/internal/modules/gamemanagement"
|
||||
healthmodule "hyapp-admin-server/internal/modules/health"
|
||||
hostorgmodule "hyapp-admin-server/internal/modules/hostorg"
|
||||
@ -43,6 +44,7 @@ import (
|
||||
menumodule "hyapp-admin-server/internal/modules/menu"
|
||||
paymentmodule "hyapp-admin-server/internal/modules/payment"
|
||||
rbacmodule "hyapp-admin-server/internal/modules/rbac"
|
||||
redpacketmodule "hyapp-admin-server/internal/modules/redpacket"
|
||||
regionblockmodule "hyapp-admin-server/internal/modules/regionblock"
|
||||
registrationrewardmodule "hyapp-admin-server/internal/modules/registrationreward"
|
||||
resourcemodule "hyapp-admin-server/internal/modules/resource"
|
||||
@ -51,6 +53,8 @@ import (
|
||||
searchmodule "hyapp-admin-server/internal/modules/search"
|
||||
sevendaycheckinmodule "hyapp-admin-server/internal/modules/sevendaycheckin"
|
||||
uploadmodule "hyapp-admin-server/internal/modules/upload"
|
||||
userleaderboardmodule "hyapp-admin-server/internal/modules/userleaderboard"
|
||||
vipconfigmodule "hyapp-admin-server/internal/modules/vipconfig"
|
||||
"hyapp-admin-server/internal/platform/logging"
|
||||
"hyapp-admin-server/internal/platform/tencentcos"
|
||||
"hyapp-admin-server/internal/repository"
|
||||
@ -132,15 +136,6 @@ func main() {
|
||||
}
|
||||
defer walletDB.Close()
|
||||
|
||||
roomDB, err := sql.Open("mysql", cfg.RoomMySQLDSN)
|
||||
if err != nil {
|
||||
fatalRuntime("connect_room_mysql_failed", err)
|
||||
}
|
||||
if err := roomDB.PingContext(context.Background()); err != nil {
|
||||
fatalRuntime("ping_room_mysql_failed", err)
|
||||
}
|
||||
defer roomDB.Close()
|
||||
|
||||
store := repository.New(db)
|
||||
if cfg.MySQLAutoMigrate {
|
||||
if err := store.AutoMigrate(); err != nil {
|
||||
@ -170,6 +165,7 @@ func main() {
|
||||
fatalRuntime("connect_room_service_failed", err)
|
||||
}
|
||||
defer roomConn.Close()
|
||||
roomClient := roomclient.NewGRPC(roomConn)
|
||||
|
||||
activityConn, err := grpc.Dial(cfg.ActivityService.Addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
@ -209,34 +205,38 @@ func main() {
|
||||
objectUploader = uploader
|
||||
}
|
||||
handlers := router.Handlers{
|
||||
Audit: auditHandler,
|
||||
Auth: authmodule.New(store, auth, auditHandler, cfg),
|
||||
AdminUser: adminusermodule.New(store, cfg, auditHandler),
|
||||
AchievementConfig: achievementconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
AppConfig: appconfigmodule.New(store, auditHandler),
|
||||
AppRegistry: appregistrymodule.New(userDB),
|
||||
AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler),
|
||||
CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler),
|
||||
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
|
||||
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
Dashboard: dashboardmodule.New(store),
|
||||
Game: gamemanagementmodule.New(gameclient.NewGRPC(gameConn), auditHandler),
|
||||
Health: healthmodule.New(store, redisClient, jobStatus),
|
||||
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
|
||||
Job: jobmodule.New(store, cfg, auditHandler),
|
||||
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
Menu: menumodule.New(store, auditHandler),
|
||||
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
||||
RBAC: rbacmodule.New(store, auditHandler),
|
||||
RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
RegionBlock: regionblockmodule.New(userDB, auditHandler),
|
||||
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, auditHandler),
|
||||
RoomAdmin: roomadminmodule.New(roomDB, userDB, roomclient.NewGRPC(roomConn), auditHandler),
|
||||
RoomTreasure: roomtreasuremodule.New(roomDB, auditHandler),
|
||||
Search: searchmodule.New(store),
|
||||
SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
|
||||
Audit: auditHandler,
|
||||
Auth: authmodule.New(store, auth, auditHandler, cfg),
|
||||
AdminUser: adminusermodule.New(store, cfg, auditHandler),
|
||||
AchievementConfig: achievementconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
AppConfig: appconfigmodule.New(store, auditHandler),
|
||||
AppRegistry: appregistrymodule.New(userDB),
|
||||
AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler),
|
||||
CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler),
|
||||
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
|
||||
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
Dashboard: dashboardmodule.New(store, cfg),
|
||||
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
Game: gamemanagementmodule.New(gameclient.NewGRPC(gameConn), auditHandler),
|
||||
Health: healthmodule.New(store, redisClient, jobStatus),
|
||||
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
|
||||
Job: jobmodule.New(store, cfg, auditHandler),
|
||||
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
Menu: menumodule.New(store, auditHandler),
|
||||
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
||||
RBAC: rbacmodule.New(store, auditHandler),
|
||||
RedPacket: redpacketmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
||||
RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
RegionBlock: regionblockmodule.New(userDB, auditHandler),
|
||||
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, auditHandler),
|
||||
RoomAdmin: roomadminmodule.New(userDB, roomClient, auditHandler),
|
||||
RoomTreasure: roomtreasuremodule.New(roomClient, auditHandler),
|
||||
Search: searchmodule.New(store),
|
||||
SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
|
||||
UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomClient),
|
||||
VIPConfig: vipconfigmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
||||
}
|
||||
engine := router.New(cfg, auth, handlers)
|
||||
|
||||
|
||||
@ -11,7 +11,6 @@ http_addr: "127.0.0.1:13100"
|
||||
mysql_dsn: "admin_user:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC"
|
||||
user_mysql_dsn: "user_reader:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC"
|
||||
wallet_mysql_dsn: "wallet_reader:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC"
|
||||
room_mysql_dsn: "room_reader:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC"
|
||||
mysql_auto_migrate: false
|
||||
migrations:
|
||||
enabled: true
|
||||
@ -49,6 +48,9 @@ activity_service:
|
||||
game_service:
|
||||
addr: "10.2.1.16:13008"
|
||||
request_timeout: "3s"
|
||||
statistics_service:
|
||||
base_url: "http://10.2.1.16:13010"
|
||||
request_timeout: "3s"
|
||||
tencent-cos:
|
||||
enabled: true
|
||||
secret-id: "REPLACE_ME"
|
||||
|
||||
@ -11,7 +11,6 @@ http_addr: ":13100"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC"
|
||||
user_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC"
|
||||
wallet_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC"
|
||||
room_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC"
|
||||
mysql_auto_migrate: true
|
||||
migrations:
|
||||
enabled: true
|
||||
@ -49,6 +48,9 @@ activity_service:
|
||||
game_service:
|
||||
addr: "127.0.0.1:13008"
|
||||
request_timeout: "3s"
|
||||
statistics_service:
|
||||
base_url: "http://127.0.0.1:13010"
|
||||
request_timeout: "3s"
|
||||
tencent-cos:
|
||||
enabled: true
|
||||
secret-id: "IKIDMchhZEfrsiNo472DAtTpzzmLjttkOnyu"
|
||||
|
||||
@ -13,32 +13,32 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ServiceName string `yaml:"service_name"`
|
||||
NodeID string `yaml:"node_id"`
|
||||
Environment string `yaml:"environment"`
|
||||
Log logging.Config `yaml:"log"`
|
||||
HTTPAddr string `yaml:"http_addr"`
|
||||
MySQLDSN string `yaml:"mysql_dsn"`
|
||||
UserMySQLDSN string `yaml:"user_mysql_dsn"`
|
||||
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
|
||||
RoomMySQLDSN string `yaml:"room_mysql_dsn"`
|
||||
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
|
||||
Migrations MigrationConfig `yaml:"migrations"`
|
||||
JWTSecret string `yaml:"jwt_secret"`
|
||||
AccessTokenTTL time.Duration `yaml:"access_token_ttl"`
|
||||
RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl"`
|
||||
CORSAllowedOrigins []string `yaml:"cors_allowed_origins"`
|
||||
RefreshCookieSecure bool `yaml:"refresh_cookie_secure"`
|
||||
Bootstrap BootstrapConfig `yaml:"bootstrap"`
|
||||
BootstrapPassword string `yaml:"bootstrap_password"`
|
||||
UserService UserServiceConfig `yaml:"user_service"`
|
||||
TencentCOS TencentCOSConfig `yaml:"tencent-cos"`
|
||||
Redis RedisConfig `yaml:"redis"`
|
||||
Jobs JobsConfig `yaml:"jobs"`
|
||||
WalletService WalletServiceConfig `yaml:"wallet_service"`
|
||||
RoomService RoomServiceConfig `yaml:"room_service"`
|
||||
ActivityService ActivityServiceConfig `yaml:"activity_service"`
|
||||
GameService GameServiceConfig `yaml:"game_service"`
|
||||
ServiceName string `yaml:"service_name"`
|
||||
NodeID string `yaml:"node_id"`
|
||||
Environment string `yaml:"environment"`
|
||||
Log logging.Config `yaml:"log"`
|
||||
HTTPAddr string `yaml:"http_addr"`
|
||||
MySQLDSN string `yaml:"mysql_dsn"`
|
||||
UserMySQLDSN string `yaml:"user_mysql_dsn"`
|
||||
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
|
||||
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
|
||||
Migrations MigrationConfig `yaml:"migrations"`
|
||||
JWTSecret string `yaml:"jwt_secret"`
|
||||
AccessTokenTTL time.Duration `yaml:"access_token_ttl"`
|
||||
RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl"`
|
||||
CORSAllowedOrigins []string `yaml:"cors_allowed_origins"`
|
||||
RefreshCookieSecure bool `yaml:"refresh_cookie_secure"`
|
||||
Bootstrap BootstrapConfig `yaml:"bootstrap"`
|
||||
BootstrapPassword string `yaml:"bootstrap_password"`
|
||||
UserService UserServiceConfig `yaml:"user_service"`
|
||||
TencentCOS TencentCOSConfig `yaml:"tencent-cos"`
|
||||
Redis RedisConfig `yaml:"redis"`
|
||||
Jobs JobsConfig `yaml:"jobs"`
|
||||
WalletService WalletServiceConfig `yaml:"wallet_service"`
|
||||
RoomService RoomServiceConfig `yaml:"room_service"`
|
||||
ActivityService ActivityServiceConfig `yaml:"activity_service"`
|
||||
GameService GameServiceConfig `yaml:"game_service"`
|
||||
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
|
||||
}
|
||||
|
||||
type MigrationConfig struct {
|
||||
@ -72,6 +72,11 @@ type GameServiceConfig struct {
|
||||
RequestTimeout time.Duration `yaml:"request_timeout"`
|
||||
}
|
||||
|
||||
type StatisticsServiceConfig struct {
|
||||
BaseURL string `yaml:"base_url"`
|
||||
RequestTimeout time.Duration `yaml:"request_timeout"`
|
||||
}
|
||||
|
||||
type TencentCOSConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
SecretID string `yaml:"secret-id"`
|
||||
@ -117,7 +122,6 @@ func Default() Config {
|
||||
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC",
|
||||
UserMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC",
|
||||
WalletMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC",
|
||||
RoomMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC",
|
||||
MySQLAutoMigrate: true,
|
||||
Migrations: MigrationConfig{
|
||||
Enabled: true,
|
||||
@ -161,6 +165,10 @@ func Default() Config {
|
||||
Addr: "127.0.0.1:13008",
|
||||
RequestTimeout: 3 * time.Second,
|
||||
},
|
||||
StatisticsService: StatisticsServiceConfig{
|
||||
BaseURL: "http://127.0.0.1:13010",
|
||||
RequestTimeout: 3 * time.Second,
|
||||
},
|
||||
TencentCOS: TencentCOSConfig{
|
||||
Enabled: false,
|
||||
ObjectPrefix: "admin",
|
||||
@ -269,6 +277,13 @@ func (cfg *Config) Normalize() {
|
||||
if cfg.GameService.RequestTimeout <= 0 {
|
||||
cfg.GameService.RequestTimeout = 3 * time.Second
|
||||
}
|
||||
cfg.StatisticsService.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.StatisticsService.BaseURL), "/")
|
||||
if cfg.StatisticsService.BaseURL == "" {
|
||||
cfg.StatisticsService.BaseURL = "http://127.0.0.1:13010"
|
||||
}
|
||||
if cfg.StatisticsService.RequestTimeout <= 0 {
|
||||
cfg.StatisticsService.RequestTimeout = 3 * time.Second
|
||||
}
|
||||
cfg.TencentCOS.SecretID = strings.TrimSpace(cfg.TencentCOS.SecretID)
|
||||
cfg.TencentCOS.SecretKey = strings.TrimSpace(cfg.TencentCOS.SecretKey)
|
||||
cfg.TencentCOS.BucketName = strings.TrimSpace(cfg.TencentCOS.BucketName)
|
||||
@ -302,9 +317,6 @@ func (cfg Config) Validate() error {
|
||||
if strings.TrimSpace(cfg.WalletMySQLDSN) == "" {
|
||||
return errors.New("wallet_mysql_dsn is required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.RoomMySQLDSN) == "" {
|
||||
return errors.New("room_mysql_dsn is required")
|
||||
}
|
||||
if cfg.Migrations.Enabled && strings.TrimSpace(cfg.Migrations.Dir) == "" {
|
||||
return errors.New("migrations.dir is required when migrations are enabled")
|
||||
}
|
||||
@ -367,6 +379,12 @@ func (cfg Config) Validate() error {
|
||||
if cfg.GameService.RequestTimeout <= 0 {
|
||||
return errors.New("game_service.request_timeout must be greater than 0")
|
||||
}
|
||||
if strings.TrimSpace(cfg.StatisticsService.BaseURL) == "" {
|
||||
return errors.New("statistics_service.base_url is required")
|
||||
}
|
||||
if cfg.StatisticsService.RequestTimeout <= 0 {
|
||||
return errors.New("statistics_service.request_timeout must be greater than 0")
|
||||
}
|
||||
if cfg.TencentCOS.Enabled {
|
||||
if cfg.TencentCOS.SecretID == "" || cfg.TencentCOS.SecretKey == "" || cfg.TencentCOS.BucketName == "" || cfg.TencentCOS.Region == "" || cfg.TencentCOS.AccessURL == "" {
|
||||
return errors.New("tencent-cos config is incomplete")
|
||||
|
||||
@ -18,12 +18,17 @@ type Client interface {
|
||||
GetRegistrationRewardConfig(ctx context.Context, req *activityv1.GetRegistrationRewardConfigRequest) (*activityv1.GetRegistrationRewardConfigResponse, error)
|
||||
UpdateRegistrationRewardConfig(ctx context.Context, req *activityv1.UpdateRegistrationRewardConfigRequest) (*activityv1.UpdateRegistrationRewardConfigResponse, error)
|
||||
ListRegistrationRewardClaims(ctx context.Context, req *activityv1.ListRegistrationRewardClaimsRequest) (*activityv1.ListRegistrationRewardClaimsResponse, error)
|
||||
GetFirstRechargeRewardConfig(ctx context.Context, req *activityv1.GetFirstRechargeRewardConfigRequest) (*activityv1.GetFirstRechargeRewardConfigResponse, error)
|
||||
UpdateFirstRechargeRewardConfig(ctx context.Context, req *activityv1.UpdateFirstRechargeRewardConfigRequest) (*activityv1.UpdateFirstRechargeRewardConfigResponse, error)
|
||||
ListFirstRechargeRewardClaims(ctx context.Context, req *activityv1.ListFirstRechargeRewardClaimsRequest) (*activityv1.ListFirstRechargeRewardClaimsResponse, error)
|
||||
GetSevenDayCheckInConfig(ctx context.Context, req *activityv1.GetSevenDayCheckInConfigRequest) (*activityv1.GetSevenDayCheckInConfigResponse, error)
|
||||
UpdateSevenDayCheckInConfig(ctx context.Context, req *activityv1.UpdateSevenDayCheckInConfigRequest) (*activityv1.UpdateSevenDayCheckInConfigResponse, error)
|
||||
ListSevenDayCheckInClaims(ctx context.Context, req *activityv1.ListSevenDayCheckInClaimsRequest) (*activityv1.ListSevenDayCheckInClaimsResponse, error)
|
||||
GetLuckyGiftConfig(ctx context.Context, req *activityv1.GetLuckyGiftConfigRequest) (*activityv1.GetLuckyGiftConfigResponse, error)
|
||||
UpsertLuckyGiftConfig(ctx context.Context, req *activityv1.UpsertLuckyGiftConfigRequest) (*activityv1.UpsertLuckyGiftConfigResponse, error)
|
||||
ListLuckyGiftConfigs(ctx context.Context, req *activityv1.ListLuckyGiftConfigsRequest) (*activityv1.ListLuckyGiftConfigsResponse, error)
|
||||
ListLuckyGiftDraws(ctx context.Context, req *activityv1.ListLuckyGiftDrawsRequest) (*activityv1.ListLuckyGiftDrawsResponse, error)
|
||||
GetLuckyGiftDrawSummary(ctx context.Context, req *activityv1.GetLuckyGiftDrawSummaryRequest) (*activityv1.GetLuckyGiftDrawSummaryResponse, error)
|
||||
RemoveRegionBroadcastMember(ctx context.Context, req *activityv1.RemoveRegionBroadcastMemberRequest) (*activityv1.RemoveRegionBroadcastMemberResponse, error)
|
||||
ListLevelConfig(ctx context.Context, req *activityv1.ListLevelConfigRequest) (*activityv1.ListLevelConfigResponse, error)
|
||||
UpsertLevelTrack(ctx context.Context, req *activityv1.UpsertLevelTrackRequest) (*activityv1.UpsertLevelTrackResponse, error)
|
||||
@ -32,24 +37,26 @@ type Client interface {
|
||||
}
|
||||
|
||||
type GRPCClient struct {
|
||||
taskClient activityv1.AdminTaskServiceClient
|
||||
achievementClient activityv1.AdminAchievementServiceClient
|
||||
registrationRewardClient activityv1.AdminRegistrationRewardServiceClient
|
||||
sevenDayCheckInClient activityv1.AdminSevenDayCheckInServiceClient
|
||||
luckyGiftClient activityv1.AdminLuckyGiftServiceClient
|
||||
broadcastClient activityv1.BroadcastServiceClient
|
||||
growthClient activityv1.AdminGrowthLevelServiceClient
|
||||
taskClient activityv1.AdminTaskServiceClient
|
||||
achievementClient activityv1.AdminAchievementServiceClient
|
||||
registrationRewardClient activityv1.AdminRegistrationRewardServiceClient
|
||||
firstRechargeRewardClient activityv1.AdminFirstRechargeRewardServiceClient
|
||||
sevenDayCheckInClient activityv1.AdminSevenDayCheckInServiceClient
|
||||
luckyGiftClient activityv1.AdminLuckyGiftServiceClient
|
||||
broadcastClient activityv1.BroadcastServiceClient
|
||||
growthClient activityv1.AdminGrowthLevelServiceClient
|
||||
}
|
||||
|
||||
func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
|
||||
return &GRPCClient{
|
||||
taskClient: activityv1.NewAdminTaskServiceClient(conn),
|
||||
achievementClient: activityv1.NewAdminAchievementServiceClient(conn),
|
||||
registrationRewardClient: activityv1.NewAdminRegistrationRewardServiceClient(conn),
|
||||
sevenDayCheckInClient: activityv1.NewAdminSevenDayCheckInServiceClient(conn),
|
||||
luckyGiftClient: activityv1.NewAdminLuckyGiftServiceClient(conn),
|
||||
broadcastClient: activityv1.NewBroadcastServiceClient(conn),
|
||||
growthClient: activityv1.NewAdminGrowthLevelServiceClient(conn),
|
||||
taskClient: activityv1.NewAdminTaskServiceClient(conn),
|
||||
achievementClient: activityv1.NewAdminAchievementServiceClient(conn),
|
||||
registrationRewardClient: activityv1.NewAdminRegistrationRewardServiceClient(conn),
|
||||
firstRechargeRewardClient: activityv1.NewAdminFirstRechargeRewardServiceClient(conn),
|
||||
sevenDayCheckInClient: activityv1.NewAdminSevenDayCheckInServiceClient(conn),
|
||||
luckyGiftClient: activityv1.NewAdminLuckyGiftServiceClient(conn),
|
||||
broadcastClient: activityv1.NewBroadcastServiceClient(conn),
|
||||
growthClient: activityv1.NewAdminGrowthLevelServiceClient(conn),
|
||||
}
|
||||
}
|
||||
|
||||
@ -85,6 +92,18 @@ func (c *GRPCClient) ListRegistrationRewardClaims(ctx context.Context, req *acti
|
||||
return c.registrationRewardClient.ListRegistrationRewardClaims(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) GetFirstRechargeRewardConfig(ctx context.Context, req *activityv1.GetFirstRechargeRewardConfigRequest) (*activityv1.GetFirstRechargeRewardConfigResponse, error) {
|
||||
return c.firstRechargeRewardClient.GetFirstRechargeRewardConfig(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) UpdateFirstRechargeRewardConfig(ctx context.Context, req *activityv1.UpdateFirstRechargeRewardConfigRequest) (*activityv1.UpdateFirstRechargeRewardConfigResponse, error) {
|
||||
return c.firstRechargeRewardClient.UpdateFirstRechargeRewardConfig(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) ListFirstRechargeRewardClaims(ctx context.Context, req *activityv1.ListFirstRechargeRewardClaimsRequest) (*activityv1.ListFirstRechargeRewardClaimsResponse, error) {
|
||||
return c.firstRechargeRewardClient.ListFirstRechargeRewardClaims(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) GetSevenDayCheckInConfig(ctx context.Context, req *activityv1.GetSevenDayCheckInConfigRequest) (*activityv1.GetSevenDayCheckInConfigResponse, error) {
|
||||
return c.sevenDayCheckInClient.GetSevenDayCheckInConfig(ctx, req)
|
||||
}
|
||||
@ -105,10 +124,18 @@ func (c *GRPCClient) UpsertLuckyGiftConfig(ctx context.Context, req *activityv1.
|
||||
return c.luckyGiftClient.UpsertLuckyGiftConfig(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) ListLuckyGiftConfigs(ctx context.Context, req *activityv1.ListLuckyGiftConfigsRequest) (*activityv1.ListLuckyGiftConfigsResponse, error) {
|
||||
return c.luckyGiftClient.ListLuckyGiftConfigs(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) ListLuckyGiftDraws(ctx context.Context, req *activityv1.ListLuckyGiftDrawsRequest) (*activityv1.ListLuckyGiftDrawsResponse, error) {
|
||||
return c.luckyGiftClient.ListLuckyGiftDraws(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) GetLuckyGiftDrawSummary(ctx context.Context, req *activityv1.GetLuckyGiftDrawSummaryRequest) (*activityv1.GetLuckyGiftDrawSummaryResponse, error) {
|
||||
return c.luckyGiftClient.GetLuckyGiftDrawSummary(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) RemoveRegionBroadcastMember(ctx context.Context, req *activityv1.RemoveRegionBroadcastMemberRequest) (*activityv1.RemoveRegionBroadcastMemberResponse, error) {
|
||||
return c.broadcastClient.RemoveRegionBroadcastMember(ctx, req)
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ type Client interface {
|
||||
ListCatalog(ctx context.Context, req *gamev1.ListCatalogRequest) (*gamev1.ListCatalogResponse, error)
|
||||
UpsertCatalog(ctx context.Context, req *gamev1.UpsertCatalogRequest) (*gamev1.CatalogResponse, error)
|
||||
SetGameStatus(ctx context.Context, req *gamev1.SetGameStatusRequest) (*gamev1.CatalogResponse, error)
|
||||
DeleteCatalog(ctx context.Context, req *gamev1.DeleteCatalogRequest) (*gamev1.DeleteCatalogResponse, error)
|
||||
}
|
||||
|
||||
type GRPCClient struct {
|
||||
@ -44,3 +45,7 @@ func (c *GRPCClient) UpsertCatalog(ctx context.Context, req *gamev1.UpsertCatalo
|
||||
func (c *GRPCClient) SetGameStatus(ctx context.Context, req *gamev1.SetGameStatusRequest) (*gamev1.CatalogResponse, error) {
|
||||
return c.client.SetGameStatus(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) DeleteCatalog(ctx context.Context, req *gamev1.DeleteCatalogRequest) (*gamev1.DeleteCatalogResponse, error) {
|
||||
return c.client.DeleteCatalog(ctx, req)
|
||||
}
|
||||
|
||||
@ -13,10 +13,200 @@ import (
|
||||
)
|
||||
|
||||
type Client interface {
|
||||
ListRooms(ctx context.Context, req ListRoomsRequest) (*ListRoomsResult, error)
|
||||
GetRoom(ctx context.Context, req GetRoomRequest) (*Room, error)
|
||||
UpdateRoom(ctx context.Context, req UpdateRoomRequest) (*CloseRoomResult, error)
|
||||
DeleteRoom(ctx context.Context, req DeleteRoomRequest) (*CloseRoomResult, error)
|
||||
GetRoomTreasureConfig(ctx context.Context) (RoomTreasureConfig, error)
|
||||
UpdateRoomTreasureConfig(ctx context.Context, req UpdateRoomTreasureConfigRequest) (RoomTreasureConfig, error)
|
||||
GetRoomSeatConfig(ctx context.Context) (RoomSeatConfig, error)
|
||||
UpdateRoomSeatConfig(ctx context.Context, req UpdateRoomSeatConfigRequest) (RoomSeatConfig, error)
|
||||
ListRoomPins(ctx context.Context, req ListRoomPinsRequest) (ListRoomPinsResult, error)
|
||||
CreateRoomPin(ctx context.Context, req CreateRoomPinRequest) (RoomPin, error)
|
||||
CancelRoomPin(ctx context.Context, req CancelRoomPinRequest) (RoomPin, error)
|
||||
CloseRoom(ctx context.Context, req CloseRoomRequest) (*CloseRoomResult, error)
|
||||
ReopenRoom(ctx context.Context, req ReopenRoomRequest) (*CloseRoomResult, error)
|
||||
}
|
||||
|
||||
type ListRoomsRequest struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Keyword string
|
||||
Status string
|
||||
RegionID int64
|
||||
SortBy string
|
||||
SortDirection string
|
||||
}
|
||||
|
||||
type ListRoomsResult struct {
|
||||
Rooms []Room
|
||||
Total int64
|
||||
ServerTimeMs int64
|
||||
}
|
||||
|
||||
type GetRoomRequest struct {
|
||||
RoomID string
|
||||
}
|
||||
|
||||
type UpdateRoomRequest struct {
|
||||
RequestID string
|
||||
RoomID string
|
||||
ActorUserID int64
|
||||
Title *string
|
||||
CoverURL *string
|
||||
Description *string
|
||||
Mode *string
|
||||
Status *string
|
||||
VisibleRegionID *int64
|
||||
CloseReason string
|
||||
AdminID uint64
|
||||
AdminName string
|
||||
}
|
||||
|
||||
type DeleteRoomRequest struct {
|
||||
RequestID string
|
||||
RoomID string
|
||||
ActorUserID int64
|
||||
AdminID uint64
|
||||
AdminName string
|
||||
}
|
||||
|
||||
type Room struct {
|
||||
RoomID string
|
||||
RoomShortID string
|
||||
VisibleRegionID int64
|
||||
OwnerUserID int64
|
||||
Title string
|
||||
CoverURL string
|
||||
Mode string
|
||||
Status string
|
||||
CloseReason string
|
||||
ClosedByAdminID uint64
|
||||
ClosedByAdminName string
|
||||
ClosedAtMS int64
|
||||
Heat int64
|
||||
OnlineCount int32
|
||||
SeatCount int32
|
||||
OccupiedSeatCount int32
|
||||
SortScore int64
|
||||
CreatedAtMS int64
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
type RoomTreasureConfig struct {
|
||||
AppCode string
|
||||
Enabled bool
|
||||
ConfigVersion int64
|
||||
EnergySource string
|
||||
OpenDelayMS int64
|
||||
BroadcastEnabled bool
|
||||
BroadcastScope string
|
||||
BroadcastDelayMS int64
|
||||
RewardStackPolicy string
|
||||
Levels []RoomTreasureLevelConfig
|
||||
GiftEnergyRules []GiftEnergyRuleConfig
|
||||
UpdatedByAdminID int64
|
||||
CreatedAtMS int64
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
type RoomTreasureLevelConfig struct {
|
||||
Level int32
|
||||
EnergyThreshold int64
|
||||
CoverURL string
|
||||
AnimationURL string
|
||||
OpeningAnimationURL string
|
||||
OpenedImageURL string
|
||||
InRoomRewards []RoomTreasureRewardItem
|
||||
Top1Rewards []RoomTreasureRewardItem
|
||||
IgniterRewards []RoomTreasureRewardItem
|
||||
}
|
||||
|
||||
type RoomTreasureRewardItem struct {
|
||||
RewardItemID string
|
||||
ResourceGroupID int64
|
||||
Weight int64
|
||||
DisplayName string
|
||||
IconURL string
|
||||
}
|
||||
|
||||
type GiftEnergyRuleConfig struct {
|
||||
RuleID string
|
||||
GiftID string
|
||||
GiftTypeCode string
|
||||
MultiplierPPM int64
|
||||
FixedEnergy int64
|
||||
Excluded bool
|
||||
}
|
||||
|
||||
type UpdateRoomTreasureConfigRequest struct {
|
||||
Config RoomTreasureConfig
|
||||
AdminID int64
|
||||
}
|
||||
|
||||
type RoomSeatConfig struct {
|
||||
CandidateSeatCounts []int32
|
||||
AllowedSeatCounts []int32
|
||||
DefaultSeatCount int32
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
type UpdateRoomSeatConfigRequest struct {
|
||||
AllowedSeatCounts []int32
|
||||
DefaultSeatCount int32
|
||||
}
|
||||
|
||||
type RoomPinRoom struct {
|
||||
RoomID string
|
||||
RoomShortID string
|
||||
VisibleRegionID int64
|
||||
OwnerUserID int64
|
||||
Title string
|
||||
CoverURL string
|
||||
Status string
|
||||
}
|
||||
|
||||
type RoomPin struct {
|
||||
ID int64
|
||||
VisibleRegionID int64
|
||||
RoomID string
|
||||
Weight int64
|
||||
Status string
|
||||
PinnedAtMS int64
|
||||
ExpiresAtMS int64
|
||||
CancelledAtMS int64
|
||||
CreatedByAdminID uint64
|
||||
CancelledByAdminID uint64
|
||||
CreatedAtMS int64
|
||||
UpdatedAtMS int64
|
||||
Room RoomPinRoom
|
||||
}
|
||||
|
||||
type ListRoomPinsRequest struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Keyword string
|
||||
Status string
|
||||
RegionID int64
|
||||
}
|
||||
|
||||
type ListRoomPinsResult struct {
|
||||
Pins []RoomPin
|
||||
Total int64
|
||||
}
|
||||
|
||||
type CreateRoomPinRequest struct {
|
||||
RoomID string
|
||||
Weight int64
|
||||
DurationDays int64
|
||||
AdminID uint64
|
||||
}
|
||||
|
||||
type CancelRoomPinRequest struct {
|
||||
PinID int64
|
||||
AdminID uint64
|
||||
}
|
||||
|
||||
type CloseRoomRequest struct {
|
||||
RequestID string
|
||||
RoomID string
|
||||
@ -39,11 +229,163 @@ type ReopenRoomRequest struct {
|
||||
}
|
||||
|
||||
type GRPCClient struct {
|
||||
client roomv1.RoomCommandServiceClient
|
||||
client roomv1.RoomCommandServiceClient
|
||||
queryClient roomv1.RoomQueryServiceClient
|
||||
}
|
||||
|
||||
func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
|
||||
return &GRPCClient{client: roomv1.NewRoomCommandServiceClient(conn)}
|
||||
return &GRPCClient{client: roomv1.NewRoomCommandServiceClient(conn), queryClient: roomv1.NewRoomQueryServiceClient(conn)}
|
||||
}
|
||||
|
||||
func (c *GRPCClient) ListRooms(ctx context.Context, req ListRoomsRequest) (*ListRoomsResult, error) {
|
||||
resp, err := c.queryClient.AdminListRooms(ctx, &roomv1.AdminListRoomsRequest{
|
||||
Meta: requestMeta(ctx, "", 0, "admin-list-rooms"),
|
||||
Page: int32(req.Page),
|
||||
PageSize: int32(req.PageSize),
|
||||
Keyword: strings.TrimSpace(req.Keyword),
|
||||
Status: strings.TrimSpace(req.Status),
|
||||
VisibleRegionId: req.RegionID,
|
||||
SortBy: strings.TrimSpace(req.SortBy),
|
||||
SortDirection: strings.TrimSpace(req.SortDirection),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rooms := make([]Room, 0, len(resp.GetRooms()))
|
||||
for _, item := range resp.GetRooms() {
|
||||
rooms = append(rooms, roomFromProto(item))
|
||||
}
|
||||
return &ListRoomsResult{Rooms: rooms, Total: resp.GetTotal(), ServerTimeMs: resp.GetServerTimeMs()}, nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) GetRoom(ctx context.Context, req GetRoomRequest) (*Room, error) {
|
||||
resp, err := c.queryClient.AdminGetRoom(ctx, &roomv1.AdminGetRoomRequest{
|
||||
Meta: requestMeta(ctx, strings.TrimSpace(req.RoomID), 0, "admin-get-room"),
|
||||
RoomId: strings.TrimSpace(req.RoomID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
room := roomFromProto(resp.GetRoom())
|
||||
return &room, nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) UpdateRoom(ctx context.Context, req UpdateRoomRequest) (*CloseRoomResult, error) {
|
||||
resp, err := c.client.AdminUpdateRoom(ctx, &roomv1.AdminUpdateRoomRequest{
|
||||
Meta: requestMeta(ctx, req.RoomID, req.ActorUserID, firstNonEmpty(req.RequestID, "admin-update-room")),
|
||||
Title: req.Title,
|
||||
CoverUrl: req.CoverURL,
|
||||
Description: req.Description,
|
||||
Mode: req.Mode,
|
||||
Status: req.Status,
|
||||
VisibleRegionId: req.VisibleRegionID,
|
||||
CloseReason: strings.TrimSpace(req.CloseReason),
|
||||
AdminId: req.AdminID,
|
||||
AdminName: strings.TrimSpace(req.AdminName),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return closeRoomResultFromCommand(resp.GetResult(), resp.GetRoom()), nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) DeleteRoom(ctx context.Context, req DeleteRoomRequest) (*CloseRoomResult, error) {
|
||||
resp, err := c.client.AdminDeleteRoom(ctx, &roomv1.AdminDeleteRoomRequest{
|
||||
Meta: requestMeta(ctx, req.RoomID, req.ActorUserID, firstNonEmpty(req.RequestID, "admin-delete-room")),
|
||||
AdminId: req.AdminID,
|
||||
AdminName: strings.TrimSpace(req.AdminName),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return closeRoomResultFromCommand(resp.GetResult(), resp.GetRoom()), nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) GetRoomTreasureConfig(ctx context.Context) (RoomTreasureConfig, error) {
|
||||
resp, err := c.queryClient.AdminGetRoomTreasureConfig(ctx, &roomv1.AdminGetRoomTreasureConfigRequest{
|
||||
Meta: requestMeta(ctx, "", 0, "admin-get-room-treasure-config"),
|
||||
})
|
||||
if err != nil {
|
||||
return RoomTreasureConfig{}, err
|
||||
}
|
||||
return roomTreasureConfigFromProto(resp.GetConfig()), nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) UpdateRoomTreasureConfig(ctx context.Context, req UpdateRoomTreasureConfigRequest) (RoomTreasureConfig, error) {
|
||||
resp, err := c.client.AdminUpdateRoomTreasureConfig(ctx, &roomv1.AdminUpdateRoomTreasureConfigRequest{
|
||||
Meta: requestMeta(ctx, "", req.AdminID, "admin-update-room-treasure-config"),
|
||||
Config: roomTreasureConfigToProto(req.Config),
|
||||
AdminId: req.AdminID,
|
||||
})
|
||||
if err != nil {
|
||||
return RoomTreasureConfig{}, err
|
||||
}
|
||||
return roomTreasureConfigFromProto(resp.GetConfig()), nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) GetRoomSeatConfig(ctx context.Context) (RoomSeatConfig, error) {
|
||||
resp, err := c.queryClient.AdminGetRoomSeatConfig(ctx, &roomv1.AdminGetRoomSeatConfigRequest{Meta: requestMeta(ctx, "", 0, "admin-get-room-seat-config")})
|
||||
if err != nil {
|
||||
return RoomSeatConfig{}, err
|
||||
}
|
||||
return roomSeatConfigFromProto(resp.GetConfig()), nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) UpdateRoomSeatConfig(ctx context.Context, req UpdateRoomSeatConfigRequest) (RoomSeatConfig, error) {
|
||||
resp, err := c.client.AdminUpdateRoomSeatConfig(ctx, &roomv1.AdminUpdateRoomSeatConfigRequest{
|
||||
Meta: requestMeta(ctx, "", 0, "admin-update-room-seat-config"),
|
||||
AllowedSeatCounts: req.AllowedSeatCounts,
|
||||
DefaultSeatCount: req.DefaultSeatCount,
|
||||
})
|
||||
if err != nil {
|
||||
return RoomSeatConfig{}, err
|
||||
}
|
||||
return roomSeatConfigFromProto(resp.GetConfig()), nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) ListRoomPins(ctx context.Context, req ListRoomPinsRequest) (ListRoomPinsResult, error) {
|
||||
resp, err := c.queryClient.AdminListRoomPins(ctx, &roomv1.AdminListRoomPinsRequest{
|
||||
Meta: requestMeta(ctx, "", 0, "admin-list-room-pins"),
|
||||
Page: int32(req.Page),
|
||||
PageSize: int32(req.PageSize),
|
||||
Keyword: strings.TrimSpace(req.Keyword),
|
||||
Status: strings.TrimSpace(req.Status),
|
||||
VisibleRegionId: req.RegionID,
|
||||
})
|
||||
if err != nil {
|
||||
return ListRoomPinsResult{}, err
|
||||
}
|
||||
pins := make([]RoomPin, 0, len(resp.GetPins()))
|
||||
for _, pin := range resp.GetPins() {
|
||||
pins = append(pins, roomPinFromProto(pin))
|
||||
}
|
||||
return ListRoomPinsResult{Pins: pins, Total: resp.GetTotal()}, nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) CreateRoomPin(ctx context.Context, req CreateRoomPinRequest) (RoomPin, error) {
|
||||
resp, err := c.client.AdminCreateRoomPin(ctx, &roomv1.AdminCreateRoomPinRequest{
|
||||
Meta: requestMeta(ctx, strings.TrimSpace(req.RoomID), int64(req.AdminID), "admin-create-room-pin"),
|
||||
RoomId: strings.TrimSpace(req.RoomID),
|
||||
Weight: req.Weight,
|
||||
DurationDays: req.DurationDays,
|
||||
AdminId: req.AdminID,
|
||||
})
|
||||
if err != nil {
|
||||
return RoomPin{}, err
|
||||
}
|
||||
return roomPinFromProto(resp.GetPin()), nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) CancelRoomPin(ctx context.Context, req CancelRoomPinRequest) (RoomPin, error) {
|
||||
resp, err := c.client.AdminCancelRoomPin(ctx, &roomv1.AdminCancelRoomPinRequest{
|
||||
Meta: requestMeta(ctx, "", int64(req.AdminID), "admin-cancel-room-pin"),
|
||||
PinId: req.PinID,
|
||||
AdminId: req.AdminID,
|
||||
})
|
||||
if err != nil {
|
||||
return RoomPin{}, err
|
||||
}
|
||||
return roomPinFromProto(resp.GetPin()), nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) CloseRoom(ctx context.Context, req CloseRoomRequest) (*CloseRoomResult, error) {
|
||||
@ -68,28 +410,23 @@ func (c *GRPCClient) setRoomLifecycle(ctx context.Context, rawRequestID string,
|
||||
requestID = fmt.Sprintf("admin-room-lifecycle-%s-%d", roomID, time.Now().UnixMilli())
|
||||
}
|
||||
resp, err := c.client.CloseRoom(ctx, &roomv1.CloseRoomRequest{
|
||||
Meta: &roomv1.RequestMeta{
|
||||
RequestId: requestID,
|
||||
CommandId: commandID(requestID, reason, roomID),
|
||||
ActorUserId: actorUserID,
|
||||
RoomId: roomID,
|
||||
AppCode: appctx.FromContext(ctx),
|
||||
SentAtMs: time.Now().UnixMilli(),
|
||||
},
|
||||
Meta: requestMeta(ctx, roomID, actorUserID, requestID+":"+reason),
|
||||
Reason: reason,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := resp.GetResult()
|
||||
room := resp.GetRoom()
|
||||
return closeRoomResultFromCommand(resp.GetResult(), resp.GetRoom()), nil
|
||||
}
|
||||
|
||||
func closeRoomResultFromCommand(result *roomv1.CommandResult, room *roomv1.RoomSnapshot) *CloseRoomResult {
|
||||
return &CloseRoomResult{
|
||||
Applied: result.GetApplied(),
|
||||
RoomVersion: result.GetRoomVersion(),
|
||||
ServerTimeMs: result.GetServerTimeMs(),
|
||||
RoomStatus: room.GetStatus(),
|
||||
OnlineUserNum: len(room.GetOnlineUsers()),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func commandID(requestID string, reason string, roomID string) string {
|
||||
@ -104,6 +441,229 @@ func commandID(requestID string, reason string, roomID string) string {
|
||||
return value[:128]
|
||||
}
|
||||
|
||||
func requestMeta(ctx context.Context, roomID string, actorUserID int64, seed string) *roomv1.RequestMeta {
|
||||
requestID := strings.TrimSpace(seed)
|
||||
if requestID == "" {
|
||||
requestID = fmt.Sprintf("admin-room-%d", time.Now().UnixMilli())
|
||||
}
|
||||
return &roomv1.RequestMeta{
|
||||
RequestId: requestID,
|
||||
CommandId: commandID(requestID, "admin", roomID),
|
||||
ActorUserId: actorUserID,
|
||||
RoomId: strings.TrimSpace(roomID),
|
||||
AppCode: appctx.FromContext(ctx),
|
||||
SentAtMs: time.Now().UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
func roomFromProto(item *roomv1.AdminRoomListItem) Room {
|
||||
if item == nil {
|
||||
return Room{}
|
||||
}
|
||||
return Room{
|
||||
RoomID: item.GetRoomId(),
|
||||
RoomShortID: item.GetRoomShortId(),
|
||||
VisibleRegionID: item.GetVisibleRegionId(),
|
||||
OwnerUserID: item.GetOwnerUserId(),
|
||||
Title: item.GetTitle(),
|
||||
CoverURL: item.GetCoverUrl(),
|
||||
Mode: item.GetMode(),
|
||||
Status: item.GetStatus(),
|
||||
CloseReason: item.GetCloseReason(),
|
||||
ClosedByAdminID: item.GetClosedByAdminId(),
|
||||
ClosedByAdminName: item.GetClosedByAdminName(),
|
||||
ClosedAtMS: item.GetClosedAtMs(),
|
||||
Heat: item.GetHeat(),
|
||||
OnlineCount: item.GetOnlineCount(),
|
||||
SeatCount: item.GetSeatCount(),
|
||||
OccupiedSeatCount: item.GetOccupiedSeatCount(),
|
||||
SortScore: item.GetSortScore(),
|
||||
CreatedAtMS: item.GetCreatedAtMs(),
|
||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func roomTreasureConfigFromProto(input *roomv1.AdminRoomTreasureConfig) RoomTreasureConfig {
|
||||
if input == nil {
|
||||
return RoomTreasureConfig{}
|
||||
}
|
||||
return RoomTreasureConfig{
|
||||
AppCode: input.GetAppCode(),
|
||||
Enabled: input.GetEnabled(),
|
||||
ConfigVersion: input.GetConfigVersion(),
|
||||
EnergySource: input.GetEnergySource(),
|
||||
OpenDelayMS: input.GetOpenDelayMs(),
|
||||
BroadcastEnabled: input.GetBroadcastEnabled(),
|
||||
BroadcastScope: input.GetBroadcastScope(),
|
||||
BroadcastDelayMS: input.GetBroadcastDelayMs(),
|
||||
RewardStackPolicy: input.GetRewardStackPolicy(),
|
||||
Levels: roomTreasureLevelsFromProto(input.GetLevels()),
|
||||
GiftEnergyRules: roomTreasureEnergyRulesFromProto(input.GetGiftEnergyRules()),
|
||||
UpdatedByAdminID: input.GetUpdatedByAdminId(),
|
||||
CreatedAtMS: input.GetCreatedAtMs(),
|
||||
UpdatedAtMS: input.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func roomSeatConfigFromProto(input *roomv1.AdminRoomSeatConfig) RoomSeatConfig {
|
||||
if input == nil {
|
||||
return RoomSeatConfig{}
|
||||
}
|
||||
return RoomSeatConfig{
|
||||
CandidateSeatCounts: input.GetCandidateSeatCounts(),
|
||||
AllowedSeatCounts: input.GetAllowedSeatCounts(),
|
||||
DefaultSeatCount: input.GetDefaultSeatCount(),
|
||||
UpdatedAtMS: input.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func roomPinFromProto(input *roomv1.AdminRoomPin) RoomPin {
|
||||
if input == nil {
|
||||
return RoomPin{}
|
||||
}
|
||||
room := input.GetRoom()
|
||||
return RoomPin{
|
||||
ID: input.GetId(),
|
||||
VisibleRegionID: input.GetVisibleRegionId(),
|
||||
RoomID: input.GetRoomId(),
|
||||
Weight: input.GetWeight(),
|
||||
Status: input.GetStatus(),
|
||||
PinnedAtMS: input.GetPinnedAtMs(),
|
||||
ExpiresAtMS: input.GetExpiresAtMs(),
|
||||
CancelledAtMS: input.GetCancelledAtMs(),
|
||||
CreatedByAdminID: input.GetCreatedByAdminId(),
|
||||
CancelledByAdminID: input.GetCancelledByAdminId(),
|
||||
CreatedAtMS: input.GetCreatedAtMs(),
|
||||
UpdatedAtMS: input.GetUpdatedAtMs(),
|
||||
Room: RoomPinRoom{
|
||||
RoomID: room.GetRoomId(),
|
||||
RoomShortID: room.GetRoomShortId(),
|
||||
VisibleRegionID: room.GetVisibleRegionId(),
|
||||
OwnerUserID: room.GetOwnerUserId(),
|
||||
Title: room.GetTitle(),
|
||||
CoverURL: room.GetCoverUrl(),
|
||||
Status: room.GetStatus(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func roomTreasureConfigToProto(input RoomTreasureConfig) *roomv1.AdminRoomTreasureConfig {
|
||||
return &roomv1.AdminRoomTreasureConfig{
|
||||
Enabled: input.Enabled,
|
||||
EnergySource: input.EnergySource,
|
||||
OpenDelayMs: input.OpenDelayMS,
|
||||
BroadcastEnabled: input.BroadcastEnabled,
|
||||
BroadcastScope: input.BroadcastScope,
|
||||
BroadcastDelayMs: input.BroadcastDelayMS,
|
||||
RewardStackPolicy: input.RewardStackPolicy,
|
||||
Levels: roomTreasureLevelsToProto(input.Levels),
|
||||
GiftEnergyRules: roomTreasureEnergyRulesToProto(input.GiftEnergyRules),
|
||||
}
|
||||
}
|
||||
|
||||
func roomTreasureLevelsFromProto(input []*roomv1.RoomTreasureLevel) []RoomTreasureLevelConfig {
|
||||
out := make([]RoomTreasureLevelConfig, 0, len(input))
|
||||
for _, level := range input {
|
||||
if level == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, RoomTreasureLevelConfig{
|
||||
Level: level.GetLevel(),
|
||||
EnergyThreshold: level.GetEnergyThreshold(),
|
||||
CoverURL: level.GetCoverUrl(),
|
||||
AnimationURL: level.GetAnimationUrl(),
|
||||
OpeningAnimationURL: level.GetOpeningAnimationUrl(),
|
||||
OpenedImageURL: level.GetOpenedImageUrl(),
|
||||
InRoomRewards: roomTreasureRewardsFromProto(level.GetInRoomRewards()),
|
||||
Top1Rewards: roomTreasureRewardsFromProto(level.GetTop1Rewards()),
|
||||
IgniterRewards: roomTreasureRewardsFromProto(level.GetIgniterRewards()),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func roomTreasureLevelsToProto(input []RoomTreasureLevelConfig) []*roomv1.RoomTreasureLevel {
|
||||
out := make([]*roomv1.RoomTreasureLevel, 0, len(input))
|
||||
for _, level := range input {
|
||||
out = append(out, &roomv1.RoomTreasureLevel{
|
||||
Level: level.Level,
|
||||
EnergyThreshold: level.EnergyThreshold,
|
||||
CoverUrl: level.CoverURL,
|
||||
AnimationUrl: level.AnimationURL,
|
||||
OpeningAnimationUrl: level.OpeningAnimationURL,
|
||||
OpenedImageUrl: level.OpenedImageURL,
|
||||
InRoomRewards: roomTreasureRewardsToProto(level.InRoomRewards),
|
||||
Top1Rewards: roomTreasureRewardsToProto(level.Top1Rewards),
|
||||
IgniterRewards: roomTreasureRewardsToProto(level.IgniterRewards),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func roomTreasureRewardsFromProto(input []*roomv1.RoomTreasureRewardItem) []RoomTreasureRewardItem {
|
||||
out := make([]RoomTreasureRewardItem, 0, len(input))
|
||||
for _, reward := range input {
|
||||
if reward == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, RoomTreasureRewardItem{
|
||||
RewardItemID: reward.GetRewardItemId(),
|
||||
ResourceGroupID: reward.GetResourceGroupId(),
|
||||
Weight: reward.GetWeight(),
|
||||
DisplayName: reward.GetDisplayName(),
|
||||
IconURL: reward.GetIconUrl(),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func roomTreasureRewardsToProto(input []RoomTreasureRewardItem) []*roomv1.RoomTreasureRewardItem {
|
||||
out := make([]*roomv1.RoomTreasureRewardItem, 0, len(input))
|
||||
for _, reward := range input {
|
||||
out = append(out, &roomv1.RoomTreasureRewardItem{
|
||||
RewardItemId: reward.RewardItemID,
|
||||
ResourceGroupId: reward.ResourceGroupID,
|
||||
Weight: reward.Weight,
|
||||
DisplayName: reward.DisplayName,
|
||||
IconUrl: reward.IconURL,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func roomTreasureEnergyRulesFromProto(input []*roomv1.RoomTreasureGiftEnergyRule) []GiftEnergyRuleConfig {
|
||||
out := make([]GiftEnergyRuleConfig, 0, len(input))
|
||||
for _, rule := range input {
|
||||
if rule == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, GiftEnergyRuleConfig{
|
||||
RuleID: rule.GetRuleId(),
|
||||
GiftID: rule.GetGiftId(),
|
||||
GiftTypeCode: rule.GetGiftTypeCode(),
|
||||
MultiplierPPM: rule.GetMultiplierPpm(),
|
||||
FixedEnergy: rule.GetFixedEnergy(),
|
||||
Excluded: rule.GetExcluded(),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func roomTreasureEnergyRulesToProto(input []GiftEnergyRuleConfig) []*roomv1.RoomTreasureGiftEnergyRule {
|
||||
out := make([]*roomv1.RoomTreasureGiftEnergyRule, 0, len(input))
|
||||
for _, rule := range input {
|
||||
out = append(out, &roomv1.RoomTreasureGiftEnergyRule{
|
||||
RuleId: rule.RuleID,
|
||||
GiftId: rule.GiftID,
|
||||
GiftTypeCode: rule.GiftTypeCode,
|
||||
MultiplierPpm: rule.MultiplierPPM,
|
||||
FixedEnergy: rule.FixedEnergy,
|
||||
Excluded: rule.Excluded,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
|
||||
@ -36,6 +36,13 @@ type Client interface {
|
||||
CreateRechargeProduct(ctx context.Context, req *walletv1.CreateRechargeProductRequest) (*walletv1.RechargeProductResponse, error)
|
||||
UpdateRechargeProduct(ctx context.Context, req *walletv1.UpdateRechargeProductRequest) (*walletv1.RechargeProductResponse, error)
|
||||
DeleteRechargeProduct(ctx context.Context, req *walletv1.DeleteRechargeProductRequest) (*walletv1.DeleteRechargeProductResponse, error)
|
||||
ListAdminVipLevels(ctx context.Context, req *walletv1.ListAdminVipLevelsRequest) (*walletv1.ListAdminVipLevelsResponse, error)
|
||||
UpdateAdminVipLevels(ctx context.Context, req *walletv1.UpdateAdminVipLevelsRequest) (*walletv1.UpdateAdminVipLevelsResponse, error)
|
||||
GrantVip(ctx context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error)
|
||||
GetRedPacketConfig(ctx context.Context, req *walletv1.GetRedPacketConfigRequest) (*walletv1.GetRedPacketConfigResponse, error)
|
||||
UpdateRedPacketConfig(ctx context.Context, req *walletv1.UpdateRedPacketConfigRequest) (*walletv1.UpdateRedPacketConfigResponse, error)
|
||||
ListRedPackets(ctx context.Context, req *walletv1.ListRedPacketsRequest) (*walletv1.ListRedPacketsResponse, error)
|
||||
GetRedPacket(ctx context.Context, req *walletv1.GetRedPacketRequest) (*walletv1.GetRedPacketResponse, error)
|
||||
}
|
||||
|
||||
type GRPCClient struct {
|
||||
@ -149,3 +156,31 @@ func (c *GRPCClient) UpdateRechargeProduct(ctx context.Context, req *walletv1.Up
|
||||
func (c *GRPCClient) DeleteRechargeProduct(ctx context.Context, req *walletv1.DeleteRechargeProductRequest) (*walletv1.DeleteRechargeProductResponse, error) {
|
||||
return c.client.DeleteRechargeProduct(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) ListAdminVipLevels(ctx context.Context, req *walletv1.ListAdminVipLevelsRequest) (*walletv1.ListAdminVipLevelsResponse, error) {
|
||||
return c.client.ListAdminVipLevels(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) UpdateAdminVipLevels(ctx context.Context, req *walletv1.UpdateAdminVipLevelsRequest) (*walletv1.UpdateAdminVipLevelsResponse, error) {
|
||||
return c.client.UpdateAdminVipLevels(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) GrantVip(ctx context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error) {
|
||||
return c.client.GrantVip(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) GetRedPacketConfig(ctx context.Context, req *walletv1.GetRedPacketConfigRequest) (*walletv1.GetRedPacketConfigResponse, error) {
|
||||
return c.client.GetRedPacketConfig(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) UpdateRedPacketConfig(ctx context.Context, req *walletv1.UpdateRedPacketConfigRequest) (*walletv1.UpdateRedPacketConfigResponse, error) {
|
||||
return c.client.UpdateRedPacketConfig(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) ListRedPackets(ctx context.Context, req *walletv1.ListRedPacketsRequest) (*walletv1.ListRedPacketsResponse, error) {
|
||||
return c.client.ListRedPackets(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) GetRedPacket(ctx context.Context, req *walletv1.GetRedPacketRequest) (*walletv1.GetRedPacketResponse, error) {
|
||||
return c.client.GetRedPacket(ctx, req)
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hyapp-admin-server/internal/config"
|
||||
"hyapp-admin-server/internal/repository"
|
||||
"hyapp-admin-server/internal/response"
|
||||
)
|
||||
@ -10,8 +13,8 @@ type Handler struct {
|
||||
service *DashboardService
|
||||
}
|
||||
|
||||
func New(store *repository.Store) *Handler {
|
||||
return &Handler{service: NewService(store)}
|
||||
func New(store *repository.Store, cfg config.Config) *Handler {
|
||||
return &Handler{service: NewService(store, cfg)}
|
||||
}
|
||||
|
||||
func (h *Handler) DashboardOverview(c *gin.Context) {
|
||||
@ -22,3 +25,22 @@ func (h *Handler) DashboardOverview(c *gin.Context) {
|
||||
}
|
||||
response.OK(c, overview)
|
||||
}
|
||||
|
||||
func (h *Handler) StatisticsOverview(c *gin.Context) {
|
||||
overview, err := h.service.StatisticsOverview(c.Request.Context(), StatisticsQuery{
|
||||
AppCode: c.DefaultQuery("app_code", "lalu"),
|
||||
StartMS: parseInt64(c.Query("start_ms")),
|
||||
EndMS: parseInt64(c.Query("end_ms")),
|
||||
CountryID: parseInt64(c.Query("country_id")),
|
||||
})
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取统计总览失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, overview)
|
||||
}
|
||||
|
||||
func parseInt64(value string) int64 {
|
||||
parsed, _ := strconv.ParseInt(value, 10, 64)
|
||||
return parsed
|
||||
}
|
||||
|
||||
@ -8,4 +8,5 @@ import (
|
||||
|
||||
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
protected.GET("/dashboard/overview", middleware.RequirePermission("overview:view"), h.DashboardOverview)
|
||||
protected.GET("/statistics/overview", middleware.RequirePermission("overview:view"), h.StatisticsOverview)
|
||||
}
|
||||
|
||||
@ -1,15 +1,70 @@
|
||||
package dashboard
|
||||
|
||||
import "hyapp-admin-server/internal/repository"
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"hyapp-admin-server/internal/config"
|
||||
"hyapp-admin-server/internal/repository"
|
||||
)
|
||||
|
||||
type DashboardService struct {
|
||||
store *repository.Store
|
||||
store *repository.Store
|
||||
cfg config.Config
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewService(store *repository.Store) *DashboardService {
|
||||
return &DashboardService{store: store}
|
||||
type StatisticsQuery struct {
|
||||
AppCode string
|
||||
StartMS int64
|
||||
EndMS int64
|
||||
CountryID int64
|
||||
}
|
||||
|
||||
func NewService(store *repository.Store, cfg config.Config) *DashboardService {
|
||||
return &DashboardService{
|
||||
store: store,
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{Timeout: cfg.StatisticsService.RequestTimeout},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DashboardService) Overview() (*repository.DashboardOverview, error) {
|
||||
return s.store.DashboardOverview()
|
||||
}
|
||||
|
||||
func (s *DashboardService) StatisticsOverview(ctx context.Context, query StatisticsQuery) (map[string]any, error) {
|
||||
baseURL := s.cfg.StatisticsService.BaseURL + "/internal/v1/statistics/overview"
|
||||
values := url.Values{}
|
||||
values.Set("app_code", query.AppCode)
|
||||
if query.StartMS > 0 {
|
||||
values.Set("start_ms", strconv.FormatInt(query.StartMS, 10))
|
||||
}
|
||||
if query.EndMS > 0 {
|
||||
values.Set("end_ms", strconv.FormatInt(query.EndMS, 10))
|
||||
}
|
||||
if query.CountryID > 0 {
|
||||
values.Set("country_id", strconv.FormatInt(query.CountryID, 10))
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"?"+values.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("statistics service returned status %d", resp.StatusCode)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
348
server/admin/internal/modules/firstrechargereward/handler.go
Normal file
348
server/admin/internal/modules/firstrechargereward/handler.go
Normal file
@ -0,0 +1,348 @@
|
||||
package firstrechargereward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/integration/activityclient"
|
||||
"hyapp-admin-server/internal/middleware"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
"hyapp-admin-server/internal/response"
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
activity activityclient.Client
|
||||
userDB *sql.DB
|
||||
audit shared.OperationLogger
|
||||
}
|
||||
|
||||
func New(activity activityclient.Client, userDB *sql.DB, audit shared.OperationLogger) *Handler {
|
||||
return &Handler{activity: activity, userDB: userDB, audit: audit}
|
||||
}
|
||||
|
||||
type configRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Tiers []tierDTO `json:"tiers"`
|
||||
}
|
||||
|
||||
type configDTO struct {
|
||||
AppCode string `json:"app_code"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Tiers []tierDTO `json:"tiers"`
|
||||
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
type tierDTO struct {
|
||||
TierID int64 `json:"tier_id"`
|
||||
TierCode string `json:"tier_code"`
|
||||
TierName string `json:"tier_name"`
|
||||
MinCoinAmount int64 `json:"min_coin_amount"`
|
||||
MaxCoinAmount int64 `json:"max_coin_amount"`
|
||||
ResourceGroupID int64 `json:"resource_group_id"`
|
||||
Status string `json:"status"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
type claimDTO struct {
|
||||
ClaimID string `json:"claim_id"`
|
||||
EventID string `json:"event_id"`
|
||||
TransactionID string `json:"transaction_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
User *userDTO `json:"user,omitempty"`
|
||||
TierID int64 `json:"tier_id"`
|
||||
TierCode string `json:"tier_code"`
|
||||
ResourceGroupID int64 `json:"resource_group_id"`
|
||||
RechargeCoinAmount int64 `json:"recharge_coin_amount"`
|
||||
RechargeSequence int64 `json:"recharge_sequence"`
|
||||
RechargeType string `json:"recharge_type"`
|
||||
Status string `json:"status"`
|
||||
WalletCommandID string `json:"wallet_command_id"`
|
||||
WalletGrantID string `json:"wallet_grant_id"`
|
||||
FailureReason string `json:"failure_reason"`
|
||||
RechargedAtMS int64 `json:"recharged_at_ms"`
|
||||
GrantedAtMS int64 `json:"granted_at_ms"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
type userDTO struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
DisplayUserID string `json:"display_user_id"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
func (h *Handler) GetConfig(c *gin.Context) {
|
||||
resp, err := h.activity.GetFirstRechargeRewardConfig(c.Request.Context(), &activityv1.GetFirstRechargeRewardConfigRequest{Meta: h.meta(c)})
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取首冲奖励配置失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, configFromProto(resp.GetConfig()))
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateConfig(c *gin.Context) {
|
||||
var req configRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "首冲奖励配置参数不正确")
|
||||
return
|
||||
}
|
||||
tiers := make([]*activityv1.FirstRechargeRewardTier, 0, len(req.Tiers))
|
||||
for _, tier := range req.Tiers {
|
||||
tiers = append(tiers, &activityv1.FirstRechargeRewardTier{
|
||||
TierId: tier.TierID,
|
||||
TierCode: strings.TrimSpace(tier.TierCode),
|
||||
TierName: strings.TrimSpace(tier.TierName),
|
||||
MinCoinAmount: tier.MinCoinAmount,
|
||||
MaxCoinAmount: tier.MaxCoinAmount,
|
||||
ResourceGroupId: tier.ResourceGroupID,
|
||||
Status: strings.TrimSpace(tier.Status),
|
||||
SortOrder: tier.SortOrder,
|
||||
})
|
||||
}
|
||||
resp, err := h.activity.UpdateFirstRechargeRewardConfig(c.Request.Context(), &activityv1.UpdateFirstRechargeRewardConfigRequest{
|
||||
Meta: h.meta(c),
|
||||
Enabled: req.Enabled,
|
||||
Tiers: tiers,
|
||||
OperatorAdminId: int64(middleware.CurrentUserID(c)),
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
item := configFromProto(resp.GetConfig())
|
||||
shared.OperationLogWithResourceID(c, h.audit, "update-first-recharge-reward", "first_recharge_reward_configs", item.AppCode, "success", "")
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) ListClaims(c *gin.Context) {
|
||||
options := shared.ListOptions(c)
|
||||
userID, matched, ok := h.resolveClaimUserID(c.Request.Context(), strings.TrimSpace(options.Keyword))
|
||||
if !ok {
|
||||
response.ServerError(c, "查询用户信息失败")
|
||||
return
|
||||
}
|
||||
if options.Keyword != "" && !matched {
|
||||
response.OK(c, response.Page{Items: []claimDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0})
|
||||
return
|
||||
}
|
||||
resp, err := h.activity.ListFirstRechargeRewardClaims(c.Request.Context(), &activityv1.ListFirstRechargeRewardClaimsRequest{
|
||||
Meta: h.meta(c),
|
||||
Status: strings.TrimSpace(options.Status),
|
||||
UserId: userID,
|
||||
Page: int32(options.Page),
|
||||
PageSize: int32(options.PageSize),
|
||||
})
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取首冲奖励发放记录失败")
|
||||
return
|
||||
}
|
||||
items := make([]claimDTO, 0, len(resp.GetClaims()))
|
||||
for _, claim := range resp.GetClaims() {
|
||||
items = append(items, claimFromProto(claim))
|
||||
}
|
||||
if err := h.fillClaimUsers(c.Request.Context(), items); err != nil {
|
||||
response.ServerError(c, "补全用户信息失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
|
||||
}
|
||||
|
||||
func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta {
|
||||
return &activityv1.RequestMeta{
|
||||
RequestId: middleware.CurrentRequestID(c),
|
||||
Caller: "admin-server",
|
||||
AppCode: appctx.FromContext(c.Request.Context()),
|
||||
SentAtMs: time.Now().UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) resolveClaimUserID(ctx context.Context, keyword string) (int64, bool, bool) {
|
||||
if keyword == "" {
|
||||
return 0, false, true
|
||||
}
|
||||
if h.userDB == nil {
|
||||
return 0, false, true
|
||||
}
|
||||
appCode := appctx.FromContext(ctx)
|
||||
rows, err := h.userDB.QueryContext(ctx, `
|
||||
SELECT user_id
|
||||
FROM users
|
||||
WHERE app_code = ?
|
||||
AND (
|
||||
CAST(user_id AS CHAR) = ?
|
||||
OR current_display_user_id = ?
|
||||
OR username LIKE ?
|
||||
)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN CAST(user_id AS CHAR) = ? THEN 0
|
||||
WHEN current_display_user_id = ? THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
user_id DESC
|
||||
LIMIT 1`,
|
||||
appCode, keyword, keyword, "%"+keyword+"%", keyword, keyword,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, false, false
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return 0, false, rows.Err() == nil
|
||||
}
|
||||
var userID int64
|
||||
if err := rows.Scan(&userID); err != nil {
|
||||
return 0, false, false
|
||||
}
|
||||
return userID, true, rows.Err() == nil
|
||||
}
|
||||
|
||||
func (h *Handler) fillClaimUsers(ctx context.Context, claims []claimDTO) error {
|
||||
if h.userDB == nil || len(claims) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := collectClaimUserIDs(claims)
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
args := append([]any{appctx.FromContext(ctx)}, int64Args(ids)...)
|
||||
rows, err := h.userDB.QueryContext(ctx, `
|
||||
SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '')
|
||||
FROM users
|
||||
WHERE app_code = ? AND user_id IN (`+placeholders(len(ids))+`)`,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
users := make(map[int64]userDTO, len(ids))
|
||||
for rows.Next() {
|
||||
var user userDTO
|
||||
if err := rows.Scan(&user.UserID, &user.DisplayUserID, &user.Username, &user.Avatar); err != nil {
|
||||
return err
|
||||
}
|
||||
users[user.UserID] = user
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for index := range claims {
|
||||
if user, ok := users[claims[index].UserID]; ok {
|
||||
claims[index].User = &user
|
||||
continue
|
||||
}
|
||||
claims[index].User = &userDTO{UserID: claims[index].UserID}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func configFromProto(config *activityv1.FirstRechargeRewardConfig) configDTO {
|
||||
if config == nil {
|
||||
return configDTO{Tiers: []tierDTO{}}
|
||||
}
|
||||
tiers := make([]tierDTO, 0, len(config.GetTiers()))
|
||||
for _, tier := range config.GetTiers() {
|
||||
tiers = append(tiers, tierFromProto(tier))
|
||||
}
|
||||
return configDTO{
|
||||
AppCode: config.GetAppCode(),
|
||||
Enabled: config.GetEnabled(),
|
||||
Tiers: tiers,
|
||||
UpdatedByAdminID: config.GetUpdatedByAdminId(),
|
||||
CreatedAtMS: config.GetCreatedAtMs(),
|
||||
UpdatedAtMS: config.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func tierFromProto(tier *activityv1.FirstRechargeRewardTier) tierDTO {
|
||||
if tier == nil {
|
||||
return tierDTO{}
|
||||
}
|
||||
return tierDTO{
|
||||
TierID: tier.GetTierId(),
|
||||
TierCode: tier.GetTierCode(),
|
||||
TierName: tier.GetTierName(),
|
||||
MinCoinAmount: tier.GetMinCoinAmount(),
|
||||
MaxCoinAmount: tier.GetMaxCoinAmount(),
|
||||
ResourceGroupID: tier.GetResourceGroupId(),
|
||||
Status: tier.GetStatus(),
|
||||
SortOrder: tier.GetSortOrder(),
|
||||
CreatedAtMS: tier.GetCreatedAtMs(),
|
||||
UpdatedAtMS: tier.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func claimFromProto(claim *activityv1.FirstRechargeRewardClaim) claimDTO {
|
||||
if claim == nil {
|
||||
return claimDTO{}
|
||||
}
|
||||
return claimDTO{
|
||||
ClaimID: claim.GetClaimId(),
|
||||
EventID: claim.GetEventId(),
|
||||
TransactionID: claim.GetTransactionId(),
|
||||
CommandID: claim.GetCommandId(),
|
||||
UserID: claim.GetUserId(),
|
||||
TierID: claim.GetTierId(),
|
||||
TierCode: claim.GetTierCode(),
|
||||
ResourceGroupID: claim.GetResourceGroupId(),
|
||||
RechargeCoinAmount: claim.GetRechargeCoinAmount(),
|
||||
RechargeSequence: claim.GetRechargeSequence(),
|
||||
RechargeType: claim.GetRechargeType(),
|
||||
Status: claim.GetStatus(),
|
||||
WalletCommandID: claim.GetWalletCommandId(),
|
||||
WalletGrantID: claim.GetWalletGrantId(),
|
||||
FailureReason: claim.GetFailureReason(),
|
||||
RechargedAtMS: claim.GetRechargedAtMs(),
|
||||
GrantedAtMS: claim.GetGrantedAtMs(),
|
||||
CreatedAtMS: claim.GetCreatedAtMs(),
|
||||
UpdatedAtMS: claim.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func collectClaimUserIDs(claims []claimDTO) []int64 {
|
||||
seen := make(map[int64]struct{}, len(claims))
|
||||
ids := make([]int64, 0, len(claims))
|
||||
for _, claim := range claims {
|
||||
if claim.UserID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[claim.UserID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[claim.UserID] = struct{}{}
|
||||
ids = append(ids, claim.UserID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func int64Args(ids []int64) []any {
|
||||
args := make([]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
args = append(args, id)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func placeholders(count int) string {
|
||||
if count <= 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, count)
|
||||
for i := range parts {
|
||||
parts[i] = "?"
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
16
server/admin/internal/modules/firstrechargereward/routes.go
Normal file
16
server/admin/internal/modules/firstrechargereward/routes.go
Normal file
@ -0,0 +1,16 @@
|
||||
package firstrechargereward
|
||||
|
||||
import (
|
||||
"hyapp-admin-server/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
protected.GET("/admin/activity/first-recharge-reward/config", middleware.RequirePermission("first-recharge-reward:view"), h.GetConfig)
|
||||
protected.PUT("/admin/activity/first-recharge-reward/config", middleware.RequirePermission("first-recharge-reward:update"), h.UpdateConfig)
|
||||
protected.GET("/admin/activity/first-recharge-reward/claims", middleware.RequirePermission("first-recharge-reward:view"), h.ListClaims)
|
||||
}
|
||||
@ -8,28 +8,38 @@ type platformDTO struct {
|
||||
PlatformName string `json:"platformName"`
|
||||
Status string `json:"status"`
|
||||
APIBaseURL string `json:"apiBaseUrl"`
|
||||
SortOrder int32 `json:"sortOrder"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
// adapterType 决定服务端按哪家厂商协议拼启动 URL 和处理回调。
|
||||
AdapterType string `json:"adapterType"`
|
||||
// callbackSecret 是后台配置页可见的厂商 key/AppSecret;只对有 game:view 权限的管理员返回。
|
||||
CallbackSecret string `json:"callbackSecret"`
|
||||
CallbackSecretSet bool `json:"callbackSecretSet"`
|
||||
CallbackIPWhitelist []string `json:"callbackIpWhitelist"`
|
||||
// adapterConfigJson 承载 uid_mode/default_lang/token_ttl_seconds 等可热更新配置。
|
||||
AdapterConfigJSON string `json:"adapterConfigJson"`
|
||||
SortOrder int32 `json:"sortOrder"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
type catalogDTO struct {
|
||||
AppCode string `json:"appCode"`
|
||||
GameID string `json:"gameId"`
|
||||
PlatformCode string `json:"platformCode"`
|
||||
ProviderGameID string `json:"providerGameId"`
|
||||
GameName string `json:"gameName"`
|
||||
Category string `json:"category"`
|
||||
IconURL string `json:"iconUrl"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
LaunchMode string `json:"launchMode"`
|
||||
Orientation string `json:"orientation"`
|
||||
MinCoin int64 `json:"minCoin"`
|
||||
Status string `json:"status"`
|
||||
SortOrder int32 `json:"sortOrder"`
|
||||
Tags []string `json:"tags"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
AppCode string `json:"appCode"`
|
||||
GameID string `json:"gameId"`
|
||||
PlatformCode string `json:"platformCode"`
|
||||
ProviderGameID string `json:"providerGameId"`
|
||||
GameName string `json:"gameName"`
|
||||
Category string `json:"category"`
|
||||
IconURL string `json:"iconUrl"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
// launchUrl 只在厂商列表预览时返回,正式目录仍由 platform.adapterConfigJson.game_urls 按 providerGameId 解析。
|
||||
LaunchURL string `json:"launchUrl,omitempty"`
|
||||
LaunchMode string `json:"launchMode"`
|
||||
Orientation string `json:"orientation"`
|
||||
MinCoin int64 `json:"minCoin"`
|
||||
Status string `json:"status"`
|
||||
SortOrder int32 `json:"sortOrder"`
|
||||
Tags []string `json:"tags"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
func platformFromProto(item *gamev1.GamePlatform) platformDTO {
|
||||
@ -37,14 +47,19 @@ func platformFromProto(item *gamev1.GamePlatform) platformDTO {
|
||||
return platformDTO{}
|
||||
}
|
||||
return platformDTO{
|
||||
AppCode: item.GetAppCode(),
|
||||
PlatformCode: item.GetPlatformCode(),
|
||||
PlatformName: item.GetPlatformName(),
|
||||
Status: item.GetStatus(),
|
||||
APIBaseURL: item.GetApiBaseUrl(),
|
||||
SortOrder: item.GetSortOrder(),
|
||||
CreatedAtMS: item.GetCreatedAtMs(),
|
||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||
AppCode: item.GetAppCode(),
|
||||
PlatformCode: item.GetPlatformCode(),
|
||||
PlatformName: item.GetPlatformName(),
|
||||
Status: item.GetStatus(),
|
||||
APIBaseURL: item.GetApiBaseUrl(),
|
||||
AdapterType: item.GetAdapterType(),
|
||||
CallbackSecret: item.GetCallbackSecret(),
|
||||
CallbackSecretSet: item.GetCallbackSecretSet(),
|
||||
CallbackIPWhitelist: item.GetCallbackIpWhitelist(),
|
||||
AdapterConfigJSON: item.GetAdapterConfigJson(),
|
||||
SortOrder: item.GetSortOrder(),
|
||||
CreatedAtMS: item.GetCreatedAtMs(),
|
||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
24
server/admin/internal/modules/gamemanagement/dto_test.go
Normal file
24
server/admin/internal/modules/gamemanagement/dto_test.go
Normal file
@ -0,0 +1,24 @@
|
||||
package gamemanagement
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
gamev1 "hyapp.local/api/proto/game/v1"
|
||||
)
|
||||
|
||||
func TestPlatformFromProtoReturnsCallbackSecret(t *testing.T) {
|
||||
const secret = "yomi-or-lingxian-secret"
|
||||
|
||||
got := platformFromProto(&gamev1.GamePlatform{
|
||||
PlatformCode: "yomi",
|
||||
CallbackSecret: secret,
|
||||
CallbackSecretSet: true,
|
||||
})
|
||||
|
||||
if got.CallbackSecret != secret {
|
||||
t.Fatalf("CallbackSecret = %q, want %q", got.CallbackSecret, secret)
|
||||
}
|
||||
if !got.CallbackSecretSet {
|
||||
t.Fatal("CallbackSecretSet = false, want true")
|
||||
}
|
||||
}
|
||||
@ -46,7 +46,7 @@ func (h *Handler) UpdatePlatform(c *gin.Context) {
|
||||
h.upsertPlatform(c, strings.TrimSpace(c.Param("platform_code")))
|
||||
}
|
||||
|
||||
// upsertPlatform 创建或更新第三方游戏平台配置;平台密钥后续走单独的密钥托管接口。
|
||||
// upsertPlatform 创建或更新第三方游戏平台配置;callbackSecret 非空时才会轮换厂商密钥。
|
||||
func (h *Handler) upsertPlatform(c *gin.Context, platformCode string) {
|
||||
var req platformRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@ -146,6 +146,24 @@ func (h *Handler) SetGameStatus(c *gin.Context) {
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
// DeleteCatalog 删除后台游戏目录记录,同时由 game-service 清理展示规则;历史订单和启动会话保留用于审计。
|
||||
func (h *Handler) DeleteCatalog(c *gin.Context) {
|
||||
gameID := strings.TrimSpace(c.Param("game_id"))
|
||||
if gameID == "" {
|
||||
response.BadRequest(c, "游戏 ID 参数不正确")
|
||||
return
|
||||
}
|
||||
if _, err := h.game.DeleteCatalog(c.Request.Context(), &gamev1.DeleteCatalogRequest{
|
||||
Meta: requestMeta(c),
|
||||
GameId: gameID,
|
||||
}); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
h.auditLog(c, "delete-game-catalog", "game_catalog", gameID, "deleted")
|
||||
response.OK(c, gin.H{"gameId": gameID})
|
||||
}
|
||||
|
||||
func (h *Handler) auditLog(c *gin.Context, action string, resource string, resourceID string, detail string) {
|
||||
shared.OperationLogWithResourceID(c, h.audit, action, resource, resourceID, "success", detail)
|
||||
}
|
||||
|
||||
@ -16,7 +16,14 @@ type platformRequest struct {
|
||||
PlatformName string `json:"platformName"`
|
||||
Status string `json:"status"`
|
||||
APIBaseURL string `json:"apiBaseUrl"`
|
||||
SortOrder int32 `json:"sortOrder"`
|
||||
// AdapterType 只允许服务端白名单中的值,避免后台误填导致回调走错协议。
|
||||
AdapterType string `json:"adapterType"`
|
||||
// CallbackSecret 只有提交非空时才覆盖旧密钥,编辑其它字段时前端传空即可。
|
||||
CallbackSecret string `json:"callbackSecret"`
|
||||
CallbackIPWhitelist []string `json:"callbackIpWhitelist"`
|
||||
// AdapterConfigJSON 是厂商扩展配置,保持 JSON 字符串透传给 game-service。
|
||||
AdapterConfigJSON string `json:"adapterConfigJson"`
|
||||
SortOrder int32 `json:"sortOrder"`
|
||||
}
|
||||
|
||||
type catalogRequest struct {
|
||||
@ -44,11 +51,15 @@ func (r platformRequest) toProto(platformCode string) *gamev1.GamePlatform {
|
||||
platformCode = r.PlatformCode
|
||||
}
|
||||
return &gamev1.GamePlatform{
|
||||
PlatformCode: strings.TrimSpace(platformCode),
|
||||
PlatformName: strings.TrimSpace(r.PlatformName),
|
||||
Status: strings.TrimSpace(r.Status),
|
||||
ApiBaseUrl: strings.TrimSpace(r.APIBaseURL),
|
||||
SortOrder: r.SortOrder,
|
||||
PlatformCode: strings.TrimSpace(platformCode),
|
||||
PlatformName: strings.TrimSpace(r.PlatformName),
|
||||
Status: strings.TrimSpace(r.Status),
|
||||
ApiBaseUrl: strings.TrimSpace(r.APIBaseURL),
|
||||
AdapterType: strings.TrimSpace(r.AdapterType),
|
||||
CallbackSecret: strings.TrimSpace(r.CallbackSecret),
|
||||
CallbackIpWhitelist: compactTags(r.CallbackIPWhitelist),
|
||||
AdapterConfigJson: strings.TrimSpace(r.AdapterConfigJSON),
|
||||
SortOrder: r.SortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -14,8 +14,10 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
protected.GET("/admin/game/platforms", middleware.RequirePermission("game:view"), h.ListPlatforms)
|
||||
protected.POST("/admin/game/platforms", middleware.RequirePermission("game:update"), h.CreatePlatform)
|
||||
protected.PATCH("/admin/game/platforms/:platform_code", middleware.RequirePermission("game:update"), h.UpdatePlatform)
|
||||
protected.POST("/admin/game/platforms/:platform_code/sync-games", middleware.RequirePermission("game:update"), h.SyncPlatformGames)
|
||||
protected.GET("/admin/game/games", middleware.RequirePermission("game:view"), h.ListCatalog)
|
||||
protected.POST("/admin/game/games", middleware.RequirePermission("game:create"), h.CreateCatalog)
|
||||
protected.PATCH("/admin/game/games/:game_id", middleware.RequirePermission("game:update"), h.UpdateCatalog)
|
||||
protected.PATCH("/admin/game/games/:game_id/status", middleware.RequirePermission("game:status"), h.SetGameStatus)
|
||||
protected.DELETE("/admin/game/games/:game_id", middleware.RequireAnyPermission("game:delete", "game:update"), h.DeleteCatalog)
|
||||
}
|
||||
|
||||
656
server/admin/internal/modules/gamemanagement/sync_games.go
Normal file
656
server/admin/internal/modules/gamemanagement/sync_games.go
Normal file
@ -0,0 +1,656 @@
|
||||
package gamemanagement
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/response"
|
||||
gamev1 "hyapp.local/api/proto/game/v1"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
adapterBaishunV1 = "baishun_v1"
|
||||
adapterZeeOneV1 = "zeeone_v1"
|
||||
defaultGameStatus = "disabled"
|
||||
defaultGameCategory = "casino"
|
||||
defaultLaunchMode = "full_screen"
|
||||
defaultOrientation = "portrait"
|
||||
)
|
||||
|
||||
type syncGamesRequest struct {
|
||||
DryRun bool `json:"dryRun"`
|
||||
Status string `json:"status"`
|
||||
Category string `json:"category"`
|
||||
LaunchMode string `json:"launchMode"`
|
||||
MinCoin int64 `json:"minCoin"`
|
||||
Tags []string `json:"tags"`
|
||||
GameListType int `json:"gameListType"`
|
||||
ProviderGameIDs []string `json:"providerGameIds"`
|
||||
}
|
||||
|
||||
type providerGameSyncPlan struct {
|
||||
Games []catalogRequest
|
||||
GameURLs map[string]string
|
||||
}
|
||||
|
||||
// SyncPlatformGames 从厂商后台 API 拉取游戏清单,格式化成本地 game_catalog,再由 game-service 落库并补默认展示规则。
|
||||
func (h *Handler) SyncPlatformGames(c *gin.Context) {
|
||||
platformCode := strings.TrimSpace(c.Param("platform_code"))
|
||||
if platformCode == "" {
|
||||
response.BadRequest(c, "平台 Code 参数不正确")
|
||||
return
|
||||
}
|
||||
var req syncGamesRequest
|
||||
if c.Request.ContentLength > 0 {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "同步参数不正确")
|
||||
return
|
||||
}
|
||||
}
|
||||
platform, err := h.findGamePlatform(c.Request.Context(), c, platformCode)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
plan, err := fetchProviderGameSyncPlan(c.Request.Context(), platform, req)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
games := selectProviderGames(plan.Games, req.ProviderGameIDs)
|
||||
items := make([]catalogDTO, 0, len(games))
|
||||
if req.DryRun {
|
||||
for _, game := range games {
|
||||
item := catalogFromProto(game.toProto(""))
|
||||
item.LaunchURL = strings.TrimSpace(plan.GameURLs[game.ProviderGameID])
|
||||
items = append(items, item)
|
||||
}
|
||||
response.OK(c, gin.H{"platformCode": platformCode, "synced": len(items), "dryRun": true, "items": items})
|
||||
return
|
||||
}
|
||||
if len(games) == 0 {
|
||||
response.BadRequest(c, "请选择要添加的游戏")
|
||||
return
|
||||
}
|
||||
adapterConfigJSON := mergeAdapterGameURLs(platform.GetAdapterConfigJson(), selectedGameURLs(plan.GameURLs, games))
|
||||
if strings.TrimSpace(adapterConfigJSON) != "" && strings.TrimSpace(adapterConfigJSON) != strings.TrimSpace(platform.GetAdapterConfigJson()) {
|
||||
if _, err := h.game.UpsertPlatform(c.Request.Context(), &gamev1.UpsertPlatformRequest{
|
||||
Meta: requestMeta(c),
|
||||
Platform: platformWithAdapterConfig(platform, adapterConfigJSON),
|
||||
}); err != nil {
|
||||
response.BadRequest(c, "同步游戏平台配置失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, game := range games {
|
||||
resp, err := h.game.UpsertCatalog(c.Request.Context(), &gamev1.UpsertCatalogRequest{
|
||||
Meta: requestMeta(c),
|
||||
Game: game.toProto(""),
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, "同步游戏目录失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
items = append(items, catalogFromProto(resp.GetGame()))
|
||||
}
|
||||
h.auditLog(c, "sync-provider-games", "game_platforms", platformCode, fmt.Sprintf("synced=%d", len(items)))
|
||||
response.OK(c, gin.H{"platformCode": platformCode, "synced": len(items), "dryRun": false, "items": items})
|
||||
}
|
||||
|
||||
func (h *Handler) findGamePlatform(ctx context.Context, c *gin.Context, platformCode string) (*gamev1.GamePlatform, error) {
|
||||
resp, err := h.game.ListPlatforms(ctx, &gamev1.ListPlatformsRequest{Meta: requestMeta(c)})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取游戏平台失败")
|
||||
}
|
||||
for _, platform := range resp.GetPlatforms() {
|
||||
if strings.EqualFold(strings.TrimSpace(platform.GetPlatformCode()), platformCode) {
|
||||
return platform, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("游戏平台不存在")
|
||||
}
|
||||
|
||||
func fetchProviderGameSyncPlan(ctx context.Context, platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(platform.GetAdapterType())) {
|
||||
case adapterBaishunV1:
|
||||
return fetchBaishunGameSyncPlan(ctx, platform, req)
|
||||
case adapterZeeOneV1:
|
||||
return fetchZeeOneGameSyncPlan(platform, req)
|
||||
default:
|
||||
// 不在配置文件写死游戏:每家厂商新增“列表 API”时只加一个 adapter fetcher,后台入口保持不变。
|
||||
return providerGameSyncPlan{}, fmt.Errorf("当前适配器暂未接入厂商游戏列表 API: %s", platform.GetAdapterType())
|
||||
}
|
||||
}
|
||||
|
||||
func platformWithAdapterConfig(platform *gamev1.GamePlatform, adapterConfigJSON string) *gamev1.GamePlatform {
|
||||
return &gamev1.GamePlatform{
|
||||
AppCode: platform.GetAppCode(),
|
||||
PlatformCode: platform.GetPlatformCode(),
|
||||
PlatformName: platform.GetPlatformName(),
|
||||
Status: platform.GetStatus(),
|
||||
ApiBaseUrl: platform.GetApiBaseUrl(),
|
||||
AdapterType: platform.GetAdapterType(),
|
||||
CallbackSecret: platform.GetCallbackSecret(),
|
||||
CallbackSecretSet: platform.GetCallbackSecretSet(),
|
||||
CallbackIpWhitelist: platform.GetCallbackIpWhitelist(),
|
||||
AdapterConfigJson: adapterConfigJSON,
|
||||
SortOrder: platform.GetSortOrder(),
|
||||
CreatedAtMs: platform.GetCreatedAtMs(),
|
||||
UpdatedAtMs: platform.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
type baishunSyncConfig struct {
|
||||
AppID int64 `json:"app_id"`
|
||||
AppIDCamel int64 `json:"appId"`
|
||||
AppChannel string `json:"app_channel"`
|
||||
AppChannelCamel string `json:"appChannel"`
|
||||
GameListType int `json:"game_list_type"`
|
||||
GameListTypeCamel int `json:"gameListType"`
|
||||
ProviderAPIBaseURL string `json:"provider_api_base_url"`
|
||||
ProviderAPIBaseURLAlt string `json:"providerApiBaseUrl"`
|
||||
GameListURL string `json:"game_list_url"`
|
||||
GameListURLCamel string `json:"gameListUrl"`
|
||||
GameURLs map[string]string `json:"game_urls"`
|
||||
}
|
||||
|
||||
type zeeoneSyncConfig struct {
|
||||
// ZeeOne 测试资料给的是逐游戏 H5 地址,不是列表 API;后台“获取游戏列表”直接把这个映射转换为候选目录。
|
||||
GameURLs map[string]string `json:"game_urls"`
|
||||
GameNames map[string]string `json:"game_names"`
|
||||
GameIcons map[string]string `json:"game_icons"`
|
||||
GameCovers map[string]string `json:"game_covers"`
|
||||
}
|
||||
|
||||
func fetchZeeOneGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) {
|
||||
config, err := decodeZeeOneSyncConfig(platform.GetAdapterConfigJson())
|
||||
if err != nil {
|
||||
return providerGameSyncPlan{}, err
|
||||
}
|
||||
games, gameURLs := zeeoneCatalogItems(platform.GetPlatformCode(), config, req)
|
||||
if len(games) == 0 {
|
||||
return providerGameSyncPlan{}, fmt.Errorf("ZeeOne 游戏列表为空:请在 adapterConfigJson.game_urls 配置游戏 ID 到 H5 URL 的映射")
|
||||
}
|
||||
return providerGameSyncPlan{
|
||||
Games: games,
|
||||
GameURLs: gameURLs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeZeeOneSyncConfig(raw string) (zeeoneSyncConfig, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return zeeoneSyncConfig{}, nil
|
||||
}
|
||||
var config zeeoneSyncConfig
|
||||
if err := json.Unmarshal([]byte(raw), &config); err != nil {
|
||||
return zeeoneSyncConfig{}, fmt.Errorf("ZeeOne 适配器配置 JSON 不合法")
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func zeeoneCatalogItems(platformCode string, config zeeoneSyncConfig, req syncGamesRequest) ([]catalogRequest, map[string]string) {
|
||||
keys := make([]string, 0, len(config.GameURLs))
|
||||
for providerGameID, launchURL := range config.GameURLs {
|
||||
if strings.TrimSpace(providerGameID) != "" && strings.TrimSpace(launchURL) != "" {
|
||||
keys = append(keys, strings.TrimSpace(providerGameID))
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
items := make([]catalogRequest, 0, len(keys))
|
||||
gameURLs := make(map[string]string, len(keys))
|
||||
for index, providerGameID := range keys {
|
||||
launchURL := strings.TrimSpace(config.GameURLs[providerGameID])
|
||||
gameURLs[providerGameID] = launchURL
|
||||
name := firstNonEmpty(config.GameNames[providerGameID], zeeoneGameNameFromURL(launchURL), providerGameID)
|
||||
iconURL := strings.TrimSpace(config.GameIcons[providerGameID])
|
||||
coverURL := firstNonEmpty(config.GameCovers[providerGameID], iconURL)
|
||||
items = append(items, catalogRequest{
|
||||
GameID: stableGameID(platformCode, providerGameID),
|
||||
PlatformCode: strings.TrimSpace(platformCode),
|
||||
ProviderGameID: providerGameID,
|
||||
GameName: name,
|
||||
Category: defaulted(req.Category, defaultGameCategory),
|
||||
IconURL: iconURL,
|
||||
CoverURL: coverURL,
|
||||
LaunchMode: defaulted(req.LaunchMode, defaultLaunchMode),
|
||||
Orientation: defaultOrientation,
|
||||
MinCoin: req.MinCoin,
|
||||
Status: defaulted(req.Status, defaultGameStatus),
|
||||
SortOrder: int32((index + 1) * 10),
|
||||
Tags: compactTags(append([]string{strings.TrimSpace(platformCode), adapterZeeOneV1}, req.Tags...)),
|
||||
})
|
||||
}
|
||||
return items, gameURLs
|
||||
}
|
||||
|
||||
func zeeoneGameNameFromURL(raw string) string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
pathValue := strings.TrimSpace(raw)
|
||||
if err == nil && parsed.Path != "" {
|
||||
pathValue = parsed.Path
|
||||
}
|
||||
parts := strings.Split(strings.Trim(pathValue, "/"), "/")
|
||||
candidate := ""
|
||||
for index := len(parts) - 1; index >= 0; index-- {
|
||||
part := strings.TrimSpace(parts[index])
|
||||
if part == "" || strings.EqualFold(part, "index.html") || strings.EqualFold(part, "index.htm") {
|
||||
continue
|
||||
}
|
||||
candidate = strings.TrimSuffix(strings.TrimSuffix(part, ".html"), ".htm")
|
||||
break
|
||||
}
|
||||
return titleGameName(candidate)
|
||||
}
|
||||
|
||||
func titleGameName(value string) string {
|
||||
tokens := splitGameNameTokens(value)
|
||||
if len(tokens) == 0 {
|
||||
return ""
|
||||
}
|
||||
smallWords := map[string]struct{}{"and": {}, "of": {}, "the": {}}
|
||||
words := make([]string, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
lower := strings.ToLower(token)
|
||||
if _, err := strconv.Atoi(lower); err == nil {
|
||||
continue
|
||||
}
|
||||
if len(words) > 0 {
|
||||
if _, ok := smallWords[lower]; ok {
|
||||
words = append(words, lower)
|
||||
continue
|
||||
}
|
||||
}
|
||||
words = append(words, strings.ToUpper(lower[:1])+lower[1:])
|
||||
}
|
||||
return strings.Join(words, " ")
|
||||
}
|
||||
|
||||
func splitGameNameTokens(value string) []string {
|
||||
var tokens []string
|
||||
var builder strings.Builder
|
||||
var previous rune
|
||||
flush := func() {
|
||||
if builder.Len() > 0 {
|
||||
tokens = append(tokens, builder.String())
|
||||
builder.Reset()
|
||||
}
|
||||
}
|
||||
for _, r := range strings.TrimSpace(value) {
|
||||
if !asciiAlphaNumeric(r) {
|
||||
flush()
|
||||
previous = 0
|
||||
continue
|
||||
}
|
||||
if builder.Len() > 0 && asciiUpper(r) && (asciiLower(previous) || asciiDigit(previous)) {
|
||||
flush()
|
||||
}
|
||||
builder.WriteRune(r)
|
||||
previous = r
|
||||
}
|
||||
flush()
|
||||
return tokens
|
||||
}
|
||||
|
||||
func asciiAlphaNumeric(r rune) bool {
|
||||
return asciiLower(r) || asciiUpper(r) || asciiDigit(r)
|
||||
}
|
||||
|
||||
func asciiLower(r rune) bool {
|
||||
return r >= 'a' && r <= 'z'
|
||||
}
|
||||
|
||||
func asciiUpper(r rune) bool {
|
||||
return r >= 'A' && r <= 'Z'
|
||||
}
|
||||
|
||||
func asciiDigit(r rune) bool {
|
||||
return r >= '0' && r <= '9'
|
||||
}
|
||||
|
||||
func fetchBaishunGameSyncPlan(ctx context.Context, platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) {
|
||||
config, err := decodeBaishunSyncConfig(platform.GetAdapterConfigJson())
|
||||
if err != nil {
|
||||
return providerGameSyncPlan{}, err
|
||||
}
|
||||
appID := config.appID()
|
||||
appChannel := config.appChannel()
|
||||
appKey := strings.TrimSpace(platform.GetCallbackSecret())
|
||||
endpoint := baishunGameListEndpoint(platform.GetApiBaseUrl(), config)
|
||||
if missing := missingBaishunSyncFields(appID, appChannel, appKey, endpoint); len(missing) > 0 {
|
||||
return providerGameSyncPlan{}, fmt.Errorf("百顺列表同步缺少配置: %s", strings.Join(missing, "、"))
|
||||
}
|
||||
body := baishunSignedListRequest(appID, appChannel, baishunGameListType(config, req), appKey)
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return providerGameSyncPlan{}, err
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return providerGameSyncPlan{}, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(httpReq)
|
||||
if err != nil {
|
||||
return providerGameSyncPlan{}, fmt.Errorf("请求百顺游戏列表失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respRaw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return providerGameSyncPlan{}, fmt.Errorf("读取百顺游戏列表失败: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return providerGameSyncPlan{}, fmt.Errorf("百顺游戏列表 HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respRaw)))
|
||||
}
|
||||
var list baishunGameListResponse
|
||||
if err := json.Unmarshal(respRaw, &list); err != nil {
|
||||
return providerGameSyncPlan{}, fmt.Errorf("解析百顺游戏列表失败: %w", err)
|
||||
}
|
||||
if list.Code != 0 {
|
||||
return providerGameSyncPlan{}, fmt.Errorf("百顺游戏列表返回失败: code=%d message=%s", list.Code, firstNonEmpty(list.Message, list.Msg))
|
||||
}
|
||||
games, gameURLs := baishunCatalogItems(platform.GetPlatformCode(), list.Data, req)
|
||||
if len(games) == 0 {
|
||||
return providerGameSyncPlan{}, fmt.Errorf("百顺游戏列表为空")
|
||||
}
|
||||
return providerGameSyncPlan{
|
||||
Games: games,
|
||||
GameURLs: gameURLs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeBaishunSyncConfig(raw string) (baishunSyncConfig, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return baishunSyncConfig{}, nil
|
||||
}
|
||||
var config baishunSyncConfig
|
||||
if err := json.Unmarshal([]byte(raw), &config); err != nil {
|
||||
return baishunSyncConfig{}, fmt.Errorf("百顺适配器配置 JSON 不合法")
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func missingBaishunSyncFields(appID int64, appChannel string, appKey string, endpoint string) []string {
|
||||
missing := make([]string, 0, 4)
|
||||
if appID == 0 {
|
||||
missing = append(missing, "adapterConfigJson.app_id")
|
||||
}
|
||||
if strings.TrimSpace(appChannel) == "" {
|
||||
missing = append(missing, "adapterConfigJson.app_channel")
|
||||
}
|
||||
if strings.TrimSpace(appKey) == "" {
|
||||
missing = append(missing, "回调密钥")
|
||||
}
|
||||
if strings.TrimSpace(endpoint) == "" {
|
||||
missing = append(missing, "adapterConfigJson.game_list_url 或 provider_api_base_url")
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func (c baishunSyncConfig) appID() int64 {
|
||||
if c.AppID != 0 {
|
||||
return c.AppID
|
||||
}
|
||||
return c.AppIDCamel
|
||||
}
|
||||
|
||||
func (c baishunSyncConfig) appChannel() string {
|
||||
return firstNonEmpty(c.AppChannel, c.AppChannelCamel)
|
||||
}
|
||||
|
||||
func baishunGameListType(config baishunSyncConfig, req syncGamesRequest) int {
|
||||
if req.GameListType != 0 {
|
||||
return req.GameListType
|
||||
}
|
||||
if config.GameListType != 0 {
|
||||
return config.GameListType
|
||||
}
|
||||
if config.GameListTypeCamel != 0 {
|
||||
return config.GameListTypeCamel
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func baishunGameListEndpoint(apiBaseURL string, config baishunSyncConfig) string {
|
||||
if endpoint := strings.TrimSpace(firstNonEmpty(config.GameListURL, config.GameListURLCamel)); endpoint != "" {
|
||||
return endpoint
|
||||
}
|
||||
base := strings.TrimSpace(firstNonEmpty(config.ProviderAPIBaseURL, config.ProviderAPIBaseURLAlt, apiBaseURL))
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
||||
base = "https://" + base
|
||||
}
|
||||
if strings.Contains(base, "/v1/api/gamelist") {
|
||||
return base
|
||||
}
|
||||
parsed, err := url.Parse(base)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return ""
|
||||
}
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/") + "/v1/api/gamelist"
|
||||
parsed.RawQuery = ""
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func baishunSignedListRequest(appID int64, appChannel string, gameListType int, appKey string) map[string]any {
|
||||
timestamp := time.Now().Unix()
|
||||
nonce := randomHex(8)
|
||||
signature := baishunMD5(nonce + appKey + strconv.FormatInt(timestamp, 10))
|
||||
return map[string]any{
|
||||
"game_list_type": gameListType,
|
||||
"app_channel": appChannel,
|
||||
"app_id": appID,
|
||||
"signature": signature,
|
||||
"signature_nonce": nonce,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
type baishunGameListResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Msg string `json:"msg"`
|
||||
Data []baishunGameListItem `json:"data"`
|
||||
}
|
||||
|
||||
type baishunGameListItem struct {
|
||||
GameID any `json:"game_id"`
|
||||
Name string `json:"name"`
|
||||
GameName string `json:"game_name"`
|
||||
PreviewURL string `json:"preview_url"`
|
||||
IconURL string `json:"icon_url"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
GameOrientation any `json:"game_orientation"`
|
||||
}
|
||||
|
||||
func baishunCatalogItems(platformCode string, providerItems []baishunGameListItem, req syncGamesRequest) ([]catalogRequest, map[string]string) {
|
||||
items := make([]catalogRequest, 0, len(providerItems))
|
||||
gameURLs := make(map[string]string, len(providerItems))
|
||||
for index, providerItem := range providerItems {
|
||||
providerGameID := stringFromJSONValue(providerItem.GameID)
|
||||
if providerGameID == "" {
|
||||
continue
|
||||
}
|
||||
name := firstNonEmpty(providerItem.Name, providerItem.GameName, providerGameID)
|
||||
iconURL := firstNonEmpty(providerItem.IconURL, providerItem.PreviewURL)
|
||||
coverURL := firstNonEmpty(providerItem.CoverURL, providerItem.PreviewURL, iconURL)
|
||||
gameURLs[providerGameID] = strings.TrimSpace(providerItem.DownloadURL)
|
||||
items = append(items, catalogRequest{
|
||||
GameID: stableGameID(platformCode, providerGameID),
|
||||
PlatformCode: strings.TrimSpace(platformCode),
|
||||
ProviderGameID: providerGameID,
|
||||
GameName: name,
|
||||
Category: defaulted(req.Category, defaultGameCategory),
|
||||
IconURL: iconURL,
|
||||
CoverURL: coverURL,
|
||||
LaunchMode: defaulted(req.LaunchMode, defaultLaunchMode),
|
||||
Orientation: baishunOrientation(providerItem.GameOrientation),
|
||||
MinCoin: req.MinCoin,
|
||||
Status: defaulted(req.Status, defaultGameStatus),
|
||||
SortOrder: int32((index + 1) * 10),
|
||||
Tags: compactTags(append([]string{strings.TrimSpace(platformCode), adapterBaishunV1}, req.Tags...)),
|
||||
})
|
||||
}
|
||||
return items, gameURLs
|
||||
}
|
||||
|
||||
func selectProviderGames(games []catalogRequest, providerGameIDs []string) []catalogRequest {
|
||||
if len(providerGameIDs) == 0 {
|
||||
return games
|
||||
}
|
||||
allowed := make(map[string]struct{}, len(providerGameIDs))
|
||||
for _, id := range providerGameIDs {
|
||||
if strings.TrimSpace(id) != "" {
|
||||
allowed[strings.TrimSpace(id)] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := make([]catalogRequest, 0, len(games))
|
||||
for _, game := range games {
|
||||
if _, ok := allowed[game.ProviderGameID]; ok {
|
||||
out = append(out, game)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func selectedGameURLs(all map[string]string, games []catalogRequest) map[string]string {
|
||||
out := make(map[string]string, len(games))
|
||||
for _, game := range games {
|
||||
if launchURL := strings.TrimSpace(all[game.ProviderGameID]); launchURL != "" {
|
||||
out[game.ProviderGameID] = launchURL
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeAdapterGameURLs(raw string, gameURLs map[string]string) string {
|
||||
config := map[string]any{}
|
||||
if strings.TrimSpace(raw) != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &config)
|
||||
}
|
||||
current := map[string]any{}
|
||||
if existing, ok := config["game_urls"].(map[string]any); ok {
|
||||
for key, value := range existing {
|
||||
current[key] = value
|
||||
}
|
||||
}
|
||||
for providerGameID, launchURL := range gameURLs {
|
||||
if strings.TrimSpace(providerGameID) != "" && strings.TrimSpace(launchURL) != "" {
|
||||
current[providerGameID] = strings.TrimSpace(launchURL)
|
||||
}
|
||||
}
|
||||
config["game_urls"] = current
|
||||
out, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func stableGameID(platformCode string, providerGameID string) string {
|
||||
prefix := asciiIDPart(platformCode)
|
||||
suffix := asciiIDPart(providerGameID)
|
||||
if prefix == "" {
|
||||
prefix = "game"
|
||||
}
|
||||
if suffix == "" {
|
||||
suffix = "unknown"
|
||||
}
|
||||
value := prefix + "_" + suffix
|
||||
if len(value) <= 96 {
|
||||
return value
|
||||
}
|
||||
sum := sha256.Sum256([]byte(platformCode + ":" + providerGameID))
|
||||
return prefix[:min(len(prefix), 32)] + "_" + hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
func asciiIDPart(value string) string {
|
||||
var builder strings.Builder
|
||||
lastUnderscore := false
|
||||
for _, r := range strings.ToLower(strings.TrimSpace(value)) {
|
||||
keep := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
|
||||
if keep {
|
||||
builder.WriteRune(r)
|
||||
lastUnderscore = false
|
||||
continue
|
||||
}
|
||||
if !lastUnderscore {
|
||||
builder.WriteByte('_')
|
||||
lastUnderscore = true
|
||||
}
|
||||
}
|
||||
return strings.Trim(builder.String(), "_")
|
||||
}
|
||||
|
||||
func baishunOrientation(value any) string {
|
||||
switch strings.TrimSpace(stringFromJSONValue(value)) {
|
||||
case "2", "landscape":
|
||||
return "landscape"
|
||||
default:
|
||||
return defaultOrientation
|
||||
}
|
||||
}
|
||||
|
||||
func stringFromJSONValue(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
case float64:
|
||||
if typed == float64(int64(typed)) {
|
||||
return strconv.FormatInt(int64(typed), 10)
|
||||
}
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64)
|
||||
case json.Number:
|
||||
return typed.String()
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprint(typed))
|
||||
}
|
||||
}
|
||||
|
||||
func baishunMD5(value string) string {
|
||||
sum := md5.Sum([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func randomHex(length int) string {
|
||||
buf := make([]byte, length)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return strconv.FormatInt(time.Now().UnixNano(), 16)
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func defaulted(value string, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
134
server/admin/internal/modules/gamemanagement/sync_games_test.go
Normal file
134
server/admin/internal/modules/gamemanagement/sync_games_test.go
Normal file
@ -0,0 +1,134 @@
|
||||
package gamemanagement
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
gamev1 "hyapp.local/api/proto/game/v1"
|
||||
)
|
||||
|
||||
func TestFetchBaishunGameSyncPlanFetchesSignsAndFormatsCatalog(t *testing.T) {
|
||||
const appKey = "baishun-test-key"
|
||||
var receivedPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedPath = r.URL.Path
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
nonce := stringFromJSONValue(body["signature_nonce"])
|
||||
timestamp := stringFromJSONValue(body["timestamp"])
|
||||
if got, want := body["signature"], baishunMD5(nonce+appKey+timestamp); got != want {
|
||||
t.Fatalf("signature = %v, want %s body=%+v", got, want, body)
|
||||
}
|
||||
if body["app_channel"] != "test-channel" || stringFromJSONValue(body["app_id"]) != "21397507" || stringFromJSONValue(body["game_list_type"]) != "1" {
|
||||
t.Fatalf("request body mismatch: %+v", body)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":0,"message":"ok","data":[{"game_id":1006,"name":"Gold of Test","preview_url":"https://cdn.test/1006.png","download_url":"https://h5.test/1006/index.html","game_orientation":2}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
plan, err := fetchBaishunGameSyncPlan(t.Context(), &gamev1.GamePlatform{
|
||||
PlatformCode: "baishun",
|
||||
AdapterType: adapterBaishunV1,
|
||||
CallbackSecret: appKey,
|
||||
AdapterConfigJson: `{
|
||||
"app_id": 21397507,
|
||||
"app_channel": "test-channel",
|
||||
"provider_api_base_url": ` + strconv.Quote(server.URL) + `
|
||||
}`,
|
||||
}, syncGamesRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("fetchBaishunGameSyncPlan failed: %v", err)
|
||||
}
|
||||
if receivedPath != "/v1/api/gamelist" {
|
||||
t.Fatalf("path = %q, want /v1/api/gamelist", receivedPath)
|
||||
}
|
||||
if len(plan.Games) != 1 {
|
||||
t.Fatalf("games len = %d, want 1", len(plan.Games))
|
||||
}
|
||||
game := plan.Games[0]
|
||||
if game.GameID != "baishun_1006" || game.ProviderGameID != "1006" || game.GameName != "Gold of Test" {
|
||||
t.Fatalf("catalog identity mismatch: %+v", game)
|
||||
}
|
||||
if game.IconURL != "https://cdn.test/1006.png" || game.CoverURL != "https://cdn.test/1006.png" || game.Orientation != "landscape" || game.Status != "disabled" || game.LaunchMode != "full_screen" {
|
||||
t.Fatalf("catalog presentation mismatch: %+v", game)
|
||||
}
|
||||
var merged struct {
|
||||
GameURLs map[string]string `json:"game_urls"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(mergeAdapterGameURLs(`{}`, selectedGameURLs(plan.GameURLs, plan.Games))), &merged); err != nil {
|
||||
t.Fatalf("decode merged config: %v", err)
|
||||
}
|
||||
if merged.GameURLs["1006"] != "https://h5.test/1006/index.html" {
|
||||
t.Fatalf("merged game url = %q", merged.GameURLs["1006"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchZeeOneGameSyncPlanReadsConfiguredGameURLs(t *testing.T) {
|
||||
plan, err := fetchProviderGameSyncPlan(t.Context(), &gamev1.GamePlatform{
|
||||
PlatformCode: "zeeone",
|
||||
AdapterType: adapterZeeOneV1,
|
||||
AdapterConfigJson: `{
|
||||
"game_urls": {
|
||||
"1028": "https://dx3kkv99b7clg.cloudfront.net/test/1028_starShinePrincess/index.html",
|
||||
"1021": "https://dx3kkv99b7clg.cloudfront.net/test/1021_gold_of_olympus/index.html"
|
||||
}
|
||||
}`,
|
||||
}, syncGamesRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("fetchProviderGameSyncPlan failed: %v", err)
|
||||
}
|
||||
if len(plan.Games) != 2 {
|
||||
t.Fatalf("games len = %d, want 2", len(plan.Games))
|
||||
}
|
||||
first := plan.Games[0]
|
||||
if first.GameID != "zeeone_1021" || first.ProviderGameID != "1021" || first.GameName != "Gold of Olympus" {
|
||||
t.Fatalf("first game mismatch: %+v", first)
|
||||
}
|
||||
second := plan.Games[1]
|
||||
if second.GameID != "zeeone_1028" || second.ProviderGameID != "1028" || second.GameName != "Star Shine Princess" {
|
||||
t.Fatalf("second game mismatch: %+v", second)
|
||||
}
|
||||
if plan.GameURLs["1021"] != "https://dx3kkv99b7clg.cloudfront.net/test/1021_gold_of_olympus/index.html" {
|
||||
t.Fatalf("game url mismatch: %+v", plan.GameURLs)
|
||||
}
|
||||
if first.Status != "disabled" || first.LaunchMode != "full_screen" || first.Orientation != "portrait" {
|
||||
t.Fatalf("default fields mismatch: %+v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectProviderGamesKeepsOnlySelectedIDs(t *testing.T) {
|
||||
games := []catalogRequest{
|
||||
{ProviderGameID: "1001", GameName: "one"},
|
||||
{ProviderGameID: "1002", GameName: "two"},
|
||||
}
|
||||
|
||||
selected := selectProviderGames(games, []string{"1002"})
|
||||
|
||||
if len(selected) != 1 || selected[0].ProviderGameID != "1002" {
|
||||
t.Fatalf("selected games mismatch: %+v", selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingBaishunSyncFieldsNamesExactInputs(t *testing.T) {
|
||||
missing := missingBaishunSyncFields(0, "", "", "")
|
||||
want := []string{
|
||||
"adapterConfigJson.app_id",
|
||||
"adapterConfigJson.app_channel",
|
||||
"回调密钥",
|
||||
"adapterConfigJson.game_list_url 或 provider_api_base_url",
|
||||
}
|
||||
if len(missing) != len(want) {
|
||||
t.Fatalf("missing len = %d, want %d: %+v", len(missing), len(want), missing)
|
||||
}
|
||||
for index := range want {
|
||||
if missing[index] != want[index] {
|
||||
t.Fatalf("missing[%d] = %q, want %q", index, missing[index], want[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -24,6 +24,7 @@ func New(activity activityclient.Client, audit shared.OperationLogger) *Handler
|
||||
}
|
||||
|
||||
type configRequest struct {
|
||||
PoolID string `json:"pool_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
GiftPrice int64 `json:"gift_price"`
|
||||
TargetRTPPPM int64 `json:"target_rtp_ppm"`
|
||||
@ -55,6 +56,7 @@ type configRequest struct {
|
||||
ActivityBudget int64 `json:"activity_budget"`
|
||||
ActivityDailyLimit int64 `json:"activity_daily_limit"`
|
||||
LargeTierEnabled bool `json:"large_tier_enabled"`
|
||||
MultiplierPPMs []int64 `json:"multiplier_ppms"`
|
||||
Tiers []tierDTO `json:"tiers"`
|
||||
}
|
||||
|
||||
@ -72,6 +74,7 @@ type tierDTO struct {
|
||||
Pool string `json:"pool"`
|
||||
TierID string `json:"tier_id"`
|
||||
RewardCoins int64 `json:"reward_coins"`
|
||||
MultiplierPPM int64 `json:"multiplier_ppm"`
|
||||
Weight int64 `json:"weight"`
|
||||
HighWaterOnly bool `json:"high_water_only"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@ -80,6 +83,7 @@ type tierDTO struct {
|
||||
type drawDTO struct {
|
||||
DrawID string `json:"draw_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
PoolID string `json:"pool_id"`
|
||||
GiftID string `json:"gift_id"`
|
||||
RuleVersion int64 `json:"rule_version"`
|
||||
ExperiencePool string `json:"experience_pool"`
|
||||
@ -99,9 +103,26 @@ type drawDTO struct {
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
}
|
||||
|
||||
type drawSummaryDTO struct {
|
||||
PoolID string `json:"pool_id"`
|
||||
TotalDraws int64 `json:"total_draws"`
|
||||
UniqueUsers int64 `json:"unique_users"`
|
||||
UniqueRooms int64 `json:"unique_rooms"`
|
||||
TotalSpentCoins int64 `json:"total_spent_coins"`
|
||||
TotalRewardCoins int64 `json:"total_reward_coins"`
|
||||
BaseRewardCoins int64 `json:"base_reward_coins"`
|
||||
RoomAtmosphereRewardCoins int64 `json:"room_atmosphere_reward_coins"`
|
||||
ActivitySubsidyCoins int64 `json:"activity_subsidy_coins"`
|
||||
ActualRTPPPM int64 `json:"actual_rtp_ppm"`
|
||||
PendingDraws int64 `json:"pending_draws"`
|
||||
GrantedDraws int64 `json:"granted_draws"`
|
||||
FailedDraws int64 `json:"failed_draws"`
|
||||
}
|
||||
|
||||
func (h *Handler) GetLuckyGiftConfig(c *gin.Context) {
|
||||
resp, err := h.activity.GetLuckyGiftConfig(c.Request.Context(), &activityv1.GetLuckyGiftConfigRequest{
|
||||
Meta: h.meta(c),
|
||||
Meta: h.meta(c),
|
||||
PoolId: strings.TrimSpace(c.Query("pool_id")),
|
||||
})
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取幸运礼物配置失败")
|
||||
@ -116,6 +137,9 @@ func (h *Handler) UpsertLuckyGiftConfig(c *gin.Context) {
|
||||
response.BadRequest(c, "幸运礼物配置参数不正确")
|
||||
return
|
||||
}
|
||||
if req.PoolID == "" {
|
||||
req.PoolID = strings.TrimSpace(c.Query("pool_id"))
|
||||
}
|
||||
resp, err := h.activity.UpsertLuckyGiftConfig(c.Request.Context(), &activityv1.UpsertLuckyGiftConfigRequest{
|
||||
Meta: h.meta(c),
|
||||
Config: configToProto(req),
|
||||
@ -126,14 +150,30 @@ func (h *Handler) UpsertLuckyGiftConfig(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
item := configFromProto(resp.GetConfig())
|
||||
shared.OperationLogWithResourceID(c, h.audit, "upsert-lucky-gift-config", "lucky_gift_rules", "global", "success", "")
|
||||
shared.OperationLogWithResourceID(c, h.audit, "upsert-lucky-gift-config", "lucky_gift_rules", item.PoolID, "success", "")
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) ListLuckyGiftConfigs(c *gin.Context) {
|
||||
resp, err := h.activity.ListLuckyGiftConfigs(c.Request.Context(), &activityv1.ListLuckyGiftConfigsRequest{
|
||||
Meta: h.meta(c),
|
||||
})
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取幸运礼物奖池列表失败")
|
||||
return
|
||||
}
|
||||
items := make([]configDTO, 0, len(resp.GetConfigs()))
|
||||
for _, config := range resp.GetConfigs() {
|
||||
items = append(items, configFromProto(config))
|
||||
}
|
||||
response.OK(c, items)
|
||||
}
|
||||
|
||||
func (h *Handler) ListLuckyGiftDraws(c *gin.Context) {
|
||||
options := shared.ListOptions(c)
|
||||
resp, err := h.activity.ListLuckyGiftDraws(c.Request.Context(), &activityv1.ListLuckyGiftDrawsRequest{
|
||||
Meta: h.meta(c),
|
||||
PoolId: strings.TrimSpace(c.Query("pool_id")),
|
||||
GiftId: strings.TrimSpace(c.Query("gift_id")),
|
||||
UserId: parseOptionalInt64(c.Query("user_id")),
|
||||
RoomId: strings.TrimSpace(c.Query("room_id")),
|
||||
@ -152,6 +192,22 @@ func (h *Handler) ListLuckyGiftDraws(c *gin.Context) {
|
||||
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
|
||||
}
|
||||
|
||||
func (h *Handler) GetLuckyGiftDrawSummary(c *gin.Context) {
|
||||
resp, err := h.activity.GetLuckyGiftDrawSummary(c.Request.Context(), &activityv1.GetLuckyGiftDrawSummaryRequest{
|
||||
Meta: h.meta(c),
|
||||
PoolId: strings.TrimSpace(c.Query("pool_id")),
|
||||
GiftId: strings.TrimSpace(c.Query("gift_id")),
|
||||
UserId: parseOptionalInt64(c.Query("user_id")),
|
||||
RoomId: strings.TrimSpace(c.Query("room_id")),
|
||||
Status: strings.TrimSpace(c.Query("status")),
|
||||
})
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取幸运礼物抽奖汇总失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, drawSummaryFromProto(resp.GetSummary()))
|
||||
}
|
||||
|
||||
func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta {
|
||||
return &activityv1.RequestMeta{
|
||||
RequestId: middleware.CurrentRequestID(c),
|
||||
@ -168,12 +224,14 @@ func configToProto(req configRequest) *activityv1.LuckyGiftConfig {
|
||||
Pool: tier.Pool,
|
||||
TierId: tier.TierID,
|
||||
RewardCoins: tier.RewardCoins,
|
||||
MultiplierPpm: tier.MultiplierPPM,
|
||||
Weight: tier.Weight,
|
||||
HighWaterOnly: tier.HighWaterOnly,
|
||||
Enabled: tier.Enabled,
|
||||
})
|
||||
}
|
||||
return &activityv1.LuckyGiftConfig{
|
||||
PoolId: strings.TrimSpace(req.PoolID),
|
||||
Enabled: req.Enabled,
|
||||
GiftPrice: req.GiftPrice,
|
||||
TargetRtpPpm: req.TargetRTPPPM,
|
||||
@ -205,6 +263,7 @@ func configToProto(req configRequest) *activityv1.LuckyGiftConfig {
|
||||
ActivityBudget: req.ActivityBudget,
|
||||
ActivityDailyLimit: req.ActivityDailyLimit,
|
||||
LargeTierEnabled: req.LargeTierEnabled,
|
||||
MultiplierPpms: append([]int64(nil), req.MultiplierPPMs...),
|
||||
Tiers: tiers,
|
||||
}
|
||||
}
|
||||
@ -219,11 +278,16 @@ func configFromProto(config *activityv1.LuckyGiftConfig) configDTO {
|
||||
Pool: tier.GetPool(),
|
||||
TierID: tier.GetTierId(),
|
||||
RewardCoins: tier.GetRewardCoins(),
|
||||
MultiplierPPM: tier.GetMultiplierPpm(),
|
||||
Weight: tier.GetWeight(),
|
||||
HighWaterOnly: tier.GetHighWaterOnly(),
|
||||
Enabled: tier.GetEnabled(),
|
||||
})
|
||||
}
|
||||
poolID := strings.TrimSpace(config.GetPoolId())
|
||||
if poolID == "" {
|
||||
poolID = strings.TrimSpace(config.GetGiftId())
|
||||
}
|
||||
return configDTO{
|
||||
AppCode: config.GetAppCode(),
|
||||
GiftID: config.GetGiftId(),
|
||||
@ -232,6 +296,7 @@ func configFromProto(config *activityv1.LuckyGiftConfig) configDTO {
|
||||
CreatedAtMS: config.GetCreatedAtMs(),
|
||||
UpdatedAtMS: config.GetUpdatedAtMs(),
|
||||
configRequest: configRequest{
|
||||
PoolID: poolID,
|
||||
Enabled: config.GetEnabled(),
|
||||
GiftPrice: config.GetGiftPrice(),
|
||||
TargetRTPPPM: config.GetTargetRtpPpm(),
|
||||
@ -263,6 +328,7 @@ func configFromProto(config *activityv1.LuckyGiftConfig) configDTO {
|
||||
ActivityBudget: config.GetActivityBudget(),
|
||||
ActivityDailyLimit: config.GetActivityDailyLimit(),
|
||||
LargeTierEnabled: config.GetLargeTierEnabled(),
|
||||
MultiplierPPMs: append([]int64(nil), config.GetMultiplierPpms()...),
|
||||
Tiers: tiers,
|
||||
},
|
||||
}
|
||||
@ -275,6 +341,7 @@ func drawFromProto(draw *activityv1.LuckyGiftDrawResult) drawDTO {
|
||||
return drawDTO{
|
||||
DrawID: draw.GetDrawId(),
|
||||
CommandID: draw.GetCommandId(),
|
||||
PoolID: draw.GetPoolId(),
|
||||
GiftID: draw.GetGiftId(),
|
||||
RuleVersion: draw.GetRuleVersion(),
|
||||
ExperiencePool: draw.GetExperiencePool(),
|
||||
@ -295,6 +362,27 @@ func drawFromProto(draw *activityv1.LuckyGiftDrawResult) drawDTO {
|
||||
}
|
||||
}
|
||||
|
||||
func drawSummaryFromProto(summary *activityv1.LuckyGiftDrawSummary) drawSummaryDTO {
|
||||
if summary == nil {
|
||||
return drawSummaryDTO{}
|
||||
}
|
||||
return drawSummaryDTO{
|
||||
PoolID: summary.GetPoolId(),
|
||||
TotalDraws: summary.GetTotalDraws(),
|
||||
UniqueUsers: summary.GetUniqueUsers(),
|
||||
UniqueRooms: summary.GetUniqueRooms(),
|
||||
TotalSpentCoins: summary.GetTotalSpentCoins(),
|
||||
TotalRewardCoins: summary.GetTotalRewardCoins(),
|
||||
BaseRewardCoins: summary.GetBaseRewardCoins(),
|
||||
RoomAtmosphereRewardCoins: summary.GetRoomAtmosphereRewardCoins(),
|
||||
ActivitySubsidyCoins: summary.GetActivitySubsidyCoins(),
|
||||
ActualRTPPPM: summary.GetActualRtpPpm(),
|
||||
PendingDraws: summary.GetPendingDraws(),
|
||||
GrantedDraws: summary.GetGrantedDraws(),
|
||||
FailedDraws: summary.GetFailedDraws(),
|
||||
}
|
||||
}
|
||||
|
||||
func parseOptionalInt64(value string) int64 {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
|
||||
@ -13,5 +13,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
|
||||
protected.GET("/admin/activity/lucky-gifts/config", middleware.RequirePermission("lucky-gift:view"), h.GetLuckyGiftConfig)
|
||||
protected.PUT("/admin/activity/lucky-gifts/config", middleware.RequirePermission("lucky-gift:update"), h.UpsertLuckyGiftConfig)
|
||||
protected.GET("/admin/activity/lucky-gifts/configs", middleware.RequirePermission("lucky-gift:view"), h.ListLuckyGiftConfigs)
|
||||
protected.GET("/admin/activity/lucky-gifts/draws", middleware.RequirePermission("lucky-gift:view"), h.ListLuckyGiftDraws)
|
||||
protected.GET("/admin/activity/lucky-gifts/draw-summary", middleware.RequirePermission("lucky-gift:view"), h.GetLuckyGiftDrawSummary)
|
||||
}
|
||||
|
||||
254
server/admin/internal/modules/redpacket/handler.go
Normal file
254
server/admin/internal/modules/redpacket/handler.go
Normal file
@ -0,0 +1,254 @@
|
||||
package redpacket
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/integration/walletclient"
|
||||
"hyapp-admin-server/internal/middleware"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
"hyapp-admin-server/internal/response"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
wallet walletclient.Client
|
||||
audit shared.OperationLogger
|
||||
}
|
||||
|
||||
func New(wallet walletclient.Client, audit shared.OperationLogger) *Handler {
|
||||
return &Handler{wallet: wallet, audit: audit}
|
||||
}
|
||||
|
||||
type configRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
CountTiers []int32 `json:"count_tiers"`
|
||||
AmountTiers []int64 `json:"amount_tiers"`
|
||||
DelayedOpenSeconds int32 `json:"delayed_open_seconds"`
|
||||
ExpireSeconds int32 `json:"expire_seconds"`
|
||||
DailySendLimit int32 `json:"daily_send_limit"`
|
||||
}
|
||||
|
||||
type configDTO struct {
|
||||
AppCode string `json:"app_code"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CountTiers []int32 `json:"count_tiers"`
|
||||
AmountTiers []int64 `json:"amount_tiers"`
|
||||
DelayedOpenSeconds int32 `json:"delayed_open_seconds"`
|
||||
ExpireSeconds int32 `json:"expire_seconds"`
|
||||
DailySendLimit int32 `json:"daily_send_limit"`
|
||||
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
type packetDTO struct {
|
||||
AppCode string `json:"app_code"`
|
||||
PacketID string `json:"packet_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
SenderUserID int64 `json:"sender_user_id"`
|
||||
RoomID string `json:"room_id"`
|
||||
RegionID int64 `json:"region_id"`
|
||||
PacketType string `json:"packet_type"`
|
||||
TotalAmount int64 `json:"total_amount"`
|
||||
PacketCount int32 `json:"packet_count"`
|
||||
RemainingAmount int64 `json:"remaining_amount"`
|
||||
RemainingCount int32 `json:"remaining_count"`
|
||||
Status string `json:"status"`
|
||||
OpenAtMS int64 `json:"open_at_ms"`
|
||||
ExpiresAtMS int64 `json:"expires_at_ms"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
Claims []claimDTO `json:"claims,omitempty"`
|
||||
}
|
||||
|
||||
type claimDTO struct {
|
||||
AppCode string `json:"app_code"`
|
||||
ClaimID string `json:"claim_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
PacketID string `json:"packet_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
WalletTransactionID string `json:"wallet_transaction_id"`
|
||||
Status string `json:"status"`
|
||||
FailureReason string `json:"failure_reason"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
func (h *Handler) GetConfig(c *gin.Context) {
|
||||
if h.wallet == nil {
|
||||
response.ServerError(c, "钱包服务未配置")
|
||||
return
|
||||
}
|
||||
resp, err := h.wallet.GetRedPacketConfig(c.Request.Context(), &walletv1.GetRedPacketConfigRequest{
|
||||
AppCode: appctx.FromContext(c.Request.Context()),
|
||||
})
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取红包配置失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, configFromProto(resp.GetConfig()))
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateConfig(c *gin.Context) {
|
||||
if h.wallet == nil {
|
||||
response.ServerError(c, "钱包服务未配置")
|
||||
return
|
||||
}
|
||||
var req configRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "红包配置参数不正确")
|
||||
return
|
||||
}
|
||||
resp, err := h.wallet.UpdateRedPacketConfig(c.Request.Context(), &walletv1.UpdateRedPacketConfigRequest{
|
||||
AppCode: appctx.FromContext(c.Request.Context()),
|
||||
Enabled: req.Enabled,
|
||||
CountTiers: req.CountTiers,
|
||||
AmountTiers: req.AmountTiers,
|
||||
DelayedOpenSeconds: req.DelayedOpenSeconds,
|
||||
ExpireSeconds: req.ExpireSeconds,
|
||||
DailySendLimit: req.DailySendLimit,
|
||||
OperatorUserId: int64(middleware.CurrentUserID(c)),
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
item := configFromProto(resp.GetConfig())
|
||||
shared.OperationLogWithResourceID(c, h.audit, "update-red-packet-config", "red_packet_configs", item.AppCode, "success", "")
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) ListPackets(c *gin.Context) {
|
||||
if h.wallet == nil {
|
||||
response.ServerError(c, "钱包服务未配置")
|
||||
return
|
||||
}
|
||||
options := shared.ListOptions(c)
|
||||
req := &walletv1.ListRedPacketsRequest{
|
||||
AppCode: appctx.FromContext(c.Request.Context()),
|
||||
RoomId: strings.TrimSpace(c.Query("room_id")),
|
||||
PacketType: strings.TrimSpace(c.Query("packet_type")),
|
||||
Status: strings.TrimSpace(options.Status),
|
||||
Page: int32(options.Page),
|
||||
PageSize: int32(options.PageSize),
|
||||
SenderUserId: parseInt64Query(c, "sender_user_id"),
|
||||
RegionId: parseInt64Query(c, "region_id"),
|
||||
StartAtMs: parseInt64Query(c, "start_at_ms"),
|
||||
EndAtMs: parseInt64Query(c, "end_at_ms"),
|
||||
}
|
||||
resp, err := h.wallet.ListRedPackets(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取红包列表失败")
|
||||
return
|
||||
}
|
||||
items := make([]packetDTO, 0, len(resp.GetPackets()))
|
||||
for _, packet := range resp.GetPackets() {
|
||||
items = append(items, packetFromProto(packet))
|
||||
}
|
||||
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
|
||||
}
|
||||
|
||||
func (h *Handler) GetPacket(c *gin.Context) {
|
||||
if h.wallet == nil {
|
||||
response.ServerError(c, "钱包服务未配置")
|
||||
return
|
||||
}
|
||||
packetID := strings.TrimSpace(c.Param("packet_id"))
|
||||
if packetID == "" {
|
||||
response.BadRequest(c, "红包 ID 不正确")
|
||||
return
|
||||
}
|
||||
resp, err := h.wallet.GetRedPacket(c.Request.Context(), &walletv1.GetRedPacketRequest{
|
||||
AppCode: appctx.FromContext(c.Request.Context()),
|
||||
PacketId: packetID,
|
||||
IncludeClaims: true,
|
||||
})
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取红包详情失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, packetFromProto(resp.GetPacket()))
|
||||
}
|
||||
|
||||
func configFromProto(config *walletv1.RedPacketConfig) configDTO {
|
||||
if config == nil {
|
||||
return configDTO{}
|
||||
}
|
||||
return configDTO{
|
||||
AppCode: config.GetAppCode(),
|
||||
Enabled: config.GetEnabled(),
|
||||
CountTiers: append([]int32(nil), config.GetCountTiers()...),
|
||||
AmountTiers: append([]int64(nil), config.GetAmountTiers()...),
|
||||
DelayedOpenSeconds: config.GetDelayedOpenSeconds(),
|
||||
ExpireSeconds: config.GetExpireSeconds(),
|
||||
DailySendLimit: config.GetDailySendLimit(),
|
||||
UpdatedByAdminID: config.GetUpdatedByAdminId(),
|
||||
CreatedAtMS: config.GetCreatedAtMs(),
|
||||
UpdatedAtMS: config.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func packetFromProto(packet *walletv1.RedPacket) packetDTO {
|
||||
if packet == nil {
|
||||
return packetDTO{}
|
||||
}
|
||||
claims := make([]claimDTO, 0, len(packet.GetClaims()))
|
||||
for _, claim := range packet.GetClaims() {
|
||||
claims = append(claims, claimFromProto(claim))
|
||||
}
|
||||
return packetDTO{
|
||||
AppCode: packet.GetAppCode(),
|
||||
PacketID: packet.GetPacketId(),
|
||||
CommandID: packet.GetCommandId(),
|
||||
SenderUserID: packet.GetSenderUserId(),
|
||||
RoomID: packet.GetRoomId(),
|
||||
RegionID: packet.GetRegionId(),
|
||||
PacketType: packet.GetPacketType(),
|
||||
TotalAmount: packet.GetTotalAmount(),
|
||||
PacketCount: packet.GetPacketCount(),
|
||||
RemainingAmount: packet.GetRemainingAmount(),
|
||||
RemainingCount: packet.GetRemainingCount(),
|
||||
Status: packet.GetStatus(),
|
||||
OpenAtMS: packet.GetOpenAtMs(),
|
||||
ExpiresAtMS: packet.GetExpiresAtMs(),
|
||||
CreatedAtMS: packet.GetCreatedAtMs(),
|
||||
UpdatedAtMS: packet.GetUpdatedAtMs(),
|
||||
Claims: claims,
|
||||
}
|
||||
}
|
||||
|
||||
func claimFromProto(claim *walletv1.RedPacketClaim) claimDTO {
|
||||
if claim == nil {
|
||||
return claimDTO{}
|
||||
}
|
||||
return claimDTO{
|
||||
AppCode: claim.GetAppCode(),
|
||||
ClaimID: claim.GetClaimId(),
|
||||
CommandID: claim.GetCommandId(),
|
||||
PacketID: claim.GetPacketId(),
|
||||
UserID: claim.GetUserId(),
|
||||
Amount: claim.GetAmount(),
|
||||
WalletTransactionID: claim.GetWalletTransactionId(),
|
||||
Status: claim.GetStatus(),
|
||||
FailureReason: claim.GetFailureReason(),
|
||||
CreatedAtMS: claim.GetCreatedAtMs(),
|
||||
UpdatedAtMS: claim.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func parseInt64Query(c *gin.Context, key string) int64 {
|
||||
value := strings.TrimSpace(c.Query(key))
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
parsed, _ := strconv.ParseInt(value, 10, 64)
|
||||
if parsed < 0 {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
17
server/admin/internal/modules/redpacket/routes.go
Normal file
17
server/admin/internal/modules/redpacket/routes.go
Normal file
@ -0,0 +1,17 @@
|
||||
package redpacket
|
||||
|
||||
import (
|
||||
"hyapp-admin-server/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
protected.GET("/admin/activity/red-packets/config", middleware.RequirePermission("red-packet:view"), h.GetConfig)
|
||||
protected.PUT("/admin/activity/red-packets/config", middleware.RequirePermission("red-packet:update"), h.UpdateConfig)
|
||||
protected.GET("/admin/activity/red-packets", middleware.RequirePermission("red-packet:view"), h.ListPackets)
|
||||
protected.GET("/admin/activity/red-packets/:packet_id", middleware.RequirePermission("red-packet:view"), h.GetPacket)
|
||||
}
|
||||
@ -14,6 +14,10 @@ type resourceDTO struct {
|
||||
GrantStrategy string `json:"grantStrategy"`
|
||||
WalletAssetType string `json:"walletAssetType"`
|
||||
WalletAssetAmount int64 `json:"walletAssetAmount"`
|
||||
PriceType string `json:"priceType"`
|
||||
CoinPrice int64 `json:"coinPrice"`
|
||||
GiftPointAmount int64 `json:"giftPointAmount"`
|
||||
BadgeForm string `json:"badgeForm,omitempty"`
|
||||
UsageScopes []string `json:"usageScopes"`
|
||||
AssetURL string `json:"assetUrl"`
|
||||
PreviewURL string `json:"previewUrl"`
|
||||
@ -172,6 +176,10 @@ func resourceFromProto(item *walletv1.Resource) resourceDTO {
|
||||
GrantStrategy: item.GetGrantStrategy(),
|
||||
WalletAssetType: item.GetWalletAssetType(),
|
||||
WalletAssetAmount: item.GetWalletAssetAmount(),
|
||||
PriceType: item.GetPriceType(),
|
||||
CoinPrice: item.GetCoinPrice(),
|
||||
GiftPointAmount: item.GetGiftPointAmount(),
|
||||
BadgeForm: badgeFormForResource(item.GetResourceType(), item.GetMetadataJson()),
|
||||
UsageScopes: item.GetUsageScopes(),
|
||||
AssetURL: item.GetAssetUrl(),
|
||||
PreviewURL: item.GetPreviewUrl(),
|
||||
|
||||
@ -74,6 +74,14 @@ func (h *Handler) CreateResource(c *gin.Context) {
|
||||
response.BadRequest(c, "资源参数不正确")
|
||||
return
|
||||
}
|
||||
if err := validateResourcePricing(req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
if err := validateResourceBadgeForm(req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
resp, err := h.wallet.CreateResource(c.Request.Context(), req.createProto(c))
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
@ -170,6 +178,14 @@ func (h *Handler) UpdateResource(c *gin.Context) {
|
||||
response.BadRequest(c, "资源参数不正确")
|
||||
return
|
||||
}
|
||||
if err := validateResourcePricing(req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
if err := validateResourceBadgeForm(req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
protoReq := req.updateProto(c, resourceID)
|
||||
resp, err := h.wallet.UpdateResource(c.Request.Context(), protoReq)
|
||||
if err != nil {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,9 +18,20 @@ const dayMillis int64 = 24 * 60 * 60 * 1000
|
||||
|
||||
const (
|
||||
resourceTypeCoin = "coin"
|
||||
resourceTypeBadge = "badge"
|
||||
resourceTypeEmojiPack = "emoji_pack"
|
||||
)
|
||||
|
||||
const (
|
||||
resourcePriceTypeCoin = "coin"
|
||||
resourcePriceTypeFree = "free"
|
||||
)
|
||||
|
||||
const (
|
||||
badgeFormStrip = "strip"
|
||||
badgeFormTile = "tile"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultEmojiPackCategory = "默认"
|
||||
emojiPackPricingFree = "free"
|
||||
@ -33,6 +44,10 @@ type resourceRequest struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Amount int64 `json:"amount"`
|
||||
PriceType string `json:"priceType"`
|
||||
CoinPrice int64 `json:"coinPrice"`
|
||||
GiftPointAmount int64 `json:"giftPointAmount"`
|
||||
BadgeForm string `json:"badgeForm"`
|
||||
ManagerGrantEnabled *bool `json:"managerGrantEnabled"`
|
||||
AssetURL string `json:"assetUrl"`
|
||||
PreviewURL string `json:"previewUrl"`
|
||||
@ -57,6 +72,11 @@ type emojiPackMetadataPayload struct {
|
||||
RegionScope string `json:"region_scope"`
|
||||
}
|
||||
|
||||
type badgeMetadataPayload struct {
|
||||
BadgeForm string `json:"badge_form"`
|
||||
DefaultSlot string `json:"default_slot,omitempty"`
|
||||
}
|
||||
|
||||
type resourceGroupRequest struct {
|
||||
GroupCode string `json:"groupCode"`
|
||||
Name string `json:"name"`
|
||||
@ -121,6 +141,8 @@ type grantGroupRequest struct {
|
||||
func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceRequest {
|
||||
resourceType := normalizeResourceType(r.ResourceType)
|
||||
walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount)
|
||||
priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice)
|
||||
metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm)
|
||||
return &walletv1.CreateResourceRequest{
|
||||
RequestId: middleware.CurrentRequestID(c),
|
||||
AppCode: appctx.FromContext(c.Request.Context()),
|
||||
@ -133,11 +155,14 @@ func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceReq
|
||||
GrantStrategy: resourceGrantStrategy(resourceType),
|
||||
WalletAssetType: walletAssetType,
|
||||
WalletAssetAmount: walletAssetAmount,
|
||||
PriceType: priceType,
|
||||
CoinPrice: coinPrice,
|
||||
GiftPointAmount: giftPointAmount,
|
||||
UsageScopes: []string{resourceType},
|
||||
AssetUrl: strings.TrimSpace(r.AssetURL),
|
||||
PreviewUrl: strings.TrimSpace(r.PreviewURL),
|
||||
AnimationUrl: strings.TrimSpace(r.AnimationURL),
|
||||
MetadataJson: "{}",
|
||||
MetadataJson: metadataJSON,
|
||||
SortOrder: r.SortOrder,
|
||||
OperatorUserId: actorID(c),
|
||||
}
|
||||
@ -146,6 +171,8 @@ func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceReq
|
||||
func (r resourceRequest) updateProto(c *gin.Context, resourceID int64) *walletv1.UpdateResourceRequest {
|
||||
resourceType := normalizeResourceType(r.ResourceType)
|
||||
walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount)
|
||||
priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice)
|
||||
metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm)
|
||||
return &walletv1.UpdateResourceRequest{
|
||||
RequestId: middleware.CurrentRequestID(c),
|
||||
AppCode: appctx.FromContext(c.Request.Context()),
|
||||
@ -159,11 +186,14 @@ func (r resourceRequest) updateProto(c *gin.Context, resourceID int64) *walletv1
|
||||
GrantStrategy: resourceGrantStrategy(resourceType),
|
||||
WalletAssetType: walletAssetType,
|
||||
WalletAssetAmount: walletAssetAmount,
|
||||
PriceType: priceType,
|
||||
CoinPrice: coinPrice,
|
||||
GiftPointAmount: giftPointAmount,
|
||||
UsageScopes: []string{resourceType},
|
||||
AssetUrl: strings.TrimSpace(r.AssetURL),
|
||||
PreviewUrl: strings.TrimSpace(r.PreviewURL),
|
||||
AnimationUrl: strings.TrimSpace(r.AnimationURL),
|
||||
MetadataJson: "{}",
|
||||
MetadataJson: metadataJSON,
|
||||
SortOrder: r.SortOrder,
|
||||
OperatorUserId: actorID(c),
|
||||
}
|
||||
@ -340,6 +370,39 @@ func resourceWalletAsset(resourceType string, amount int64) (string, int64) {
|
||||
return "", 0
|
||||
}
|
||||
|
||||
func resourcePricing(priceType string, coinPrice int64) (string, int64, int64) {
|
||||
priceType = strings.ToLower(strings.TrimSpace(priceType))
|
||||
if priceType == resourcePriceTypeFree {
|
||||
return resourcePriceTypeFree, 0, 0
|
||||
}
|
||||
return resourcePriceTypeCoin, coinPrice, coinPrice
|
||||
}
|
||||
|
||||
func validateResourcePricing(req resourceRequest) error {
|
||||
priceType := strings.ToLower(strings.TrimSpace(req.PriceType))
|
||||
switch priceType {
|
||||
case resourcePriceTypeCoin:
|
||||
if req.CoinPrice <= 0 {
|
||||
return fmt.Errorf("请输入资源价格")
|
||||
}
|
||||
case resourcePriceTypeFree:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("请选择资源价格类型")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateResourceBadgeForm(req resourceRequest) error {
|
||||
if normalizeResourceType(req.ResourceType) != resourceTypeBadge {
|
||||
return nil
|
||||
}
|
||||
if normalizeBadgeForm(req.BadgeForm) == "" {
|
||||
return fmt.Errorf("请选择徽章属性")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resourceGrantStrategy(resourceType string) string {
|
||||
switch resourceType {
|
||||
case resourceTypeCoin:
|
||||
@ -353,6 +416,52 @@ func resourceGrantStrategy(resourceType string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func resourceMetadataJSON(resourceType string, badgeForm string) string {
|
||||
if resourceType != resourceTypeBadge {
|
||||
return "{}"
|
||||
}
|
||||
payload := badgeMetadataPayload{BadgeForm: normalizeBadgeForm(badgeForm)}
|
||||
switch payload.BadgeForm {
|
||||
case badgeFormStrip:
|
||||
payload.DefaultSlot = "profile_strip"
|
||||
case badgeFormTile:
|
||||
payload.DefaultSlot = "honor_wall"
|
||||
default:
|
||||
return "{}"
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
func normalizeBadgeForm(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case badgeFormStrip, "long":
|
||||
return badgeFormStrip
|
||||
case badgeFormTile, "short":
|
||||
return badgeFormTile
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func badgeFormFromMetadata(metadataJSON string) string {
|
||||
payload := badgeMetadataPayload{}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(metadataJSON)), &payload); err != nil {
|
||||
return ""
|
||||
}
|
||||
return normalizeBadgeForm(payload.BadgeForm)
|
||||
}
|
||||
|
||||
func badgeFormForResource(resourceType string, metadataJSON string) string {
|
||||
if normalizeResourceType(resourceType) != resourceTypeBadge {
|
||||
return ""
|
||||
}
|
||||
return badgeFormFromMetadata(metadataJSON)
|
||||
}
|
||||
|
||||
func emojiPackMetadataJSON(regionIDs []int64, category string, pricingType string) (string, error) {
|
||||
normalizedRegionIDs := normalizeRegionIDs(regionIDs)
|
||||
metadata := emojiPackMetadataPayload{
|
||||
|
||||
@ -2,13 +2,10 @@ package roomadmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/integration/roomclient"
|
||||
)
|
||||
|
||||
var roomSeatCountCandidates = []int32{10, 15, 20, 25, 30}
|
||||
@ -22,58 +19,36 @@ type RoomConfig struct {
|
||||
|
||||
// GetRoomConfig 读取房间座位数配置;缺行时返回产品默认值,不阻塞首次打开配置页。
|
||||
func (s *Service) GetRoomConfig(ctx context.Context) (RoomConfig, error) {
|
||||
if s.db == nil {
|
||||
return RoomConfig{}, fmt.Errorf("room mysql is not configured")
|
||||
if s.roomClient == nil {
|
||||
return RoomConfig{}, fmt.Errorf("room service client is not configured")
|
||||
}
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT allowed_seat_counts, default_seat_count, updated_at_ms
|
||||
FROM room_seat_configs
|
||||
WHERE app_code = ?
|
||||
`, appctx.FromContext(ctx))
|
||||
|
||||
var rawAllowed string
|
||||
config := defaultRoomConfig()
|
||||
if err := row.Scan(&rawAllowed, &config.DefaultSeatCount, &config.UpdatedAtMS); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return config, nil
|
||||
}
|
||||
return RoomConfig{}, err
|
||||
config, err := s.roomClient.GetRoomSeatConfig(ctx)
|
||||
if err != nil {
|
||||
return RoomConfig{}, mapRoomClientError(err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(rawAllowed), &config.AllowedSeatCounts); err != nil {
|
||||
return RoomConfig{}, err
|
||||
}
|
||||
return normalizeRoomConfig(config)
|
||||
return roomConfigFromClient(config)
|
||||
}
|
||||
|
||||
// UpdateRoomConfig 保存后台房间配置;候选集合固定,后台只控制启用项和默认值。
|
||||
func (s *Service) UpdateRoomConfig(ctx context.Context, req updateRoomConfigRequest) (RoomConfig, error) {
|
||||
if s.db == nil {
|
||||
return RoomConfig{}, fmt.Errorf("room mysql is not configured")
|
||||
if s.roomClient == nil {
|
||||
return RoomConfig{}, fmt.Errorf("room service client is not configured")
|
||||
}
|
||||
config, err := normalizeRoomConfig(RoomConfig{
|
||||
AllowedSeatCounts: req.AllowedSeatCounts,
|
||||
DefaultSeatCount: req.DefaultSeatCount,
|
||||
UpdatedAtMS: time.Now().UTC().UnixMilli(),
|
||||
})
|
||||
if err != nil {
|
||||
return RoomConfig{}, err
|
||||
}
|
||||
rawAllowed, err := json.Marshal(config.AllowedSeatCounts)
|
||||
saved, err := s.roomClient.UpdateRoomSeatConfig(ctx, roomclient.UpdateRoomSeatConfigRequest{
|
||||
AllowedSeatCounts: config.AllowedSeatCounts,
|
||||
DefaultSeatCount: config.DefaultSeatCount,
|
||||
})
|
||||
if err != nil {
|
||||
return RoomConfig{}, err
|
||||
return RoomConfig{}, mapRoomClientError(err)
|
||||
}
|
||||
now := config.UpdatedAtMS
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO room_seat_configs (app_code, allowed_seat_counts, default_seat_count, created_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
allowed_seat_counts = VALUES(allowed_seat_counts),
|
||||
default_seat_count = VALUES(default_seat_count),
|
||||
updated_at_ms = VALUES(updated_at_ms)
|
||||
`, appctx.FromContext(ctx), string(rawAllowed), config.DefaultSeatCount, now, now); err != nil {
|
||||
return RoomConfig{}, err
|
||||
}
|
||||
return config, nil
|
||||
return roomConfigFromClient(saved)
|
||||
}
|
||||
|
||||
func defaultRoomConfig() RoomConfig {
|
||||
@ -112,3 +87,16 @@ func normalizeRoomConfig(config RoomConfig) (RoomConfig, error) {
|
||||
config.AllowedSeatCounts = allowed
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func roomConfigFromClient(input roomclient.RoomSeatConfig) (RoomConfig, error) {
|
||||
config := RoomConfig{
|
||||
CandidateSeatCounts: input.CandidateSeatCounts,
|
||||
AllowedSeatCounts: input.AllowedSeatCounts,
|
||||
DefaultSeatCount: input.DefaultSeatCount,
|
||||
UpdatedAtMS: input.UpdatedAtMS,
|
||||
}
|
||||
if len(config.CandidateSeatCounts) == 0 {
|
||||
config.CandidateSeatCounts = append([]int32(nil), roomSeatCountCandidates...)
|
||||
}
|
||||
return normalizeRoomConfig(config)
|
||||
}
|
||||
|
||||
@ -19,8 +19,8 @@ type Handler struct {
|
||||
audit shared.OperationLogger
|
||||
}
|
||||
|
||||
func New(roomDB *sql.DB, userDB *sql.DB, roomClient roomclient.Client, audit shared.OperationLogger) *Handler {
|
||||
return &Handler{service: NewService(roomDB, userDB, roomClient), audit: audit}
|
||||
func New(userDB *sql.DB, roomClient roomclient.Client, audit shared.OperationLogger) *Handler {
|
||||
return &Handler{service: NewService(userDB, roomClient), audit: audit}
|
||||
}
|
||||
|
||||
func (h *Handler) ListRooms(c *gin.Context) {
|
||||
@ -126,7 +126,7 @@ func (h *Handler) DeleteRoom(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.service.DeleteRoom(c.Request.Context(), roomID); err != nil {
|
||||
if err := h.service.DeleteRoom(c.Request.Context(), roomID, shared.ActorFromContext(c), middleware.CurrentRequestID(c)); err != nil {
|
||||
writeMutationError(c, err, "删除房间失败")
|
||||
return
|
||||
}
|
||||
|
||||
@ -2,14 +2,12 @@ package roomadmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/integration/roomclient"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -49,204 +47,45 @@ type RoomPin struct {
|
||||
}
|
||||
|
||||
func (s *Service) ListRoomPins(ctx context.Context, query roomPinListQuery) ([]RoomPin, int64, error) {
|
||||
if s.db == nil {
|
||||
return nil, 0, fmt.Errorf("room mysql is not configured")
|
||||
if s.roomClient == nil {
|
||||
return nil, 0, fmt.Errorf("room service client is not configured")
|
||||
}
|
||||
query = normalizeRoomPinListQuery(query)
|
||||
whereSQL, args := roomPinListWhere(ctx, query, time.Now().UTC().UnixMilli())
|
||||
total, err := countRows(ctx, s.db, whereSQL, args...)
|
||||
result, err := s.roomClient.ListRoomPins(ctx, roomclient.ListRoomPinsRequest{
|
||||
Page: query.Page,
|
||||
PageSize: query.PageSize,
|
||||
Keyword: query.Keyword,
|
||||
Status: query.Status,
|
||||
RegionID: query.RegionID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT p.id,
|
||||
p.visible_region_id,
|
||||
p.room_id,
|
||||
p.weight,
|
||||
p.status,
|
||||
p.pinned_at_ms,
|
||||
p.expires_at_ms,
|
||||
p.cancelled_at_ms,
|
||||
p.created_by_admin_id,
|
||||
p.cancelled_by_admin_id,
|
||||
p.created_at_ms,
|
||||
p.updated_at_ms,
|
||||
r.room_short_id,
|
||||
r.title,
|
||||
r.cover_url,
|
||||
r.owner_user_id,
|
||||
r.status
|
||||
`+whereSQL+`
|
||||
ORDER BY p.weight DESC, p.expires_at_ms DESC, p.id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`, append(args, query.PageSize, offset(query.Page, query.PageSize))...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]RoomPin, 0, query.PageSize)
|
||||
for rows.Next() {
|
||||
item, err := scanRoomPin(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items := roomPinsFromClient(result.Pins)
|
||||
if err := s.fillRoomPinDetails(ctx, items); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
return items, result.Total, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateRoomPin(ctx context.Context, req createRoomPinRequest, actorID uint) (RoomPin, error) {
|
||||
if s.db == nil {
|
||||
return RoomPin{}, fmt.Errorf("room mysql is not configured")
|
||||
if s.roomClient == nil {
|
||||
return RoomPin{}, fmt.Errorf("room service client is not configured")
|
||||
}
|
||||
input, err := normalizeCreateRoomPinRequest(req)
|
||||
if err != nil {
|
||||
return RoomPin{}, err
|
||||
}
|
||||
|
||||
room, _, err := s.getRoomForPin(ctx, input.RoomID)
|
||||
pin, err := s.roomClient.CreateRoomPin(ctx, roomclient.CreateRoomPinRequest{
|
||||
RoomID: input.RoomID,
|
||||
Weight: input.Weight,
|
||||
DurationDays: input.DurationDays,
|
||||
AdminID: uint64(actorID),
|
||||
})
|
||||
if err != nil {
|
||||
return RoomPin{}, err
|
||||
}
|
||||
if room.Status != "active" {
|
||||
return RoomPin{}, fmt.Errorf("%w: 只能置顶正常状态房间", ErrInvalidArgument)
|
||||
}
|
||||
|
||||
nowMS := time.Now().UTC().UnixMilli()
|
||||
expiresAtMS := nowMS + input.DurationDays*roomPinDayMillis
|
||||
appCode := appctx.FromContext(ctx)
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO room_region_pins (
|
||||
app_code, visible_region_id, room_id, weight, status, pinned_at_ms, expires_at_ms,
|
||||
cancelled_at_ms, created_by_admin_id, cancelled_by_admin_id, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, 0, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
weight = VALUES(weight),
|
||||
status = VALUES(status),
|
||||
pinned_at_ms = VALUES(pinned_at_ms),
|
||||
expires_at_ms = VALUES(expires_at_ms),
|
||||
cancelled_at_ms = 0,
|
||||
created_by_admin_id = VALUES(created_by_admin_id),
|
||||
cancelled_by_admin_id = 0,
|
||||
created_at_ms = VALUES(created_at_ms),
|
||||
updated_at_ms = VALUES(updated_at_ms)
|
||||
`, appCode, room.VisibleRegionID, room.RoomID, input.Weight, roomPinStatusActive, nowMS, expiresAtMS, actorID, nowMS, nowMS); err != nil {
|
||||
return RoomPin{}, err
|
||||
}
|
||||
|
||||
pin, err := s.getRoomPinByRoom(ctx, room.VisibleRegionID, room.RoomID)
|
||||
if err != nil {
|
||||
return RoomPin{}, err
|
||||
}
|
||||
return pin, nil
|
||||
}
|
||||
|
||||
func (s *Service) CancelRoomPin(ctx context.Context, pinID int64, actorID uint) (RoomPin, error) {
|
||||
if s.db == nil {
|
||||
return RoomPin{}, fmt.Errorf("room mysql is not configured")
|
||||
}
|
||||
if pinID <= 0 {
|
||||
return RoomPin{}, fmt.Errorf("%w: 置顶 ID 不正确", ErrInvalidArgument)
|
||||
}
|
||||
|
||||
nowMS := time.Now().UTC().UnixMilli()
|
||||
appCode := appctx.FromContext(ctx)
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
UPDATE room_region_pins
|
||||
SET status = ?, cancelled_at_ms = ?, cancelled_by_admin_id = ?, updated_at_ms = ?
|
||||
WHERE app_code = ? AND id = ? AND status <> ?
|
||||
`, roomPinStatusCancelled, nowMS, actorID, nowMS, appCode, pinID, roomPinStatusCancelled)
|
||||
if err != nil {
|
||||
return RoomPin{}, err
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
||||
if _, err := s.getRoomPinByID(ctx, pinID); errors.Is(err, ErrNotFound) {
|
||||
return RoomPin{}, ErrNotFound
|
||||
}
|
||||
}
|
||||
return s.getRoomPinByID(ctx, pinID)
|
||||
}
|
||||
|
||||
func (s *Service) getRoomForPin(ctx context.Context, roomID string) (RoomPinRoom, int64, error) {
|
||||
appCode := appctx.FromContext(ctx)
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT room_id,
|
||||
room_short_id,
|
||||
visible_region_id,
|
||||
owner_user_id,
|
||||
title,
|
||||
cover_url,
|
||||
status
|
||||
FROM room_list_entries
|
||||
WHERE app_code = ? AND (room_id = ? OR room_short_id = ?)
|
||||
LIMIT 1
|
||||
`, appCode, roomID, roomID)
|
||||
|
||||
var room RoomPinRoom
|
||||
var ownerUserID int64
|
||||
if err := row.Scan(&room.RoomID, &room.RoomShortID, &room.VisibleRegionID, &ownerUserID, &room.Title, &room.CoverURL, &room.Status); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return RoomPinRoom{}, 0, ErrNotFound
|
||||
}
|
||||
return RoomPinRoom{}, 0, err
|
||||
}
|
||||
if room.Status != "active" {
|
||||
room.Status = "closed"
|
||||
}
|
||||
return room, ownerUserID, nil
|
||||
}
|
||||
|
||||
func (s *Service) getRoomPinByRoom(ctx context.Context, regionID int64, roomID string) (RoomPin, error) {
|
||||
return s.getRoomPin(ctx, `
|
||||
WHERE p.app_code = ? AND p.visible_region_id = ? AND p.room_id = ?
|
||||
`, appctx.FromContext(ctx), regionID, roomID)
|
||||
}
|
||||
|
||||
func (s *Service) getRoomPinByID(ctx context.Context, pinID int64) (RoomPin, error) {
|
||||
return s.getRoomPin(ctx, `
|
||||
WHERE p.app_code = ? AND p.id = ?
|
||||
`, appctx.FromContext(ctx), pinID)
|
||||
}
|
||||
|
||||
func (s *Service) getRoomPin(ctx context.Context, whereSQL string, args ...any) (RoomPin, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT p.id,
|
||||
p.visible_region_id,
|
||||
p.room_id,
|
||||
p.weight,
|
||||
p.status,
|
||||
p.pinned_at_ms,
|
||||
p.expires_at_ms,
|
||||
p.cancelled_at_ms,
|
||||
p.created_by_admin_id,
|
||||
p.cancelled_by_admin_id,
|
||||
p.created_at_ms,
|
||||
p.updated_at_ms,
|
||||
r.room_short_id,
|
||||
r.title,
|
||||
r.cover_url,
|
||||
r.owner_user_id,
|
||||
r.status
|
||||
FROM room_region_pins p
|
||||
JOIN room_list_entries r ON r.app_code = p.app_code AND r.room_id = p.room_id
|
||||
`+whereSQL+`
|
||||
LIMIT 1
|
||||
`, args...)
|
||||
item, err := scanRoomPin(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return RoomPin{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return RoomPin{}, err
|
||||
return RoomPin{}, mapRoomClientError(err)
|
||||
}
|
||||
item := roomPinFromClient(pin)
|
||||
items := []RoomPin{item}
|
||||
if err := s.fillRoomPinDetails(ctx, items); err != nil {
|
||||
return RoomPin{}, err
|
||||
@ -254,33 +93,23 @@ func (s *Service) getRoomPin(ctx context.Context, whereSQL string, args ...any)
|
||||
return items[0], nil
|
||||
}
|
||||
|
||||
func roomPinListWhere(ctx context.Context, query roomPinListQuery, nowMS int64) (string, []any) {
|
||||
where := []string{"p.app_code = ?"}
|
||||
args := []any{appctx.FromContext(ctx)}
|
||||
switch query.Status {
|
||||
case roomPinStatusActive:
|
||||
where = append(where, "p.status = ?", "p.expires_at_ms > ?")
|
||||
args = append(args, roomPinStatusActive, nowMS)
|
||||
case roomPinStatusExpired:
|
||||
where = append(where, "p.status = ?", "p.expires_at_ms <= ?")
|
||||
args = append(args, roomPinStatusActive, nowMS)
|
||||
case roomPinStatusCancelled:
|
||||
where = append(where, "p.status = ?")
|
||||
args = append(args, roomPinStatusCancelled)
|
||||
func (s *Service) CancelRoomPin(ctx context.Context, pinID int64, actorID uint) (RoomPin, error) {
|
||||
if s.roomClient == nil {
|
||||
return RoomPin{}, fmt.Errorf("room service client is not configured")
|
||||
}
|
||||
if query.RegionID > 0 {
|
||||
where = append(where, "p.visible_region_id = ?")
|
||||
args = append(args, query.RegionID)
|
||||
if pinID <= 0 {
|
||||
return RoomPin{}, fmt.Errorf("%w: 置顶 ID 不正确", ErrInvalidArgument)
|
||||
}
|
||||
if query.Keyword != "" {
|
||||
like := "%" + query.Keyword + "%"
|
||||
where = append(where, "(p.room_id LIKE ? OR r.room_short_id LIKE ? OR r.title LIKE ? OR CAST(r.owner_user_id AS CHAR) LIKE ?)")
|
||||
args = append(args, like, like, like, like)
|
||||
pin, err := s.roomClient.CancelRoomPin(ctx, roomclient.CancelRoomPinRequest{PinID: pinID, AdminID: uint64(actorID)})
|
||||
if err != nil {
|
||||
return RoomPin{}, mapRoomClientError(err)
|
||||
}
|
||||
return `
|
||||
FROM room_region_pins p
|
||||
JOIN room_list_entries r ON r.app_code = p.app_code AND r.room_id = p.room_id
|
||||
WHERE ` + strings.Join(where, " AND "), args
|
||||
item := roomPinFromClient(pin)
|
||||
items := []RoomPin{item}
|
||||
if err := s.fillRoomPinDetails(ctx, items); err != nil {
|
||||
return RoomPin{}, err
|
||||
}
|
||||
return items[0], nil
|
||||
}
|
||||
|
||||
func normalizeRoomPinListQuery(query roomPinListQuery) roomPinListQuery {
|
||||
@ -333,6 +162,48 @@ func normalizeCreateRoomPinRequest(req createRoomPinRequest) (createRoomPinReque
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func roomPinsFromClient(input []roomclient.RoomPin) []RoomPin {
|
||||
out := make([]RoomPin, 0, len(input))
|
||||
for _, pin := range input {
|
||||
out = append(out, roomPinFromClient(pin))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func roomPinFromClient(input roomclient.RoomPin) RoomPin {
|
||||
status := input.Room.Status
|
||||
if status != "active" {
|
||||
status = "closed"
|
||||
}
|
||||
ownerUserID := strconv.FormatInt(input.Room.OwnerUserID, 10)
|
||||
remainingMS := input.ExpiresAtMS - time.Now().UTC().UnixMilli()
|
||||
if remainingMS < 0 {
|
||||
remainingMS = 0
|
||||
}
|
||||
return RoomPin{
|
||||
ID: input.ID,
|
||||
Weight: input.Weight,
|
||||
Status: input.Status,
|
||||
PinnedAtMS: input.PinnedAtMS,
|
||||
ExpiresAtMS: input.ExpiresAtMS,
|
||||
CancelledAtMS: input.CancelledAtMS,
|
||||
CreatedByAdminID: uint(input.CreatedByAdminID),
|
||||
CancelledByAdminID: uint(input.CancelledByAdminID),
|
||||
CreatedAtMS: input.CreatedAtMS,
|
||||
UpdatedAtMS: input.UpdatedAtMS,
|
||||
RemainingMS: remainingMS,
|
||||
User: RoomOwner{UserID: ownerUserID},
|
||||
Room: RoomPinRoom{
|
||||
RoomID: input.Room.RoomID,
|
||||
RoomShortID: input.Room.RoomShortID,
|
||||
VisibleRegionID: input.Room.VisibleRegionID,
|
||||
Title: input.Room.Title,
|
||||
CoverURL: input.Room.CoverURL,
|
||||
Status: status,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type roomPinScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
@ -7,15 +7,12 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/integration/roomclient"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -24,7 +21,6 @@ var (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *sql.DB
|
||||
userDB *sql.DB
|
||||
roomClient roomclient.Client
|
||||
}
|
||||
@ -61,120 +57,40 @@ type Room struct {
|
||||
VisibleRegionID int64 `json:"visibleRegionId"`
|
||||
}
|
||||
|
||||
func NewService(db *sql.DB, userDB *sql.DB, roomClient roomclient.Client) *Service {
|
||||
return &Service{db: db, userDB: userDB, roomClient: roomClient}
|
||||
func NewService(userDB *sql.DB, roomClient roomclient.Client) *Service {
|
||||
return &Service{userDB: userDB, roomClient: roomClient}
|
||||
}
|
||||
|
||||
func (s *Service) ListRooms(ctx context.Context, query listQuery) ([]Room, int64, error) {
|
||||
if s.db == nil {
|
||||
return nil, 0, fmt.Errorf("room mysql is not configured")
|
||||
if s.roomClient == nil {
|
||||
return nil, 0, fmt.Errorf("room service client is not configured")
|
||||
}
|
||||
query = normalizeListQuery(query)
|
||||
appCode := appctx.FromContext(ctx)
|
||||
whereSQL := "FROM room_list_entries rle WHERE rle.app_code = ?"
|
||||
args := []any{appCode}
|
||||
if query.Status == "closed" {
|
||||
whereSQL += " AND rle.status <> ?"
|
||||
args = append(args, "active")
|
||||
} else if query.Status != "" {
|
||||
whereSQL += " AND rle.status = ?"
|
||||
args = append(args, query.Status)
|
||||
}
|
||||
if query.RegionID > 0 {
|
||||
whereSQL += " AND rle.visible_region_id = ?"
|
||||
args = append(args, query.RegionID)
|
||||
}
|
||||
if query.Keyword != "" {
|
||||
like := "%" + query.Keyword + "%"
|
||||
whereSQL += " AND (rle.room_id LIKE ? OR rle.room_short_id LIKE ? OR rle.title LIKE ? OR CAST(rle.owner_user_id AS CHAR) LIKE ?)"
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
total, err := countRows(ctx, s.db, whereSQL, args...)
|
||||
result, err := s.roomClient.ListRooms(ctx, roomclient.ListRoomsRequest{
|
||||
Page: query.Page,
|
||||
PageSize: query.PageSize,
|
||||
Keyword: query.Keyword,
|
||||
Status: query.Status,
|
||||
RegionID: query.RegionID,
|
||||
SortBy: query.SortBy,
|
||||
SortDirection: query.SortDirection,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, fmt.Sprintf(`
|
||||
SELECT rle.room_id,
|
||||
rle.room_short_id,
|
||||
rle.visible_region_id,
|
||||
rle.owner_user_id,
|
||||
rle.title,
|
||||
rle.cover_url,
|
||||
rle.mode,
|
||||
rle.status,
|
||||
rle.close_reason,
|
||||
rle.closed_by_admin_id,
|
||||
rle.closed_by_admin_name,
|
||||
rle.closed_at_ms,
|
||||
rle.heat,
|
||||
rle.online_count,
|
||||
rle.seat_count,
|
||||
rle.occupied_seat_count,
|
||||
rle.sort_score,
|
||||
rle.created_at_ms,
|
||||
rle.updated_at_ms
|
||||
%s
|
||||
ORDER BY %s
|
||||
LIMIT ? OFFSET ?
|
||||
`, whereSQL, roomListOrderBy(query)), append(args, query.PageSize, offset(query.Page, query.PageSize))...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]Room, 0, query.PageSize)
|
||||
for rows.Next() {
|
||||
item, err := scanRoom(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items := roomsFromClient(result.Rooms)
|
||||
if err := s.fillRoomDetails(ctx, items); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
return items, result.Total, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetRoom(ctx context.Context, roomID string) (Room, error) {
|
||||
if s.db == nil {
|
||||
return Room{}, fmt.Errorf("room mysql is not configured")
|
||||
}
|
||||
appCode := appctx.FromContext(ctx)
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT room_id,
|
||||
room_short_id,
|
||||
visible_region_id,
|
||||
owner_user_id,
|
||||
title,
|
||||
cover_url,
|
||||
mode,
|
||||
status,
|
||||
close_reason,
|
||||
closed_by_admin_id,
|
||||
closed_by_admin_name,
|
||||
closed_at_ms,
|
||||
heat,
|
||||
online_count,
|
||||
seat_count,
|
||||
occupied_seat_count,
|
||||
sort_score,
|
||||
created_at_ms,
|
||||
updated_at_ms
|
||||
FROM room_list_entries
|
||||
WHERE app_code = ? AND room_id = ?
|
||||
`, appCode, roomID)
|
||||
item, err := scanRoom(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Room{}, ErrNotFound
|
||||
}
|
||||
item, err := s.roomClient.GetRoom(ctx, roomclient.GetRoomRequest{RoomID: roomID})
|
||||
if err != nil {
|
||||
return Room{}, err
|
||||
return Room{}, mapRoomClientError(err)
|
||||
}
|
||||
items := []Room{item}
|
||||
items := []Room{roomFromClient(*item)}
|
||||
if err := s.fillRoomDetails(ctx, items); err != nil {
|
||||
return Room{}, err
|
||||
}
|
||||
@ -182,110 +98,93 @@ func (s *Service) GetRoom(ctx context.Context, roomID string) (Room, error) {
|
||||
}
|
||||
|
||||
func (s *Service) UpdateRoom(ctx context.Context, roomID string, req updateRoomRequest, actor shared.Actor, requestID string) (Room, error) {
|
||||
if s.db == nil {
|
||||
return Room{}, fmt.Errorf("room mysql is not configured")
|
||||
if s.roomClient == nil {
|
||||
return Room{}, fmt.Errorf("room service client is not configured")
|
||||
}
|
||||
updates, snapshotPatch, err := buildRoomUpdate(req)
|
||||
if err != nil {
|
||||
if _, _, err := buildRoomUpdate(req); err != nil {
|
||||
return Room{}, err
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
if !roomUpdateHasChange(req) {
|
||||
return s.GetRoom(ctx, roomID)
|
||||
}
|
||||
if snapshotPatch.statusSet && snapshotPatch.status == "closed" {
|
||||
if err := s.closeRoomLifecycle(ctx, roomID, actor, requestID); err != nil {
|
||||
return Room{}, err
|
||||
}
|
||||
} else if snapshotPatch.statusSet && snapshotPatch.status == "active" {
|
||||
if err := s.reopenRoomLifecycle(ctx, roomID, actor, requestID); err != nil {
|
||||
return Room{}, err
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
_, err := s.roomClient.UpdateRoom(ctx, roomclient.UpdateRoomRequest{
|
||||
RequestID: requestID,
|
||||
RoomID: roomID,
|
||||
ActorUserID: int64(actor.UserID),
|
||||
Title: req.Title,
|
||||
CoverURL: req.CoverURL,
|
||||
Description: req.Description,
|
||||
Mode: req.Mode,
|
||||
Status: req.Status,
|
||||
VisibleRegionID: req.VisibleRegionID,
|
||||
CloseReason: valueOrEmpty(req.CloseReason),
|
||||
AdminID: uint64(actor.UserID),
|
||||
AdminName: actor.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return Room{}, err
|
||||
}
|
||||
defer rollbackIfOpen(tx)
|
||||
|
||||
var currentRoomID string
|
||||
appCode := appctx.FromContext(ctx)
|
||||
if err := tx.QueryRowContext(ctx, `SELECT room_id FROM room_list_entries WHERE app_code = ? AND room_id = ? FOR UPDATE`, appCode, roomID).Scan(¤tRoomID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Room{}, ErrNotFound
|
||||
}
|
||||
return Room{}, err
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
listSets := make([]string, 0, len(updates)+5)
|
||||
listArgs := make([]any, 0, len(updates)+6)
|
||||
roomSets := make([]string, 0, len(updates)+4)
|
||||
roomArgs := make([]any, 0, len(updates)+6)
|
||||
for _, update := range updates {
|
||||
if update.listColumn != "" {
|
||||
listSets = append(listSets, update.listColumn+" = ?")
|
||||
listArgs = append(listArgs, update.value)
|
||||
}
|
||||
if update.roomColumn != "" {
|
||||
roomSets = append(roomSets, update.roomColumn+" = ?")
|
||||
roomArgs = append(roomArgs, update.value)
|
||||
}
|
||||
}
|
||||
if snapshotPatch.statusSet {
|
||||
if snapshotPatch.status == "closed" {
|
||||
reason := strings.TrimSpace(valueOrEmpty(req.CloseReason))
|
||||
operatorName := strings.TrimSpace(actor.Username)
|
||||
closeUpdates := []roomUpdate{
|
||||
{listColumn: "close_reason", roomColumn: "close_reason", value: reason},
|
||||
{listColumn: "closed_by_admin_id", roomColumn: "closed_by_admin_id", value: actor.UserID},
|
||||
{listColumn: "closed_by_admin_name", roomColumn: "closed_by_admin_name", value: operatorName},
|
||||
{listColumn: "closed_at_ms", roomColumn: "closed_at_ms", value: now},
|
||||
}
|
||||
appendRoomUpdates(closeUpdates, &listSets, &listArgs, &roomSets, &roomArgs)
|
||||
} else {
|
||||
clearUpdates := []roomUpdate{
|
||||
{listColumn: "close_reason", roomColumn: "close_reason", value: ""},
|
||||
{listColumn: "closed_by_admin_id", roomColumn: "closed_by_admin_id", value: 0},
|
||||
{listColumn: "closed_by_admin_name", roomColumn: "closed_by_admin_name", value: ""},
|
||||
{listColumn: "closed_at_ms", roomColumn: "closed_at_ms", value: 0},
|
||||
}
|
||||
appendRoomUpdates(clearUpdates, &listSets, &listArgs, &roomSets, &roomArgs)
|
||||
}
|
||||
}
|
||||
listSets = append(listSets, "updated_at_ms = ?")
|
||||
listArgs = append(listArgs, now, appCode, roomID)
|
||||
if _, err := tx.ExecContext(ctx, "UPDATE room_list_entries SET "+strings.Join(listSets, ", ")+" WHERE app_code = ? AND room_id = ?", listArgs...); err != nil {
|
||||
return Room{}, err
|
||||
}
|
||||
if len(roomSets) > 0 {
|
||||
roomArgs = append(roomArgs, appCode, roomID)
|
||||
if _, err := tx.ExecContext(ctx, "UPDATE rooms SET "+strings.Join(roomSets, ", ")+" WHERE app_code = ? AND room_id = ?", roomArgs...); err != nil {
|
||||
return Room{}, err
|
||||
}
|
||||
}
|
||||
if err := updateSnapshot(ctx, tx, roomID, snapshotPatch); err != nil {
|
||||
return Room{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Room{}, err
|
||||
return Room{}, mapRoomClientError(err)
|
||||
}
|
||||
return s.GetRoom(ctx, roomID)
|
||||
}
|
||||
|
||||
func (s *Service) closeRoomLifecycle(ctx context.Context, roomID string, actor shared.Actor, requestID string) error {
|
||||
func (s *Service) DeleteRoom(ctx context.Context, roomID string, actor shared.Actor, requestID string) error {
|
||||
if s.roomClient == nil {
|
||||
return fmt.Errorf("room service client is not configured")
|
||||
}
|
||||
if actor.UserID == 0 {
|
||||
return fmt.Errorf("%w: 操作人不正确", ErrInvalidArgument)
|
||||
}
|
||||
_, err := s.roomClient.CloseRoom(ctx, roomclient.CloseRoomRequest{
|
||||
_, err := s.roomClient.DeleteRoom(ctx, roomclient.DeleteRoomRequest{
|
||||
RequestID: requestID,
|
||||
RoomID: roomID,
|
||||
ActorUserID: int64(actor.UserID),
|
||||
Reason: "admin_closed",
|
||||
AdminID: uint64(actor.UserID),
|
||||
AdminName: actor.Username,
|
||||
})
|
||||
return mapRoomClientError(err)
|
||||
}
|
||||
|
||||
func roomUpdateHasChange(req updateRoomRequest) bool {
|
||||
return req.Title != nil || req.CoverURL != nil || req.Description != nil || req.Mode != nil || req.Status != nil || req.VisibleRegionID != nil
|
||||
}
|
||||
|
||||
func roomsFromClient(items []roomclient.Room) []Room {
|
||||
rooms := make([]Room, 0, len(items))
|
||||
for _, item := range items {
|
||||
rooms = append(rooms, roomFromClient(item))
|
||||
}
|
||||
return rooms
|
||||
}
|
||||
|
||||
func roomFromClient(item roomclient.Room) Room {
|
||||
status := item.Status
|
||||
if status != "active" {
|
||||
status = "closed"
|
||||
}
|
||||
ownerUserID := strconv.FormatInt(item.OwnerUserID, 10)
|
||||
return Room{
|
||||
RoomID: item.RoomID,
|
||||
RoomShortID: item.RoomShortID,
|
||||
VisibleRegionID: item.VisibleRegionID,
|
||||
OwnerUserID: ownerUserID,
|
||||
Owner: RoomOwner{UserID: ownerUserID},
|
||||
Title: item.Title,
|
||||
CoverURL: item.CoverURL,
|
||||
Mode: item.Mode,
|
||||
Status: status,
|
||||
CloseReason: item.CloseReason,
|
||||
ClosedByAdminID: uint(item.ClosedByAdminID),
|
||||
ClosedByAdminName: item.ClosedByAdminName,
|
||||
ClosedAtMs: item.ClosedAtMS,
|
||||
Heat: item.Heat,
|
||||
RoomContribution: item.Heat,
|
||||
OnlineCount: item.OnlineCount,
|
||||
SeatCount: item.SeatCount,
|
||||
OccupiedSeatCount: item.OccupiedSeatCount,
|
||||
SortScore: item.SortScore,
|
||||
CreatedAtMs: item.CreatedAtMS,
|
||||
UpdatedAtMs: item.UpdatedAtMS,
|
||||
}
|
||||
}
|
||||
|
||||
func mapRoomClientError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
@ -300,76 +199,6 @@ func (s *Service) closeRoomLifecycle(ctx context.Context, roomID string, actor s
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) reopenRoomLifecycle(ctx context.Context, roomID string, actor shared.Actor, requestID string) error {
|
||||
if s.roomClient == nil {
|
||||
return fmt.Errorf("room service client is not configured")
|
||||
}
|
||||
if actor.UserID == 0 {
|
||||
return fmt.Errorf("%w: 操作人不正确", ErrInvalidArgument)
|
||||
}
|
||||
_, err := s.roomClient.ReopenRoom(ctx, roomclient.ReopenRoomRequest{
|
||||
RequestID: requestID,
|
||||
RoomID: roomID,
|
||||
ActorUserID: int64(actor.UserID),
|
||||
})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if st, ok := status.FromError(err); ok {
|
||||
switch st.Code() {
|
||||
case codes.NotFound:
|
||||
return ErrNotFound
|
||||
case codes.InvalidArgument, codes.FailedPrecondition:
|
||||
return fmt.Errorf("%w: %s", ErrInvalidArgument, st.Message())
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func appendRoomUpdates(updates []roomUpdate, listSets *[]string, listArgs *[]any, roomSets *[]string, roomArgs *[]any) {
|
||||
for _, update := range updates {
|
||||
if update.listColumn != "" {
|
||||
*listSets = append(*listSets, update.listColumn+" = ?")
|
||||
*listArgs = append(*listArgs, update.value)
|
||||
}
|
||||
if update.roomColumn != "" {
|
||||
*roomSets = append(*roomSets, update.roomColumn+" = ?")
|
||||
*roomArgs = append(*roomArgs, update.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) DeleteRoom(ctx context.Context, roomID string) error {
|
||||
if s.db == nil {
|
||||
return fmt.Errorf("room mysql is not configured")
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rollbackIfOpen(tx)
|
||||
|
||||
appCode := appctx.FromContext(ctx)
|
||||
result, err := tx.ExecContext(ctx, `DELETE FROM room_list_entries WHERE app_code = ? AND room_id = ?`, appCode, roomID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM room_snapshots WHERE app_code = ? AND room_id = ?`, appCode, roomID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM rooms WHERE app_code = ? AND room_id = ?`, appCode, roomID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
type roomUpdate struct {
|
||||
listColumn string
|
||||
roomColumn string
|
||||
@ -465,85 +294,6 @@ func buildRoomUpdate(req updateRoomRequest) ([]roomUpdate, snapshotPatch, error)
|
||||
return updates, patch, nil
|
||||
}
|
||||
|
||||
func updateSnapshot(ctx context.Context, tx *sql.Tx, roomID string, patch snapshotPatch) error {
|
||||
var payload []byte
|
||||
appCode := appctx.FromContext(ctx)
|
||||
err := tx.QueryRowContext(ctx, `SELECT payload FROM room_snapshots WHERE app_code = ? AND room_id = ? FOR UPDATE`, appCode, roomID).Scan(&payload)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snapshot := &roomv1.RoomSnapshot{}
|
||||
if err := proto.Unmarshal(payload, snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
if snapshot.RoomExt == nil {
|
||||
snapshot.RoomExt = map[string]string{}
|
||||
}
|
||||
if patch.titleSet {
|
||||
snapshot.RoomExt["title"] = patch.title
|
||||
}
|
||||
if patch.coverURLSet {
|
||||
snapshot.RoomExt["cover_url"] = patch.coverURL
|
||||
}
|
||||
if patch.descriptionSet {
|
||||
snapshot.RoomExt["description"] = patch.description
|
||||
}
|
||||
if patch.modeSet {
|
||||
snapshot.Mode = patch.mode
|
||||
}
|
||||
if patch.statusSet {
|
||||
snapshot.Status = patch.status
|
||||
}
|
||||
nextPayload, err := proto.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `UPDATE room_snapshots SET payload = ? WHERE app_code = ? AND room_id = ?`, nextPayload, appCode, roomID)
|
||||
return err
|
||||
}
|
||||
|
||||
type roomScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanRoom(row roomScanner) (Room, error) {
|
||||
var item Room
|
||||
var ownerUserID int64
|
||||
var closedByAdminID uint64
|
||||
err := row.Scan(
|
||||
&item.RoomID,
|
||||
&item.RoomShortID,
|
||||
&item.VisibleRegionID,
|
||||
&ownerUserID,
|
||||
&item.Title,
|
||||
&item.CoverURL,
|
||||
&item.Mode,
|
||||
&item.Status,
|
||||
&item.CloseReason,
|
||||
&closedByAdminID,
|
||||
&item.ClosedByAdminName,
|
||||
&item.ClosedAtMs,
|
||||
&item.Heat,
|
||||
&item.OnlineCount,
|
||||
&item.SeatCount,
|
||||
&item.OccupiedSeatCount,
|
||||
&item.SortScore,
|
||||
&item.CreatedAtMs,
|
||||
&item.UpdatedAtMs,
|
||||
)
|
||||
item.ClosedByAdminID = uint(closedByAdminID)
|
||||
item.OwnerUserID = strconv.FormatInt(ownerUserID, 10)
|
||||
item.Owner = RoomOwner{UserID: item.OwnerUserID}
|
||||
item.RoomContribution = item.Heat
|
||||
if item.Status != "active" {
|
||||
item.Status = "closed"
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func normalizeListQuery(query listQuery) listQuery {
|
||||
if query.Page < 1 {
|
||||
query.Page = 1
|
||||
@ -743,16 +493,6 @@ func int64Args(values []int64) []any {
|
||||
return args
|
||||
}
|
||||
|
||||
func countRows(ctx context.Context, db *sql.DB, whereSQL string, args ...any) (int64, error) {
|
||||
var total int64
|
||||
err := db.QueryRowContext(ctx, "SELECT COUNT(*) "+whereSQL, args...).Scan(&total)
|
||||
return total, err
|
||||
}
|
||||
|
||||
func offset(page int, pageSize int) int {
|
||||
return (page - 1) * pageSize
|
||||
}
|
||||
|
||||
func rollbackIfOpen(tx *sql.Tx) {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
package roomtreasure
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"hyapp-admin-server/internal/integration/roomclient"
|
||||
"hyapp-admin-server/internal/middleware"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
"hyapp-admin-server/internal/response"
|
||||
@ -16,8 +16,8 @@ type Handler struct {
|
||||
audit shared.OperationLogger
|
||||
}
|
||||
|
||||
func New(roomDB *sql.DB, audit shared.OperationLogger) *Handler {
|
||||
return &Handler{service: NewService(roomDB), audit: audit}
|
||||
func New(roomClient roomclient.Client, audit shared.OperationLogger) *Handler {
|
||||
return &Handler{service: NewService(roomClient), audit: audit}
|
||||
}
|
||||
|
||||
func (h *Handler) GetRoomTreasureConfig(c *gin.Context) {
|
||||
|
||||
@ -2,15 +2,12 @@ package roomtreasure
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/integration/roomclient"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -29,7 +26,7 @@ var (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *sql.DB
|
||||
roomClient roomclient.Client
|
||||
}
|
||||
|
||||
type RoomTreasureConfig struct {
|
||||
@ -90,90 +87,29 @@ type updateRoomTreasureConfigRequest struct {
|
||||
GiftEnergyRules []GiftEnergyRuleConfig `json:"giftEnergyRules"`
|
||||
}
|
||||
|
||||
func NewService(db *sql.DB) *Service {
|
||||
return &Service{db: db}
|
||||
func NewService(roomClient roomclient.Client) *Service {
|
||||
return &Service{roomClient: roomClient}
|
||||
}
|
||||
|
||||
// GetConfig 读取当前 App 的语音房宝箱配置;缺行时返回关闭态默认配置,便于首次进入后台页面。
|
||||
func (s *Service) GetConfig(ctx context.Context) (RoomTreasureConfig, error) {
|
||||
if s.db == nil {
|
||||
return RoomTreasureConfig{}, fmt.Errorf("room mysql is not configured")
|
||||
if s.roomClient == nil {
|
||||
return RoomTreasureConfig{}, fmt.Errorf("room service client is not configured")
|
||||
}
|
||||
appCode := appctx.FromContext(ctx)
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT app_code,
|
||||
enabled,
|
||||
config_version,
|
||||
energy_source,
|
||||
open_delay_ms,
|
||||
broadcast_enabled,
|
||||
broadcast_scope,
|
||||
broadcast_delay_ms,
|
||||
reward_stack_policy,
|
||||
levels_json,
|
||||
gift_energy_rules_json,
|
||||
updated_by_admin_id,
|
||||
created_at_ms,
|
||||
updated_at_ms
|
||||
FROM room_treasure_configs
|
||||
WHERE app_code = ?
|
||||
`, appCode)
|
||||
|
||||
var enabled int
|
||||
var broadcastEnabled int
|
||||
var rawLevels string
|
||||
var rawGiftEnergyRules string
|
||||
config := defaultConfig(appCode)
|
||||
if err := row.Scan(
|
||||
&config.AppCode,
|
||||
&enabled,
|
||||
&config.ConfigVersion,
|
||||
&config.EnergySource,
|
||||
&config.OpenDelayMS,
|
||||
&broadcastEnabled,
|
||||
&config.BroadcastScope,
|
||||
&config.BroadcastDelayMS,
|
||||
&config.RewardStackPolicy,
|
||||
&rawLevels,
|
||||
&rawGiftEnergyRules,
|
||||
&config.UpdatedByAdminID,
|
||||
&config.CreatedAtMS,
|
||||
&config.UpdatedAtMS,
|
||||
); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return config, nil
|
||||
}
|
||||
config, err := s.roomClient.GetRoomTreasureConfig(ctx)
|
||||
if err != nil {
|
||||
return RoomTreasureConfig{}, err
|
||||
}
|
||||
config.Enabled = enabled == 1
|
||||
config.BroadcastEnabled = broadcastEnabled == 1
|
||||
if err := json.Unmarshal([]byte(rawLevels), &config.Levels); err != nil {
|
||||
return RoomTreasureConfig{}, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(rawGiftEnergyRules), &config.GiftEnergyRules); err != nil {
|
||||
return RoomTreasureConfig{}, err
|
||||
}
|
||||
return normalizeConfig(config)
|
||||
return configFromClient(config)
|
||||
}
|
||||
|
||||
// UpdateConfig 保存房间宝箱低频配置;版本号由后台递增,运行时只使用已提交版本。
|
||||
func (s *Service) UpdateConfig(ctx context.Context, req updateRoomTreasureConfigRequest, operatorAdminID int64) (RoomTreasureConfig, error) {
|
||||
if s.db == nil {
|
||||
return RoomTreasureConfig{}, fmt.Errorf("room mysql is not configured")
|
||||
}
|
||||
current, err := s.GetConfig(ctx)
|
||||
if err != nil {
|
||||
return RoomTreasureConfig{}, err
|
||||
}
|
||||
nowMS := time.Now().UTC().UnixMilli()
|
||||
createdAtMS := current.CreatedAtMS
|
||||
if createdAtMS <= 0 {
|
||||
createdAtMS = nowMS
|
||||
if s.roomClient == nil {
|
||||
return RoomTreasureConfig{}, fmt.Errorf("room service client is not configured")
|
||||
}
|
||||
config, err := normalizeConfig(RoomTreasureConfig{
|
||||
AppCode: appctx.FromContext(ctx),
|
||||
Enabled: req.Enabled,
|
||||
ConfigVersion: current.ConfigVersion + 1,
|
||||
EnergySource: req.EnergySource,
|
||||
OpenDelayMS: req.OpenDelayMS,
|
||||
BroadcastEnabled: req.BroadcastEnabled,
|
||||
@ -182,70 +118,18 @@ func (s *Service) UpdateConfig(ctx context.Context, req updateRoomTreasureConfig
|
||||
RewardStackPolicy: req.RewardStackPolicy,
|
||||
Levels: req.Levels,
|
||||
GiftEnergyRules: req.GiftEnergyRules,
|
||||
UpdatedByAdminID: operatorAdminID,
|
||||
CreatedAtMS: createdAtMS,
|
||||
UpdatedAtMS: nowMS,
|
||||
})
|
||||
if err != nil {
|
||||
return RoomTreasureConfig{}, err
|
||||
}
|
||||
rawLevels, err := json.Marshal(config.Levels)
|
||||
saved, err := s.roomClient.UpdateRoomTreasureConfig(ctx, roomclient.UpdateRoomTreasureConfigRequest{
|
||||
Config: configToClient(config),
|
||||
AdminID: operatorAdminID,
|
||||
})
|
||||
if err != nil {
|
||||
return RoomTreasureConfig{}, err
|
||||
}
|
||||
rawGiftEnergyRules, err := json.Marshal(config.GiftEnergyRules)
|
||||
if err != nil {
|
||||
return RoomTreasureConfig{}, err
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO room_treasure_configs (
|
||||
app_code,
|
||||
enabled,
|
||||
config_version,
|
||||
energy_source,
|
||||
open_delay_ms,
|
||||
broadcast_enabled,
|
||||
broadcast_scope,
|
||||
broadcast_delay_ms,
|
||||
reward_stack_policy,
|
||||
levels_json,
|
||||
gift_energy_rules_json,
|
||||
updated_by_admin_id,
|
||||
created_at_ms,
|
||||
updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
enabled = VALUES(enabled),
|
||||
config_version = VALUES(config_version),
|
||||
energy_source = VALUES(energy_source),
|
||||
open_delay_ms = VALUES(open_delay_ms),
|
||||
broadcast_enabled = VALUES(broadcast_enabled),
|
||||
broadcast_scope = VALUES(broadcast_scope),
|
||||
broadcast_delay_ms = VALUES(broadcast_delay_ms),
|
||||
reward_stack_policy = VALUES(reward_stack_policy),
|
||||
levels_json = VALUES(levels_json),
|
||||
gift_energy_rules_json = VALUES(gift_energy_rules_json),
|
||||
updated_by_admin_id = VALUES(updated_by_admin_id),
|
||||
updated_at_ms = VALUES(updated_at_ms)
|
||||
`,
|
||||
config.AppCode,
|
||||
boolInt(config.Enabled),
|
||||
config.ConfigVersion,
|
||||
config.EnergySource,
|
||||
config.OpenDelayMS,
|
||||
boolInt(config.BroadcastEnabled),
|
||||
config.BroadcastScope,
|
||||
config.BroadcastDelayMS,
|
||||
config.RewardStackPolicy,
|
||||
string(rawLevels),
|
||||
string(rawGiftEnergyRules),
|
||||
config.UpdatedByAdminID,
|
||||
config.CreatedAtMS,
|
||||
config.UpdatedAtMS,
|
||||
); err != nil {
|
||||
return RoomTreasureConfig{}, err
|
||||
}
|
||||
return config, nil
|
||||
return configFromClient(saved)
|
||||
}
|
||||
|
||||
func defaultConfig(appCode string) RoomTreasureConfig {
|
||||
@ -398,13 +282,6 @@ func normalizeGiftEnergyRules(rules []GiftEnergyRuleConfig) ([]GiftEnergyRuleCon
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func boolInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func defaultString(value string, fallback string) string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
@ -420,3 +297,131 @@ func stringIn(value string, candidates ...string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func configFromClient(input roomclient.RoomTreasureConfig) (RoomTreasureConfig, error) {
|
||||
config := RoomTreasureConfig{
|
||||
AppCode: input.AppCode,
|
||||
Enabled: input.Enabled,
|
||||
ConfigVersion: input.ConfigVersion,
|
||||
EnergySource: input.EnergySource,
|
||||
OpenDelayMS: input.OpenDelayMS,
|
||||
BroadcastEnabled: input.BroadcastEnabled,
|
||||
BroadcastScope: input.BroadcastScope,
|
||||
BroadcastDelayMS: input.BroadcastDelayMS,
|
||||
RewardStackPolicy: input.RewardStackPolicy,
|
||||
Levels: levelsFromClient(input.Levels),
|
||||
GiftEnergyRules: energyRulesFromClient(input.GiftEnergyRules),
|
||||
UpdatedByAdminID: input.UpdatedByAdminID,
|
||||
CreatedAtMS: input.CreatedAtMS,
|
||||
UpdatedAtMS: input.UpdatedAtMS,
|
||||
}
|
||||
return normalizeConfig(config)
|
||||
}
|
||||
|
||||
func configToClient(input RoomTreasureConfig) roomclient.RoomTreasureConfig {
|
||||
return roomclient.RoomTreasureConfig{
|
||||
Enabled: input.Enabled,
|
||||
EnergySource: input.EnergySource,
|
||||
OpenDelayMS: input.OpenDelayMS,
|
||||
BroadcastEnabled: input.BroadcastEnabled,
|
||||
BroadcastScope: input.BroadcastScope,
|
||||
BroadcastDelayMS: input.BroadcastDelayMS,
|
||||
RewardStackPolicy: input.RewardStackPolicy,
|
||||
Levels: levelsToClient(input.Levels),
|
||||
GiftEnergyRules: energyRulesToClient(input.GiftEnergyRules),
|
||||
}
|
||||
}
|
||||
|
||||
func levelsFromClient(input []roomclient.RoomTreasureLevelConfig) []RoomTreasureLevelConfig {
|
||||
out := make([]RoomTreasureLevelConfig, 0, len(input))
|
||||
for _, level := range input {
|
||||
out = append(out, RoomTreasureLevelConfig{
|
||||
Level: level.Level,
|
||||
EnergyThreshold: level.EnergyThreshold,
|
||||
CoverURL: level.CoverURL,
|
||||
AnimationURL: level.AnimationURL,
|
||||
OpeningAnimationURL: level.OpeningAnimationURL,
|
||||
OpenedImageURL: level.OpenedImageURL,
|
||||
InRoomRewards: rewardsFromClient(level.InRoomRewards),
|
||||
Top1Rewards: rewardsFromClient(level.Top1Rewards),
|
||||
IgniterRewards: rewardsFromClient(level.IgniterRewards),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func levelsToClient(input []RoomTreasureLevelConfig) []roomclient.RoomTreasureLevelConfig {
|
||||
out := make([]roomclient.RoomTreasureLevelConfig, 0, len(input))
|
||||
for _, level := range input {
|
||||
out = append(out, roomclient.RoomTreasureLevelConfig{
|
||||
Level: level.Level,
|
||||
EnergyThreshold: level.EnergyThreshold,
|
||||
CoverURL: level.CoverURL,
|
||||
AnimationURL: level.AnimationURL,
|
||||
OpeningAnimationURL: level.OpeningAnimationURL,
|
||||
OpenedImageURL: level.OpenedImageURL,
|
||||
InRoomRewards: rewardsToClient(level.InRoomRewards),
|
||||
Top1Rewards: rewardsToClient(level.Top1Rewards),
|
||||
IgniterRewards: rewardsToClient(level.IgniterRewards),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func rewardsFromClient(input []roomclient.RoomTreasureRewardItem) []RoomTreasureRewardItem {
|
||||
out := make([]RoomTreasureRewardItem, 0, len(input))
|
||||
for _, reward := range input {
|
||||
out = append(out, RoomTreasureRewardItem{
|
||||
RewardItemID: reward.RewardItemID,
|
||||
ResourceGroupID: reward.ResourceGroupID,
|
||||
Weight: reward.Weight,
|
||||
DisplayName: reward.DisplayName,
|
||||
IconURL: reward.IconURL,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func rewardsToClient(input []RoomTreasureRewardItem) []roomclient.RoomTreasureRewardItem {
|
||||
out := make([]roomclient.RoomTreasureRewardItem, 0, len(input))
|
||||
for _, reward := range input {
|
||||
out = append(out, roomclient.RoomTreasureRewardItem{
|
||||
RewardItemID: reward.RewardItemID,
|
||||
ResourceGroupID: reward.ResourceGroupID,
|
||||
Weight: reward.Weight,
|
||||
DisplayName: reward.DisplayName,
|
||||
IconURL: reward.IconURL,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func energyRulesFromClient(input []roomclient.GiftEnergyRuleConfig) []GiftEnergyRuleConfig {
|
||||
out := make([]GiftEnergyRuleConfig, 0, len(input))
|
||||
for _, rule := range input {
|
||||
out = append(out, GiftEnergyRuleConfig{
|
||||
RuleID: rule.RuleID,
|
||||
GiftID: rule.GiftID,
|
||||
GiftTypeCode: rule.GiftTypeCode,
|
||||
MultiplierPPM: rule.MultiplierPPM,
|
||||
FixedEnergy: rule.FixedEnergy,
|
||||
Excluded: rule.Excluded,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func energyRulesToClient(input []GiftEnergyRuleConfig) []roomclient.GiftEnergyRuleConfig {
|
||||
out := make([]roomclient.GiftEnergyRuleConfig, 0, len(input))
|
||||
for _, rule := range input {
|
||||
out = append(out, roomclient.GiftEnergyRuleConfig{
|
||||
RuleID: rule.RuleID,
|
||||
GiftID: rule.GiftID,
|
||||
GiftTypeCode: rule.GiftTypeCode,
|
||||
MultiplierPPM: rule.MultiplierPPM,
|
||||
FixedEnergy: rule.FixedEnergy,
|
||||
Excluded: rule.Excluded,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
88
server/admin/internal/modules/userleaderboard/handler.go
Normal file
88
server/admin/internal/modules/userleaderboard/handler.go
Normal file
@ -0,0 +1,88 @@
|
||||
package userleaderboard
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/integration/roomclient"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
"hyapp-admin-server/internal/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func New(walletDB *sql.DB, userDB *sql.DB, room roomclient.Client) *Handler {
|
||||
return &Handler{service: NewService(walletDB, userDB, room)}
|
||||
}
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
query, ok := parseQuery(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.service.List(c.Request.Context(), appctx.FromContext(c.Request.Context()), query)
|
||||
if err != nil {
|
||||
if errors.Is(err, errInvalidArgument) {
|
||||
response.BadRequest(c, "榜单参数不正确")
|
||||
return
|
||||
}
|
||||
response.ServerError(c, "获取用户榜单失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func parseQuery(c *gin.Context) (Query, bool) {
|
||||
options := shared.ListOptions(c)
|
||||
boardType := NormalizeBoardType(firstQuery(c, "board_type", "boardType", "type"))
|
||||
if boardType == "" {
|
||||
response.BadRequest(c, "榜单类型不正确")
|
||||
return Query{}, false
|
||||
}
|
||||
|
||||
period := NormalizePeriod(firstQuery(c, "period", "time_dimension", "timeDimension"))
|
||||
if period == "" {
|
||||
response.BadRequest(c, "时间维度不正确")
|
||||
return Query{}, false
|
||||
}
|
||||
|
||||
viewerUserID, ok := optionalInt64(firstQuery(c, "viewer_user_id", "viewerUserId", "user_id", "userId"))
|
||||
if !ok {
|
||||
response.BadRequest(c, "用户 ID 不正确")
|
||||
return Query{}, false
|
||||
}
|
||||
|
||||
return NormalizeQuery(Query{
|
||||
BoardType: boardType,
|
||||
Period: period,
|
||||
Page: options.Page,
|
||||
PageSize: options.PageSize,
|
||||
ViewerUserID: viewerUserID,
|
||||
}), true
|
||||
}
|
||||
|
||||
func optionalInt64(raw string) (int64, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, true
|
||||
}
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
return value, err == nil && value >= 0
|
||||
}
|
||||
|
||||
func firstQuery(c *gin.Context, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(c.Query(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
15
server/admin/internal/modules/userleaderboard/routes.go
Normal file
15
server/admin/internal/modules/userleaderboard/routes.go
Normal file
@ -0,0 +1,15 @@
|
||||
package userleaderboard
|
||||
|
||||
import (
|
||||
"hyapp-admin-server/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
|
||||
protected.GET("/admin/activity/user-leaderboards", middleware.RequirePermission("user-leaderboard:view"), h.List)
|
||||
}
|
||||
573
server/admin/internal/modules/userleaderboard/service.go
Normal file
573
server/admin/internal/modules/userleaderboard/service.go
Normal file
@ -0,0 +1,573 @@
|
||||
package userleaderboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/integration/roomclient"
|
||||
)
|
||||
|
||||
const (
|
||||
BoardTypeSent = "sent"
|
||||
BoardTypeReceived = "received"
|
||||
BoardTypeRoom = "room"
|
||||
|
||||
PeriodToday = "today"
|
||||
PeriodWeek = "week"
|
||||
PeriodMonth = "month"
|
||||
|
||||
maxPageSize = 100
|
||||
)
|
||||
|
||||
var errInvalidArgument = errors.New("invalid argument")
|
||||
|
||||
type Service struct {
|
||||
walletDB *sql.DB
|
||||
userDB *sql.DB
|
||||
room roomclient.Client
|
||||
}
|
||||
|
||||
type Query struct {
|
||||
BoardType string
|
||||
Period string
|
||||
Page int
|
||||
PageSize int
|
||||
ViewerUserID int64
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Items []Item `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
BoardType string `json:"board_type"`
|
||||
Period string `json:"period"`
|
||||
StartAtMS int64 `json:"start_at_ms"`
|
||||
EndAtMS int64 `json:"end_at_ms"`
|
||||
ServerTimeMS int64 `json:"server_time_ms"`
|
||||
MyRank *Item `json:"my_rank,omitempty"`
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
Rank int64 `json:"rank"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
RoomID string `json:"room_id,omitempty"`
|
||||
GiftValue int64 `json:"gift_value"`
|
||||
GiftCount int64 `json:"gift_count"`
|
||||
TransactionCount int64 `json:"transaction_count"`
|
||||
LastGiftAtMS int64 `json:"last_gift_at_ms"`
|
||||
User *User `json:"user,omitempty"`
|
||||
Room *Room `json:"room,omitempty"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
UserID string `json:"user_id"`
|
||||
DisplayUserID string `json:"display_user_id,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
}
|
||||
|
||||
type Room struct {
|
||||
RoomID string `json:"room_id"`
|
||||
RoomShortID string `json:"room_short_id,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
OwnerUserID string `json:"owner_user_id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type userProfile struct {
|
||||
UserID int64
|
||||
DisplayUserID string
|
||||
Username string
|
||||
Avatar string
|
||||
}
|
||||
|
||||
type roomProfile struct {
|
||||
RoomID string
|
||||
RoomShortID string
|
||||
Title string
|
||||
CoverURL string
|
||||
OwnerUserID int64
|
||||
Status string
|
||||
}
|
||||
|
||||
func NewService(walletDB *sql.DB, userDB *sql.DB, room roomclient.Client) *Service {
|
||||
return &Service{walletDB: walletDB, userDB: userDB, room: room}
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, appCode string, query Query) (Response, error) {
|
||||
query = NormalizeQuery(query)
|
||||
if query.BoardType == "" || query.Period == "" {
|
||||
return Response{}, errInvalidArgument
|
||||
}
|
||||
if s == nil || s.walletDB == nil {
|
||||
return Response{}, fmt.Errorf("wallet mysql is not configured")
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
start, end := periodWindow(query.Period, now)
|
||||
startMS := start.UnixMilli()
|
||||
endMS := end.UnixMilli()
|
||||
|
||||
total, err := s.count(ctx, appCode, query.BoardType, startMS, endMS)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
items, err := s.listItems(ctx, appCode, query.BoardType, startMS, endMS, query.PageSize, offset(query.Page, query.PageSize))
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
|
||||
var myRank *Item
|
||||
if query.ViewerUserID > 0 && query.BoardType != BoardTypeRoom {
|
||||
item, found, err := s.rankForUser(ctx, appCode, query.BoardType, startMS, endMS, query.ViewerUserID)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
if found {
|
||||
myRank = &item
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.enrich(ctx, appCode, query.BoardType, items, myRank); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
|
||||
return Response{
|
||||
Items: items,
|
||||
Total: total,
|
||||
Page: query.Page,
|
||||
PageSize: query.PageSize,
|
||||
BoardType: query.BoardType,
|
||||
Period: query.Period,
|
||||
StartAtMS: startMS,
|
||||
EndAtMS: endMS,
|
||||
ServerTimeMS: now.UnixMilli(),
|
||||
MyRank: myRank,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NormalizeBoardType(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "", "sent", "send", "sender", "gift_sent", "user_sent":
|
||||
return BoardTypeSent
|
||||
case "received", "receive", "receiver", "gift_received", "user_received":
|
||||
return BoardTypeReceived
|
||||
case "room", "rooms", "room_gift", "room_gifts":
|
||||
return BoardTypeRoom
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizePeriod(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "", "today", "day", "daily":
|
||||
return PeriodToday
|
||||
case "week", "weekly":
|
||||
return PeriodWeek
|
||||
case "month", "monthly":
|
||||
return PeriodMonth
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeQuery(query Query) Query {
|
||||
query.BoardType = NormalizeBoardType(query.BoardType)
|
||||
query.Period = NormalizePeriod(query.Period)
|
||||
if query.Page <= 0 {
|
||||
query.Page = 1
|
||||
}
|
||||
if query.PageSize <= 0 {
|
||||
query.PageSize = 20
|
||||
}
|
||||
if query.PageSize > maxPageSize {
|
||||
query.PageSize = maxPageSize
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func periodWindow(period string, now time.Time) (time.Time, time.Time) {
|
||||
utcNow := now.UTC()
|
||||
dayStart := time.Date(utcNow.Year(), utcNow.Month(), utcNow.Day(), 0, 0, 0, 0, time.UTC)
|
||||
switch period {
|
||||
case PeriodWeek:
|
||||
weekday := int(dayStart.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
return dayStart.AddDate(0, 0, 1-weekday), utcNow
|
||||
case PeriodMonth:
|
||||
return time.Date(utcNow.Year(), utcNow.Month(), 1, 0, 0, 0, 0, time.UTC), utcNow
|
||||
default:
|
||||
return dayStart, utcNow
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) count(ctx context.Context, appCode string, boardType string, startMS int64, endMS int64) (int64, error) {
|
||||
subject, predicate := leaderboardDimension(boardType)
|
||||
query := fmt.Sprintf(`
|
||||
WITH gift_facts AS (
|
||||
SELECT
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.sender_user_id')) AS SIGNED) AS sender_user_id,
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.target_user_id')) AS SIGNED) AS target_user_id,
|
||||
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.room_id')), '') AS room_id,
|
||||
COALESCE(
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_point_added')) AS SIGNED),
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_point_amount')) AS SIGNED),
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.charge_amount')) AS SIGNED),
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.coin_spent')) AS SIGNED),
|
||||
0
|
||||
) AS gift_value,
|
||||
COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_count')) AS SIGNED), 0) AS gift_count,
|
||||
created_at_ms
|
||||
FROM wallet_transactions
|
||||
WHERE app_code = ? AND biz_type = 'gift_debit' AND status = 'succeeded'
|
||||
AND created_at_ms >= ? AND created_at_ms < ?
|
||||
),
|
||||
aggregated AS (
|
||||
SELECT %s AS subject_id
|
||||
FROM gift_facts
|
||||
WHERE %s
|
||||
GROUP BY %s
|
||||
)
|
||||
SELECT COUNT(*) FROM aggregated`, subject, predicate, subject)
|
||||
|
||||
var total int64
|
||||
err := s.walletDB.QueryRowContext(ctx, query, appCode, startMS, endMS).Scan(&total)
|
||||
return total, err
|
||||
}
|
||||
|
||||
func (s *Service) listItems(ctx context.Context, appCode string, boardType string, startMS int64, endMS int64, limit int, offset int) ([]Item, error) {
|
||||
subject, predicate := leaderboardDimension(boardType)
|
||||
query := fmt.Sprintf(`
|
||||
WITH gift_facts AS (
|
||||
SELECT
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.sender_user_id')) AS SIGNED) AS sender_user_id,
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.target_user_id')) AS SIGNED) AS target_user_id,
|
||||
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.room_id')), '') AS room_id,
|
||||
COALESCE(
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_point_added')) AS SIGNED),
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_point_amount')) AS SIGNED),
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.charge_amount')) AS SIGNED),
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.coin_spent')) AS SIGNED),
|
||||
0
|
||||
) AS gift_value,
|
||||
COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_count')) AS SIGNED), 0) AS gift_count,
|
||||
created_at_ms
|
||||
FROM wallet_transactions
|
||||
WHERE app_code = ? AND biz_type = 'gift_debit' AND status = 'succeeded'
|
||||
AND created_at_ms >= ? AND created_at_ms < ?
|
||||
),
|
||||
aggregated AS (
|
||||
SELECT %s AS subject_id,
|
||||
SUM(gift_value) AS gift_value,
|
||||
SUM(gift_count) AS gift_count,
|
||||
COUNT(*) AS transaction_count,
|
||||
MAX(created_at_ms) AS last_gift_at_ms
|
||||
FROM gift_facts
|
||||
WHERE %s
|
||||
GROUP BY %s
|
||||
),
|
||||
ranked AS (
|
||||
SELECT ROW_NUMBER() OVER (ORDER BY gift_value DESC, last_gift_at_ms DESC, subject_id ASC) AS rank_no,
|
||||
subject_id, gift_value, gift_count, transaction_count, last_gift_at_ms
|
||||
FROM aggregated
|
||||
)
|
||||
SELECT rank_no, CAST(subject_id AS CHAR), gift_value, gift_count, transaction_count, last_gift_at_ms
|
||||
FROM ranked
|
||||
ORDER BY rank_no ASC
|
||||
LIMIT ? OFFSET ?`, subject, predicate, subject)
|
||||
|
||||
rows, err := s.walletDB.QueryContext(ctx, query, appCode, startMS, endMS, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]Item, 0, limit)
|
||||
for rows.Next() {
|
||||
item, err := scanItem(rows, boardType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) rankForUser(ctx context.Context, appCode string, boardType string, startMS int64, endMS int64, userID int64) (Item, bool, error) {
|
||||
subject, predicate := leaderboardDimension(boardType)
|
||||
query := fmt.Sprintf(`
|
||||
WITH gift_facts AS (
|
||||
SELECT
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.sender_user_id')) AS SIGNED) AS sender_user_id,
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.target_user_id')) AS SIGNED) AS target_user_id,
|
||||
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.room_id')), '') AS room_id,
|
||||
COALESCE(
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_point_added')) AS SIGNED),
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_point_amount')) AS SIGNED),
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.charge_amount')) AS SIGNED),
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.coin_spent')) AS SIGNED),
|
||||
0
|
||||
) AS gift_value,
|
||||
COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_count')) AS SIGNED), 0) AS gift_count,
|
||||
created_at_ms
|
||||
FROM wallet_transactions
|
||||
WHERE app_code = ? AND biz_type = 'gift_debit' AND status = 'succeeded'
|
||||
AND created_at_ms >= ? AND created_at_ms < ?
|
||||
),
|
||||
aggregated AS (
|
||||
SELECT %s AS subject_id,
|
||||
SUM(gift_value) AS gift_value,
|
||||
SUM(gift_count) AS gift_count,
|
||||
COUNT(*) AS transaction_count,
|
||||
MAX(created_at_ms) AS last_gift_at_ms
|
||||
FROM gift_facts
|
||||
WHERE %s
|
||||
GROUP BY %s
|
||||
),
|
||||
ranked AS (
|
||||
SELECT ROW_NUMBER() OVER (ORDER BY gift_value DESC, last_gift_at_ms DESC, subject_id ASC) AS rank_no,
|
||||
subject_id, gift_value, gift_count, transaction_count, last_gift_at_ms
|
||||
FROM aggregated
|
||||
)
|
||||
SELECT rank_no, CAST(subject_id AS CHAR), gift_value, gift_count, transaction_count, last_gift_at_ms
|
||||
FROM ranked
|
||||
WHERE subject_id = ?
|
||||
LIMIT 1`, subject, predicate, subject)
|
||||
|
||||
row := s.walletDB.QueryRowContext(ctx, query, appCode, startMS, endMS, userID)
|
||||
item, err := scanItem(row, boardType)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Item{}, false, nil
|
||||
}
|
||||
return Item{}, false, err
|
||||
}
|
||||
return item, true, nil
|
||||
}
|
||||
|
||||
type itemScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanItem(scanner itemScanner, boardType string) (Item, error) {
|
||||
var item Item
|
||||
var subject string
|
||||
if err := scanner.Scan(&item.Rank, &subject, &item.GiftValue, &item.GiftCount, &item.TransactionCount, &item.LastGiftAtMS); err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
if boardType == BoardTypeRoom {
|
||||
item.RoomID = subject
|
||||
item.Room = &Room{RoomID: subject}
|
||||
return item, nil
|
||||
}
|
||||
item.UserID = subject
|
||||
item.User = &User{UserID: subject}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func leaderboardDimension(boardType string) (string, string) {
|
||||
switch boardType {
|
||||
case BoardTypeReceived:
|
||||
return "target_user_id", "target_user_id > 0"
|
||||
case BoardTypeRoom:
|
||||
return "room_id", "room_id <> ''"
|
||||
default:
|
||||
return "sender_user_id", "sender_user_id > 0"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) enrich(ctx context.Context, appCode string, boardType string, items []Item, myRank *Item) error {
|
||||
if boardType == BoardTypeRoom {
|
||||
return s.enrichRooms(ctx, appCode, items)
|
||||
}
|
||||
|
||||
userIDs := make([]int64, 0, len(items)+1)
|
||||
for _, item := range items {
|
||||
if id, err := strconv.ParseInt(item.UserID, 10, 64); err == nil && id > 0 {
|
||||
userIDs = append(userIDs, id)
|
||||
}
|
||||
}
|
||||
if myRank != nil {
|
||||
if id, err := strconv.ParseInt(myRank.UserID, 10, 64); err == nil && id > 0 {
|
||||
userIDs = append(userIDs, id)
|
||||
}
|
||||
}
|
||||
profiles, err := s.userProfiles(ctx, appCode, userIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applyUserProfiles(items, profiles)
|
||||
if myRank != nil {
|
||||
applyUserProfile(myRank, profiles)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) userProfiles(ctx context.Context, appCode string, userIDs []int64) (map[int64]userProfile, error) {
|
||||
result := make(map[int64]userProfile, len(userIDs))
|
||||
if s == nil || s.userDB == nil || len(userIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
userIDs = uniqueInt64s(userIDs)
|
||||
args := make([]any, 0, len(userIDs)+1)
|
||||
args = append(args, appCode)
|
||||
for _, id := range userIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(`
|
||||
SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '')
|
||||
FROM users
|
||||
WHERE app_code = ? AND user_id IN (%s)
|
||||
`, placeholders(len(userIDs))), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var profile userProfile
|
||||
if err := rows.Scan(&profile.UserID, &profile.DisplayUserID, &profile.Username, &profile.Avatar); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[profile.UserID] = profile
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func applyUserProfiles(items []Item, profiles map[int64]userProfile) {
|
||||
for i := range items {
|
||||
applyUserProfile(&items[i], profiles)
|
||||
}
|
||||
}
|
||||
|
||||
func applyUserProfile(item *Item, profiles map[int64]userProfile) {
|
||||
if item == nil {
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(item.UserID, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return
|
||||
}
|
||||
if profile, ok := profiles[id]; ok {
|
||||
item.User = &User{
|
||||
UserID: strconv.FormatInt(profile.UserID, 10),
|
||||
DisplayUserID: profile.DisplayUserID,
|
||||
Username: profile.Username,
|
||||
Avatar: profile.Avatar,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) enrichRooms(ctx context.Context, appCode string, items []Item) error {
|
||||
roomIDs := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.RoomID != "" {
|
||||
roomIDs = append(roomIDs, item.RoomID)
|
||||
}
|
||||
}
|
||||
rooms, err := s.roomProfiles(ctx, appCode, roomIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range items {
|
||||
if room, ok := rooms[items[i].RoomID]; ok {
|
||||
items[i].Room = &Room{
|
||||
RoomID: room.RoomID,
|
||||
RoomShortID: room.RoomShortID,
|
||||
Title: room.Title,
|
||||
CoverURL: room.CoverURL,
|
||||
OwnerUserID: formatOptionalID(room.OwnerUserID),
|
||||
Status: room.Status,
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) roomProfiles(ctx context.Context, appCode string, roomIDs []string) (map[string]roomProfile, error) {
|
||||
result := make(map[string]roomProfile, len(roomIDs))
|
||||
if s == nil || s.room == nil || len(roomIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
roomIDs = uniqueStrings(roomIDs)
|
||||
_ = appCode
|
||||
for _, id := range roomIDs {
|
||||
room, err := s.room.GetRoom(ctx, roomclient.GetRoomRequest{RoomID: id})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[room.RoomID] = roomProfile{
|
||||
RoomID: room.RoomID,
|
||||
RoomShortID: room.RoomShortID,
|
||||
Title: room.Title,
|
||||
CoverURL: room.CoverURL,
|
||||
OwnerUserID: room.OwnerUserID,
|
||||
Status: room.Status,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func offset(page int, pageSize int) int {
|
||||
if page <= 1 {
|
||||
return 0
|
||||
}
|
||||
return (page - 1) * pageSize
|
||||
}
|
||||
|
||||
func placeholders(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
|
||||
}
|
||||
|
||||
func uniqueInt64s(values []int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(values))
|
||||
out := make([]int64, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func formatOptionalID(id int64) string {
|
||||
if id <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatInt(id, 10)
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
package userleaderboard
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPeriodWindowUsesUTCBoundaries(t *testing.T) {
|
||||
now := time.Date(2026, 5, 20, 15, 30, 0, 0, time.FixedZone("CST", 8*60*60))
|
||||
|
||||
todayStart, todayEnd := periodWindow(PeriodToday, now)
|
||||
if todayStart != time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC) {
|
||||
t.Fatalf("today start = %s", todayStart)
|
||||
}
|
||||
if !todayEnd.Equal(now.UTC()) {
|
||||
t.Fatalf("today end = %s", todayEnd)
|
||||
}
|
||||
|
||||
weekStart, _ := periodWindow(PeriodWeek, now)
|
||||
if weekStart != time.Date(2026, 5, 18, 0, 0, 0, 0, time.UTC) {
|
||||
t.Fatalf("week start = %s", weekStart)
|
||||
}
|
||||
|
||||
monthStart, _ := periodWindow(PeriodMonth, now)
|
||||
if monthStart != time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) {
|
||||
t.Fatalf("month start = %s", monthStart)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeQueryDefaultsAndCapsPageSize(t *testing.T) {
|
||||
query := NormalizeQuery(Query{BoardType: "receiver", Period: "monthly", PageSize: 500})
|
||||
if query.BoardType != BoardTypeReceived || query.Period != PeriodMonth {
|
||||
t.Fatalf("query = %+v", query)
|
||||
}
|
||||
if query.Page != 1 || query.PageSize != maxPageSize {
|
||||
t.Fatalf("pagination = page %d pageSize %d", query.Page, query.PageSize)
|
||||
}
|
||||
}
|
||||
247
server/admin/internal/modules/vipconfig/handler.go
Normal file
247
server/admin/internal/modules/vipconfig/handler.go
Normal file
@ -0,0 +1,247 @@
|
||||
package vipconfig
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/integration/walletclient"
|
||||
"hyapp-admin-server/internal/middleware"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
"hyapp-admin-server/internal/response"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
wallet walletclient.Client
|
||||
audit shared.OperationLogger
|
||||
}
|
||||
|
||||
func New(wallet walletclient.Client, audit shared.OperationLogger) *Handler {
|
||||
return &Handler{wallet: wallet, audit: audit}
|
||||
}
|
||||
|
||||
type configRequest struct {
|
||||
Levels []vipLevelDTO `json:"levels"`
|
||||
}
|
||||
|
||||
type grantVipRequest struct {
|
||||
CommandID string `json:"commandId"`
|
||||
TargetUserID any `json:"targetUserId"`
|
||||
Level int32 `json:"level"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type configDTO struct {
|
||||
Levels []vipLevelDTO `json:"levels"`
|
||||
ServerTimeMS int64 `json:"serverTimeMs"`
|
||||
}
|
||||
|
||||
type grantVipDTO struct {
|
||||
TransactionID string `json:"transactionId"`
|
||||
VIP userVipDTO `json:"vip"`
|
||||
RewardItems []vipRewardItemDTO `json:"rewardItems"`
|
||||
ServerTimeMS int64 `json:"serverTimeMs"`
|
||||
}
|
||||
|
||||
type userVipDTO struct {
|
||||
UserID string `json:"userId"`
|
||||
Level int32 `json:"level"`
|
||||
Name string `json:"name"`
|
||||
Active bool `json:"active"`
|
||||
StartedAtMS int64 `json:"startedAtMs"`
|
||||
ExpiresAtMS int64 `json:"expiresAtMs"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
type vipLevelDTO struct {
|
||||
Level int32 `json:"level"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
PriceCoin int64 `json:"priceCoin"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
RewardResourceGroupID int64 `json:"rewardResourceGroupId"`
|
||||
SortOrder int32 `json:"sortOrder"`
|
||||
RechargeGateRequired bool `json:"rechargeGateRequired"`
|
||||
RequiredRechargeCoinAmount int64 `json:"requiredRechargeCoinAmount"`
|
||||
UserRechargeCoinAmount int64 `json:"userRechargeCoinAmount"`
|
||||
PurchaseLockedReason string `json:"purchaseLockedReason"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
type vipRewardItemDTO struct {
|
||||
ResourceID string `json:"resourceId"`
|
||||
ResourceCode string `json:"resourceCode"`
|
||||
ResourceType string `json:"resourceType"`
|
||||
Name string `json:"name"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
ExpiresAtMS int64 `json:"expiresAtMs"`
|
||||
}
|
||||
|
||||
func (h *Handler) ListLevels(c *gin.Context) {
|
||||
resp, err := h.wallet.ListAdminVipLevels(c.Request.Context(), &walletv1.ListAdminVipLevelsRequest{
|
||||
RequestId: middleware.CurrentRequestID(c),
|
||||
AppCode: appctx.FromContext(c.Request.Context()),
|
||||
})
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取 VIP 配置失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, configDTO{Levels: vipLevelsFromProto(resp.GetLevels()), ServerTimeMS: resp.GetServerTimeMs()})
|
||||
}
|
||||
|
||||
func (h *Handler) GrantVIP(c *gin.Context) {
|
||||
var req grantVipRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "VIP 赠送参数不正确")
|
||||
return
|
||||
}
|
||||
targetUserID, ok := parseFlexibleUserID(req.TargetUserID)
|
||||
if !ok || req.Level <= 0 {
|
||||
response.BadRequest(c, "VIP 赠送参数不正确")
|
||||
return
|
||||
}
|
||||
reason := strings.TrimSpace(req.Reason)
|
||||
if reason == "" {
|
||||
response.BadRequest(c, "赠送原因不能为空")
|
||||
return
|
||||
}
|
||||
commandID := strings.TrimSpace(req.CommandID)
|
||||
if commandID == "" {
|
||||
commandID = "admin_vip_grant:" + middleware.CurrentRequestID(c)
|
||||
}
|
||||
resp, err := h.wallet.GrantVip(c.Request.Context(), &walletv1.GrantVipRequest{
|
||||
CommandId: commandID,
|
||||
AppCode: appctx.FromContext(c.Request.Context()),
|
||||
TargetUserId: targetUserID,
|
||||
Level: req.Level,
|
||||
GrantSource: "admin_grant",
|
||||
OperatorUserId: int64(middleware.CurrentUserID(c)),
|
||||
Reason: reason,
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
shared.OperationLogWithResourceID(c, h.audit, "grant-vip", "user_vip_memberships", strconv.FormatInt(targetUserID, 10), "success", "level="+strconv.Itoa(int(req.Level)))
|
||||
response.Created(c, grantVipFromProto(resp))
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateLevels(c *gin.Context) {
|
||||
var req configRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "VIP 配置参数不正确")
|
||||
return
|
||||
}
|
||||
input := make([]*walletv1.AdminVipLevelInput, 0, len(req.Levels))
|
||||
for _, level := range req.Levels {
|
||||
input = append(input, &walletv1.AdminVipLevelInput{
|
||||
Level: level.Level,
|
||||
Name: strings.TrimSpace(level.Name),
|
||||
Status: strings.TrimSpace(level.Status),
|
||||
PriceCoin: level.PriceCoin,
|
||||
DurationMs: level.DurationMS,
|
||||
RewardResourceGroupId: level.RewardResourceGroupID,
|
||||
RequiredRechargeCoinAmount: level.RequiredRechargeCoinAmount,
|
||||
SortOrder: level.SortOrder,
|
||||
})
|
||||
}
|
||||
resp, err := h.wallet.UpdateAdminVipLevels(c.Request.Context(), &walletv1.UpdateAdminVipLevelsRequest{
|
||||
RequestId: middleware.CurrentRequestID(c),
|
||||
AppCode: appctx.FromContext(c.Request.Context()),
|
||||
Levels: input,
|
||||
OperatorUserId: int64(middleware.CurrentUserID(c)),
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
shared.OperationLogWithResourceID(c, h.audit, "update-vip-config", "vip_levels", appctx.FromContext(c.Request.Context()), "success", strconv.Itoa(len(resp.GetLevels())))
|
||||
response.OK(c, configDTO{Levels: vipLevelsFromProto(resp.GetLevels()), ServerTimeMS: resp.GetServerTimeMs()})
|
||||
}
|
||||
|
||||
func vipLevelsFromProto(items []*walletv1.VipLevel) []vipLevelDTO {
|
||||
levels := make([]vipLevelDTO, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
levels = append(levels, vipLevelDTO{
|
||||
Level: item.GetLevel(),
|
||||
Name: item.GetName(),
|
||||
Status: item.GetStatus(),
|
||||
PriceCoin: item.GetPriceCoin(),
|
||||
DurationMS: item.GetDurationMs(),
|
||||
RewardResourceGroupID: item.GetRewardResourceGroupId(),
|
||||
SortOrder: item.GetSortOrder(),
|
||||
RechargeGateRequired: item.GetRechargeGateRequired(),
|
||||
RequiredRechargeCoinAmount: item.GetRequiredRechargeCoinAmount(),
|
||||
UserRechargeCoinAmount: item.GetUserRechargeCoinAmount(),
|
||||
PurchaseLockedReason: item.GetPurchaseLockedReason(),
|
||||
CreatedAtMS: item.GetCreatedAtMs(),
|
||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||
})
|
||||
}
|
||||
return levels
|
||||
}
|
||||
|
||||
func grantVipFromProto(resp *walletv1.GrantVipResponse) grantVipDTO {
|
||||
if resp == nil {
|
||||
return grantVipDTO{}
|
||||
}
|
||||
return grantVipDTO{
|
||||
TransactionID: resp.GetTransactionId(),
|
||||
VIP: userVipFromProto(resp.GetVip()),
|
||||
RewardItems: vipRewardItemsFromProto(resp.GetRewardItems()),
|
||||
ServerTimeMS: resp.GetServerTimeMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func userVipFromProto(item *walletv1.UserVip) userVipDTO {
|
||||
if item == nil {
|
||||
return userVipDTO{}
|
||||
}
|
||||
return userVipDTO{
|
||||
UserID: strconv.FormatInt(item.GetUserId(), 10),
|
||||
Level: item.GetLevel(),
|
||||
Name: item.GetName(),
|
||||
Active: item.GetActive(),
|
||||
StartedAtMS: item.GetStartedAtMs(),
|
||||
ExpiresAtMS: item.GetExpiresAtMs(),
|
||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func vipRewardItemsFromProto(items []*walletv1.VipRewardItem) []vipRewardItemDTO {
|
||||
rewards := make([]vipRewardItemDTO, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
rewards = append(rewards, vipRewardItemDTO{
|
||||
ResourceID: strconv.FormatInt(item.GetResourceId(), 10),
|
||||
ResourceCode: item.GetResourceCode(),
|
||||
ResourceType: item.GetResourceType(),
|
||||
Name: item.GetName(),
|
||||
Quantity: item.GetQuantity(),
|
||||
ExpiresAtMS: item.GetExpiresAtMs(),
|
||||
})
|
||||
}
|
||||
return rewards
|
||||
}
|
||||
|
||||
func parseFlexibleUserID(value any) (int64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
userID := int64(typed)
|
||||
return userID, userID > 0 && float64(userID) == typed
|
||||
case string:
|
||||
userID, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||||
return userID, err == nil && userID > 0
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
17
server/admin/internal/modules/vipconfig/routes.go
Normal file
17
server/admin/internal/modules/vipconfig/routes.go
Normal file
@ -0,0 +1,17 @@
|
||||
package vipconfig
|
||||
|
||||
import (
|
||||
"hyapp-admin-server/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
|
||||
protected.GET("/admin/activity/vip-levels", middleware.RequirePermission("vip-config:view"), h.ListLevels)
|
||||
protected.PUT("/admin/activity/vip-levels", middleware.RequirePermission("vip-config:update"), h.UpdateLevels)
|
||||
protected.POST("/admin/activity/vip-grants", middleware.RequirePermission("vip-config:grant"), h.GrantVIP)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -65,6 +65,7 @@ func (s *Store) AutoMigrate() error {
|
||||
&model.AppConfig{},
|
||||
&model.AppBanner{},
|
||||
&model.AppVersion{},
|
||||
&model.AppExploreTab{},
|
||||
&model.RefreshToken{},
|
||||
&model.LoginLog{},
|
||||
&model.OperationLog{},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user