hyapp-server/README.md
2026-07-06 12:43:40 +08:00

283 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# HY Voice Room
HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连接服务:房间业务状态由 `room-service` 承担,客户端实时消息接入腾讯云 IM服务端负责签发 UserSig、房间群消息桥接以及通过 `notice-service` 异步投递用户私有通知。
## Architecture
核心边界:
- `gateway-service` 是客户端 HTTP JSON 入口,负责鉴权、协议转换、签发腾讯云 IM UserSig以及调用内部 gRPC 服务。
- `room-service` 是房间业务状态 owner负责 Room Cell、麦位、房间 presence、本地礼物榜、送礼落房间、语音房火箭、snapshot、command log、outbox、Redis lease、RocketMQ room outbox relay以及腾讯云 IM 群组/系统消息桥接。
- `wallet-service` 当前提供 `DebitGift` 送礼扣费语义,余额、流水和幂等记录落 MySQL`room-service``SendGift` 同步调用它。
- `user-service` 当前承接登录、三方注册、密码、refresh session、用户主数据、默认短号和临时靓号基础能力。
- `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`
协议边界:
- 客户端命令面:`gateway-service` 暴露 HTTP JSON。
- 客户端实时面:腾讯云 IM SDK客户端用 gateway 签发的 UserSig 登录。
- 服务内部调用gRPC + protobuf契约在独立 Go module `api`,业务代码通过 `hyapp.local/api/proto/...` 引用生成包。
- 房间外事件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 产品需求,否则不暴露后台数据大屏统计。
App 系统/活动消息:
- Flutter 消息页固定展示腾讯云 IM 用户消息、后端系统消息和后端活动消息三个入口;系统/活动页通过 `gateway-service` 读取 `activity-service` 的 inbox只按当前客户端能力展示标题、摘要/正文和时间。
- `activity-service/internal/service/message` 是系统/活动消息 owner。单人消息使用 `CreateSystemNotice` / `CreateActivityNotice`,全服或范围通知使用 `CreateNoticeFanout` 创建 `message_fanout_jobs`,由 cron 触发 fanout worker 分批写入 `user_inbox_messages`
- 活动业务发通知时传 `producer_event_id``command_id` 作为幂等键,`message_type` 只允许 `system``activity`;跳转参数使用已有 `action_type` 白名单,例如 `activity_detail``room_detail``user_profile``app_h5`
- `server/admin` 的 APP配置/系统消息推送只通过 `MessageInboxService.CreateFanoutJob` 创建后台 fanout 命令,不直接写 `hyapp_activity` 表,也不在 HTTP 请求里同步遍历用户。
## Time Model
项目全局时间模型固定为 UTC + epoch milliseconds。除客户端展示外App 后端所有按天、按周、按月归档或结算的业务口径都按 UTC不按中国时区、服务器本地时区、用户设备时区或 IP 推断时区切分。
- 跨服务、数据库和 HTTP/gRPC 响应里的业务时间统一使用 Unix epoch milliseconds字段名保持 `*_ms`,例如 `created_at_ms``expire_at_ms``start_at_ms``end_at_ms``next_refresh_at_ms`
- 后端业务自然日统一按 UTC 00:00:00 切日。`task_day``reward_day``checkin_day``stat_day``budget_day` 都是 UTC `YYYY-MM-DD``next_refresh_at_ms` 是下一次 UTC 00:00:00 的毫秒时间戳。
- App 每日任务和七日签到按 UTC 日刷新。任务事件归属到 `occurred_at_ms` 所在 UTC 日;七日签到连续性只比较今天和昨天的 UTC 日期。
- 日榜、周榜、月榜按 UTC 周期计算。`today/day/daily` 是 UTC 当天,`week/weekly` 从 UTC 周一 00:00 开始,`month/monthly` 从 UTC 月初 00:00 开始;后台用户送礼榜按 `[周期开始, 当前 UTC 时间)` 查询,房间礼物榜 Redis key 也按 UTC 周期开始时间生成。
- 统计和账务时间范围统一使用 `[start_ms, end_ms)`,也就是包含开始、不包含结束。查询“昨天充值多少”时,调用方必须先把“昨天”转换成明确的 UTC 毫秒范围,再交给后端查询。
- 定时任务由 `cron-service``interval` 轮询不使用本地时区表达“几点运行”。任务运行记录、lease 和传给 owner service 的 `now_ms` 都是 UTC epoch milliseconds。
- 工资和结算周期按 UTC 月锚定。主播工资周期键 `cycle_key` 是 UTC `YYYY-MM`;主播日结、半月结和月底清算由 cron 触发后在 `wallet-service` 内按这个周期键幂等结算。团队 BD/Admin 自动结算在后台按 UTC 每月 5 日判断,结算上一个 UTC 自然月。
- MySQL DSN 必须使用 `loc=UTC`。Docker 环境设置 `TZ=UTC`MySQL 使用 `--default-time-zone=+00:00`,避免依赖服务器所在地或容器本地时区。
- 用户所在时区、设备时区或 IP 推断时区只能用于展示、风控或资料字段;不能作为结算、统计、任务刷新和幂等周期的业务切日来源。
## Ports
本地和 Docker 编排统一使用 `13xxx` 端口:
| Component | Port | Purpose |
| ------------------ | --------------: | ----------------- |
| `gateway-service` | `13000` | HTTP JSON |
| `room-service` | `13001` | gRPC |
| `wallet-service` | `13004` | gRPC |
| `user-service` | `13005` | gRPC |
| `activity-service` | `13006` | gRPC |
| `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:
```bash
make up
```
Service aliases: `gs/gateway`, `rs/room`, `ws/wallet`, `us/user`, `as/activity`, `cs/cron`, `game/games`, `ns/notice`, `mysql`, `redis`, plus full service names.
Common commands:
```bash
make test
make build
make admin-up
make admin
make addr
make logs
make rebuild rs
make down
```
`make admin-up` 会先启动后台本地依赖 MySQL、Redis 和 `user-service`,再以前台进程启动 `server/admin`。依赖已经存在时,可以直接运行 `make admin`
For direct host execution, each service reads its own `services/<service>/configs/config.yaml`; `room-service``user-service``wallet-service``activity-service``cron-service``game-service``notice-service` 都要求 MySQL 可用。
## Tencent IM
客户端获取腾讯云 IM 登录票据:
```text
GET /api/v1/im/usersig
Authorization: Bearer <access_token>
```
响应 `data` 包含:
```json
{
"sdk_app_id": 1400000000,
"user_id": "10001",
"user_sig": "xxx",
"expire_at_ms": 1710000000000
}
```
房间流程:
- 客户端先经 gateway 调 `JoinRoom`,让用户进入 `room-service` presence。
- 客户端再用腾讯云 IM SDK 登录并加入 `room_id` 对应的群组。
- `gateway-service` 创建房间时从 access token 取当前 `user_id` 写入 `RequestMeta.actor_user_id``room-service` 由该 actor 收敛 owner外部 CreateRoom 请求体不接收 `owner_user_id`
- `X-Request-ID`/`request_id` 由后端生成,只做链路追踪;写操作的 `command_id` 由前端按用户动作生成,用于服务端幂等。
- `room-service``CreateRoom` 成功后写 `room_outbox`,由 outbox worker 异步创建或补偿同名腾讯云 IM 群组。
- `room-service` 在上下麦、禁言、踢人、送礼、火箭进度和发射等命令提交后只写房间状态、command log 和 outbox腾讯云 IM 房间系统自定义消息由 outbox worker direct 投递,或由 IM bridge consumer 从 RocketMQ 消费后投递。
- `notice-service` 消费钱包余额变更 outbox 和房间踢人 outbox向单个用户发送 `wallet_notice``room_notice` 私有自定义消息owner service 不直接调用腾讯云 IM 单聊。
- `gateway-service` 已提供 `/api/v1/tencent-im/callback`,支持腾讯云 IM 入群前和发言前回调,并分别回查 `VerifyRoomPresence``CheckSpeakPermission`
腾讯云 IM 配置分别在:
```text
services/gateway-service/configs/config.yaml
services/room-service/configs/config.yaml
services/room-service/configs/config.tencent.example.yaml
services/notice-service/configs/config.yaml
services/notice-service/configs/config.tencent.example.yaml
```
本地联调腾讯云 IM 时,`gateway-service``room-service``notice-service` 必须使用同一组 `sdk_app_id``secret_key`;所有服务端 REST 调用方还必须配置匹配的 `admin_identifier` 和区域 `endpoint`
基础闭环实现顺序见:
```text
docs/语音房基础闭环实现.md
docs/notice-service架构.md
docs/flutter对接/Flutter App 通知与钱包余额对接.md
```
## RocketMQ
本地默认不依赖 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
| Topic | Producer | Consumer Group | 用途 |
| --- | --- | --- | --- |
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-activity-room-outbox` | activity-service 消费 `RoomGiftSent``RoomRocketIgnited` 等房间事实 |
| `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_rocket_launch` | `hyapp-room-rocket-launch-producer` | `hyapp-room-rocket-launch` | 火箭点火后到点唤醒 room-service 发射命令 |
RocketMQ 不拥有业务状态。`room_outbox``wallet_outbox``user_outbox``game_outbox` 仍是各 owner service MySQL 内的可靠事实源MQ 发布失败时 outbox worker 退避重试,到达重试上限后进入死信。火箭延迟消息只负责唤醒,到点后 room-service 会重新校验 `rocket_id`、等级、`launch_at_ms`、状态和 UTC 重置边界。
## Storage Model
MySQL 是本地和线上运行的持久化底座:
- `room-service`: `hyapp_room`,保存 room meta、snapshot、command log 和 outbox业务行按 `app_code` 隔离。
- `user-service`: `hyapp_user`,保存 `apps` 注册表、用户主数据、注册资料快照、登录身份、session、默认短号、靓号租约和 host 关系事实,业务行按 `app_code` 隔离。
- `wallet-service`: `hyapp_wallet`,保存余额、账务流水、礼物价格和扣费幂等记录,业务行按 `app_code` 隔离。
- `activity-service`: `hyapp_activity`,保存任务定义、任务进度、奖励领取、活动状态、事件消费幂等和 activity outbox业务行按 `app_code` 隔离。
- `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:
- `rooms`: room metadata.
- `room_snapshots`: latest room state snapshot.
- `room_command_log`: replayable command log for recovery.
- `room_outbox`: async event records.
`room-service` uses Redis for current room ownership:
```text
room:route:{app_code + "\0" + room_id}
room:node:{node_id}
```
Redis lease 只保存当前执行权和节点转发地址,不能作为恢复来源。恢复来源是 MySQL snapshot + command log。
## Room Recovery
A room is not permanently bound to one process. A node is only the current executor while its Redis lease is valid.
在多机部署下gateway 可以把 room gRPC 请求打到 `room-service` 内网 CLB。非 owner 节点会读取 `room:route:*``room:node:*`把房间命令、IM guard 和完整快照查询转发到当前 owner 实例。每个 room 节点必须配置唯一 `node_id` 和实例级 `advertise_addr``advertise_addr` 不能写负载均衡地址。无 Kubernetes 的部署步骤见 `docs/多机部署与无感发布.md`
Recovery path:
1. A request reaches a node that can own the room.
2. The node acquires or renews `room:route:{room_id}`.
3. If the local Room Cell does not exist, it loads the latest MySQL snapshot.
4. It replays command log records after the snapshot version.
5. It installs a local Room Cell and continues processing.
This is single-active recovery, not hot standby. Short retry/reconnect windows are expected during node failure.
## Project Layout
```text
go.work local workspace for app backend, api contracts and admin backend
api/ independent Go module, protobuf contracts and generated code
pkg/ app backend shared packages, including Tencent IM signing/client
server/admin/ independent admin backend module
services/gateway-service/ HTTP entry service
services/room-service/ Room Cell and durable room state
services/wallet-service/ wallet DebitGift and ledger foundation
services/user-service/ user service
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
- 腾讯云 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线上启用钱包通知或房间通知 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 等本地配置只能用开发值,线上必须走安全配置源。