# 统计服务技术方案

## 目标

`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。
