# 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` 读取和发布；`lucky_gift_outbox` 只由 `lucky-gift-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_lucky_gift_outbox` | `hyapp-lucky-gift-outbox-producer` | `hyapp-room-lucky-gift-outbox` | room-service 消费已到账的 `LuckyGiftDrawn`，以本地 `room_outbox` 幂等投递房间顶部飘屏 |
| `hyapp_lucky_gift_outbox` | `hyapp-lucky-gift-outbox-producer` | `hyapp-activity-lucky-gift-outbox` | activity-service 消费已到账的 `LuckyGiftDrawn`，以本地 `im_broadcast_outbox` 幂等投递区域大赢家飘屏 |
| `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`、`lucky_gift_outbox` 仍是各 owner service MySQL 内的可靠事实源；MQ 发布失败时 outbox worker 退避重试，到达重试上限后进入死信。火箭延迟消息只负责唤醒，到点后 room-service 会重新校验 `rocket_id`、等级、`launch_at_ms`、状态和 UTC 重置边界。

`hyapp_lucky_gift_outbox` 的两个消费者保持实时副作用所需的 latest offset。首次上线必须先创建 topic/group，并先部署、确认 room/activity 两个 group 已在线订阅，再开启 lucky-gift producer；不能用 `ConsumeFromFirst` 扫历史大奖生成过期飘屏。

## 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 等本地配置只能用开发值，线上必须走安全配置源。
