hyapp-server/docs/user-mic-online-time-architecture.md
2026-05-09 21:47:33 +08:00

444 lines
20 KiB
Markdown
Raw 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.

# User Mic Online Time Architecture
本文定义“用户麦上在线时长”的统一统计架构。这个指标属于所有用户,不属于主播专属体系。用户无论当时是不是主播,只要在麦上产生了有效在线时长,都应该累计到用户维度;后续用户成为主播、参与任务、参与活动或参与成长体系时,可以直接读取历史累计结果。
## Core Decision
麦位时间先按用户维度沉淀,再被主播、任务、活动和工资等业务读取。
不能把麦位时间只写进 `host_daily_stats`,也不能在统计事件时判断“这个用户当时是不是主播”。主播身份是后置业务条件,不是麦位在线事实本身。
示例:
- 用户 A 不是主播时累计麦上在线 10 小时。
- 后来用户 A 申请并成为主播。
- 如果任务规则是“主播累计麦上在线达到 10 小时”,该任务可以直接使用用户 A 已有的 10 小时完成。
## Goals
- 所有用户共享同一套麦上在线时长事实。
- 支持日统计、周期统计、 lifetime 累计和任务进度读取。
- 支持用户成为主播前的历史时长被后续主播任务使用。
- 统计来源必须可恢复、可重放、可幂等。
- Redis 只能做缓存,不能作为时长事实来源。
- 后续主播工资如需“只算成为主播后的有效工作时长”,必须在派生层另行按政策过滤,不能污染基础用户时长。
## Non-Goals
- 不在 room-service 命令链路里同步计算任务进度。
- 不在上麦接口里同步判断主播、Agency、BD 或任务状态。
- 不把当前麦位状态当成历史统计。
- 不用客户端上报时长作为权威事实。
- 不把单个任务进度直接写进麦位会话表。
## Terminology
| Term | Meaning |
| --- | --- |
| 麦位占用时长 | 从 `MicUp(action=up)``down/leave/kick/close` 的时间,包含 `pending_publish` |
| 麦上在线时长 | 用户真实完成发流确认后的麦上时间,默认从 `publish_state=publishing` 开始算 |
| `mic_session_id` | 单次上麦会话 ID同一用户多次上下麦会产生多条 session |
| 基础用户时长 | 不区分用户身份、房间、Agency、BD 的用户级时长事实 |
| 派生业务时长 | 主播工资、活动任务等业务按自己的政策从基础用户时长或 session 明细派生 |
## Which Time Counts
首版建议同时保存两个值,但默认任务使用 `mic_online_ms`
| Metric | Start | End | Use |
| --- | --- | --- | --- |
| `seat_occupied_ms` | `RoomMicChanged.action=up` | `down/leave/kick/close` | 产品想看“占麦多久”时使用 |
| `mic_online_ms` | `RoomMicChanged.action=publish_confirmed` | `down/leave/kick/close` | 任务、成长、有效麦上在线默认使用 |
原因:
- `MicUp` 只表示业务占麦成功,用户可能没有切 RTC anchor、没有打开麦克风、网络失败或马上超时。
- `publish_confirmed` 表示服务端已经接受客户端或 RTC webhook 的发流确认,更接近“用户真的在麦上在线”。
- 如果后续有运营活动明确要求“只要占麦就算”,可以读 `seat_occupied_ms`,不需要改历史表。
## Source Events
权威来源是 `room-service``room_outbox`
| Event | Action | Effect |
| --- | --- | --- |
| `RoomMicChanged` | `up` | 创建或开启一条用户麦位 session记录 `seat_started_at_ms` |
| `RoomMicChanged` | `publish_confirmed` | 设置 `publishing_started_at_ms`,开始累计 `mic_online_ms` |
| `RoomMicChanged` | `change` | 不结束 session只更新当前 seat时长连续 |
| `RoomMicChanged` | `down` | 结束 session结算 `seat_occupied_ms``mic_online_ms` |
`LeaveRoom``KickUser``CloseRoom`、RTC `audio_stopped/room_exited``publish_timeout` 都必须在释放麦位时产生 `RoomMicChanged(action=down)`。user-service mic time consumer 只消费这个统一麦位事实,不从 `RoomUserLeft``RoomUserKicked``RoomClosed` 反推关闭。
当前释放原因约定:
| Release Path | `RoomMicChanged.reason` |
| --- | --- |
| 主动离房 | `leave` |
| presence stale 清理 | 调用方传入的 stale reason例如 `presence_stale` |
| 踢人 | `kick` |
| 关房 | `room_closed` |
| pending publish 超时 | `publish_timeout` |
| RTC 停止/退房 | gateway/RTC 事件传入的 reason |
当前 `RoomMicChanged` 已经包含 `mic_session_id``publish_state``reason``publish_deadline_ms``publish_event_time_ms`。用户麦时长消费者必须优先使用这些字段,不能用 `room_id + user_id` 自行拼会话 ID。
事件兼容规则:
- `mic_session_id` 为空的 `up/publish_confirmed/down` 事件不能进入正常累计;当前实现写入 `user_mic_event_consumption` 并标记 `skipped/incomplete_mic_event`
- `publish_confirmed` 必须只推进当前 open session如果 session 已结束或事件时间早于已接受时间,按幂等或过期事件处理。
- `change` 事件没有时长增量,只更新 seat 快照和 `last_event_id`
- `down` 的结束时间优先取 envelope `occurred_at_ms`;如果后续事件体提供更精确 RTC stop time再使用事件体时间。
- 消费者只消费 `room_outbox` 事实,不订阅腾讯 RTC webhook 原始事件,避免绕过 Room Cell 状态机。
## Service Boundary
```mermaid
graph LR
Room["room-service\nRoom Cell"] --> Outbox[("room_outbox")]
Outbox --> Consumer["user-service\nmic time consumer"]
Consumer --> UserMicDB[("hyapp_user\nuser_mic_* tables")]
UserMicDB --> Task["activity/task service"]
UserMicDB --> Host["host/agency/bd module"]
UserMicDB --> Admin["admin salary/risk"]
```
首版建议把用户麦上时长放在 `user-service`,原因:
- 统计主维度是 `user_id`,不是房间。
- 任务、主播身份、Agency 和用户资料都需要读取用户侧统计。
- `room-service` 应继续只拥有房间实时状态、快照、command log 和 outbox不承载跨天统计。
如果后续活动系统独立承接所有任务,也可以由 `activity-service` 消费 room outbox但基础事实表仍应按用户维度建模并通过内部 RPC 提供给 host/admin 使用。
## Data Model
### `user_mic_sessions`
保存每一次上麦会话。它是可审计明细,不直接服务首页大查询。
```sql
user_mic_sessions(
app_code VARCHAR(32) NOT NULL,
mic_session_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL,
room_id VARCHAR(64) NOT NULL,
first_seat_no INT NOT NULL DEFAULT 0,
current_seat_no INT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL,
seat_started_at_ms BIGINT NOT NULL,
publishing_started_at_ms BIGINT NULL,
ended_at_ms BIGINT NULL,
end_reason VARCHAR(64) NOT NULL DEFAULT '',
seat_occupied_ms BIGINT NOT NULL DEFAULT 0,
mic_online_ms BIGINT NOT NULL DEFAULT 0,
opened_event_id VARCHAR(128) NOT NULL,
publish_event_id VARCHAR(128) NOT NULL DEFAULT '',
closed_event_id VARCHAR(128) NOT NULL DEFAULT '',
last_event_id VARCHAR(128) NOT NULL,
last_room_version BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, mic_session_id),
KEY idx_user_mic_sessions_user_status (app_code, user_id, status, updated_at_ms),
KEY idx_user_mic_sessions_room_status (app_code, room_id, status),
KEY idx_user_mic_sessions_open_timeout (app_code, status, seat_started_at_ms)
);
```
`status`
| Status | Meaning |
| --- | --- |
| `pending_publish` | 已占麦,尚未确认发流 |
| `publishing` | 已确认发流,正在累计在线时长 |
| `ended` | 会话已结束 |
| `abnormal_closed` | 预留给后续补偿任务;当前基础消费者只写 `ended` |
### `user_mic_daily_stats`
按当前实现的 UTC 日期聚合,服务任务、活动和个人中心二级页。
```sql
user_mic_daily_stats(
app_code VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
stat_date CHAR(10) NOT NULL,
seat_occupied_ms BIGINT NOT NULL DEFAULT 0,
mic_online_ms BIGINT NOT NULL DEFAULT 0,
session_count BIGINT NOT NULL DEFAULT 0,
room_count BIGINT NOT NULL DEFAULT 0,
first_mic_at_ms BIGINT NOT NULL DEFAULT 0,
last_mic_at_ms BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, user_id, stat_date),
KEY idx_user_mic_daily_date (app_code, stat_date, mic_online_ms)
);
```
当前实现以 UTC 日期字符串切分 `stat_date`。跨天 session 必须按 UTC 日切分。例如 23:30 到 01:10要拆成两天分别累计。后续如果产品需要按 App 运营时区切分,需要先新增明确时区配置和重算策略,不能在代码里隐式依赖机器本地时区。
### `user_mic_lifetime_stats`
保存用户累计值,避免任务系统每次扫 daily 表。
```sql
user_mic_lifetime_stats(
app_code VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
seat_occupied_ms BIGINT NOT NULL DEFAULT 0,
mic_online_ms BIGINT NOT NULL DEFAULT 0,
session_count BIGINT NOT NULL DEFAULT 0,
first_mic_at_ms BIGINT NOT NULL DEFAULT 0,
last_mic_at_ms BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, user_id)
);
```
任务判断“累计麦上在线 10 小时”时优先读 `user_mic_lifetime_stats.mic_online_ms`
### `user_mic_event_consumption`
消费者幂等和跳过原因表。所有 room mic 事件先写入这张表,再推进 session/aggregate重复 `event_id` 直接跳过,避免 `down` 重放重复累计。
```sql
user_mic_event_consumption(
app_code VARCHAR(32) NOT NULL,
event_id VARCHAR(128) NOT NULL,
event_type VARCHAR(64) NOT NULL,
mic_session_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL,
action VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL,
skip_reason VARCHAR(128) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
consumed_at_ms BIGINT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, event_id),
KEY idx_user_mic_event_session (app_code, mic_session_id, created_at_ms),
KEY idx_user_mic_event_user (app_code, user_id, created_at_ms)
);
```
## Event Processing Rules
### `up`
处理 `RoomMicChanged(action=up)`
1.`app_code + event_id` 做幂等。
2.`app_code + mic_session_id` upsert `user_mic_sessions`
3. 设置 `status=pending_publish`
4. 设置 `seat_started_at_ms = envelope.occurred_at_ms`
5. 不增加 `mic_online_ms`
### `publish_confirmed`
处理 `RoomMicChanged(action=publish_confirmed)`
1. 找到 `mic_session_id` 对应 session。
2. 如果 session 已经是 `publishing/ended`,按幂等处理。
3. 设置 `publishing_started_at_ms`
4. 设置 `status=publishing`
5. 不立即增加 daily/lifetime避免 open session 周期性重复累计。
`publishing_started_at_ms` 建议使用事件里的 `publish_event_time_ms`;如果为空,才使用 envelope 的 `occurred_at_ms`
### `change`
处理 `RoomMicChanged(action=change)`
- 不结束 session。
- 更新 `current_seat_no`
- 不切断 `mic_online_ms`
换麦是同一次上麦会话里的位置变化,不应把时长拆断。
### `down`
处理 `RoomMicChanged(action=down)`
1. 找到 `mic_session_id` 对应 session。
2. 设置 `ended_at_ms``end_reason`
3. 计算 `seat_occupied_ms = ended_at_ms - seat_started_at_ms`
4. 如果已 `publishing`,计算 `mic_online_ms = ended_at_ms - publishing_started_at_ms`
5. 按天切分写入 `user_mic_daily_stats`
6. 增量写入 `user_mic_lifetime_stats`
7. 设置 session `status=ended`
如果没有 `publish_confirmed` 就下麦,`mic_online_ms=0`,但 `seat_occupied_ms` 仍可记录。
## Open Session Compensation
open session compensation 由 `cron-service` 调度,`user-service` 只暴露单批 `CompensateMicOpenSessions` 处理能力,用于兜底关闭 room-service down 事件缺失导致的异常 open session。补偿只修正用户麦时长事实不反写 Room Cell也不向客户端发送房间控制消息。
| Case | Action |
| --- | --- |
| `pending_publish` 超过 `pending_publish_max_age` | 关闭 session`end_reason=compensated_publish_timeout``mic_online_ms=0` |
| `publishing` 超过 `publishing_session_max_age` | 关闭 session`end_reason=compensated_publishing_max_window`,按确认发流后到截断点累计 `mic_online_ms` |
| 消费者停机后恢复 | room outbox worker 继续从位点扫描cron-service 触发补偿批处理扫描 MySQL open session 兜底 |
补偿扫描使用 MySQL `FOR UPDATE SKIP LOCKED` 锁定候选 session。单批 claim、关闭、daily/lifetime 聚合和 outbox 写入在同一个事务内完成,避免关闭成功但派生事件丢失。
补偿关闭必须写 `end_reason=compensated_*`,不能伪装成用户主动下麦。补偿必须设置最大可累计窗口,避免房间事件缺失时把异常 open session 算成超长在线。当前默认配置在 cron-service
| Config | Default | Meaning |
| --- | ---: | --- |
| `tasks.mic_open_session_compensation.enabled` | `true` | 是否启动异常 session 补偿调度 |
| `tasks.mic_open_session_compensation.interval` | `60s` | 没有积压时两轮扫描间隔 |
| `tasks.mic_open_session_compensation.batch_size` | `100` | 单批最多关闭 session 数 |
| `tasks.mic_open_session_compensation.pending_publish_max_age` | `2m` | pending 未确认发流的最大保留窗口 |
| `tasks.mic_open_session_compensation.publishing_session_max_age` | `12h` | publishing session 最大可累计窗口 |
## Derived Events And Consumers
用户麦时长消费者关闭 session 时,在同一个 MySQL 事务内写入 user-service `user_outbox` 派生事件,供 host stats、任务、活动和 BI 消费。派生事件不能替代基础事实表,消费者失败不影响 `user_mic_sessions`、daily 和 lifetime 事实。
| Event | Trigger | Consumer |
| --- | --- | --- |
| `UserMicSessionClosed` | 一条 session 正常或补偿结束 | host stats、activity task、BI |
| `UserMicDailyStatsUpdated` | daily stats 有增量 | task progress、growth system |
| `UserMicLifetimeStatsUpdated` | lifetime stats 有增量 | lifetime task cache |
事件幂等键:
| Event | `event_id` |
| --- | --- |
| `UserMicSessionClosed` | `UserMicSessionClosed:{mic_session_id}` |
| `UserMicDailyStatsUpdated` | `UserMicDailyStatsUpdated:{mic_session_id}:{stat_date}` |
| `UserMicLifetimeStatsUpdated` | `UserMicLifetimeStatsUpdated:{mic_session_id}` |
`UserMicSessionClosed` payload 至少包含 `app_code``user_id``room_id``mic_session_id``seat_occupied_ms``mic_online_ms``seat_started_at_ms``ended_at_ms``end_reason``closed_event_id`
daily/lifetime update payload 使用 delta 语义,包含 `seat_occupied_delta_ms``mic_online_delta_ms``session_count_delta``updated_at_ms`daily 事件额外包含 `stat_date``room_count_delta`
host/Agency/BD 统计应该优先消费 `UserMicSessionClosed` 或读取 user mic facts 派生关系快照,不应该再直接消费 `RoomMicChanged` 重算同一套用户时长。
## Task Usage
任务系统不要自己消费房间事件来算麦位时间,应该读取用户麦上时长事实。
示例任务:
```text
task: become_host_online_10h
condition:
user must currently be host
user_mic_lifetime_stats.mic_online_ms >= 10 * 60 * 60 * 1000
```
注意:任务判断时校验“当前是否主播”,但时长使用用户历史累计,不要求这些时长发生在成为主播之后。
如果某个任务明确要求“成为主播之后再累计 10 小时”,应该新增任务条件:
```text
sum user_mic_daily_stats where stat_date/time >= host_profile.first_became_host_at_ms
```
这属于任务政策,不属于基础统计口径。
## Relationship To Host Salary
用户麦上在线时长是基础事实;主播工资是派生事实。
| Use Case | Recommended Source |
| --- | --- |
| 用户成长任务 | `user_mic_lifetime_stats` |
| 主播入门任务 | `user_mic_lifetime_stats` + 当前 host 身份 |
| 主播周期任务 | `user_mic_daily_stats` 按周期汇总 |
| 主播工资 | 从 `user_mic_sessions``user_mic_daily_stats` 派生,并按工资政策决定是否只算 host 生效后的时间 |
| Agency/BD 归属工资 | 需要额外关系快照,不直接用基础用户累计 |
因此后续 `host_daily_stats` 不应该是唯一时长来源。它可以从 `user_mic_sessions` 派生,并附带 Agency/BD 关系快照,用于工资和团队统计。
## API Suggestions
### Internal Query
```protobuf
message GetUserMicLifetimeStatsRequest {
RequestMeta meta = 1;
int64 user_id = 2;
}
message UserMicLifetimeStats {
int64 user_id = 1;
int64 seat_occupied_ms = 2;
int64 mic_online_ms = 3;
int64 session_count = 4;
int64 first_mic_at_ms = 5;
int64 last_mic_at_ms = 6;
int64 updated_at_ms = 7;
string app_code = 8;
}
message GetUserMicLifetimeStatsResponse {
UserMicLifetimeStats stats = 1;
}
```
当前 proto 已在 `UserService` 暴露 `GetUserMicLifetimeStats(user_id)`。区间查询后续再加,不把未稳定的 range 语义提前暴露给客户端或任务系统。
### App Query
个人页首屏不要返回麦位明细。二级页可以提供:
```http
GET /api/v1/users/me/mic-stats?range=lifetime
GET /api/v1/users/me/mic-stats?from_ms=...&to_ms=...
```
返回:
```json
{
"seat_occupied_ms": 39600000,
"mic_online_ms": 36000000,
"session_count": 18,
"first_mic_at_ms": 1777000000000,
"last_mic_at_ms": 1777996800000
}
```
## Implementation Sequence
1. 已增加 `user_mic_sessions``user_mic_daily_stats``user_mic_lifetime_stats``user_mic_event_consumption`
2. 已调整 room-service释放麦位路径统一产出 `RoomMicChanged(action=down)`,包括 leave、kick、close、RTC stopped、publish timeout。
3. 已增加 user-service mic time consumer消费 `RoomMicChanged`
4. 已实现 session 状态机:`up -> publish_confirmed -> down`,支持 `change` 不切断。
5. 已实现 daily/lifetime 聚合,跨 UTC 日切分。
6. 已增加内部查询 RPC `GetUserMicLifetimeStats`,供任务、主播、后台读取 lifetime 聚合。
7. 已增加 `CompensateMicOpenSessions`,由 cron-service 调度关闭异常 open session。
8. 已增加 user mic outbox产出 `UserMicSessionClosed``UserMicDailyStatsUpdated``UserMicLifetimeStatsUpdated`
9. 任务系统接入 `user_mic_lifetime_stats`,不要重复消费 room 事件。
10. host stats 从 user mic facts 或 user mic outbox 派生,不直接重复处理 Room Cell 麦位事件。
## Verification Matrix
| Scenario | Expected |
| --- | --- |
| 普通用户上麦并确认发流 10 分钟后下麦 | `mic_online_ms` 增加 10 分钟 |
| 普通用户只占麦但未发流,超时下麦 | `seat_occupied_ms` 有值,`mic_online_ms=0` |
| 用户换麦位 | session 不结束,时长连续 |
| 用户离房释放麦位 | session 结束并累计到 daily/lifetime |
| 用户被踢释放麦位 | session 结束,`end_reason=kick` |
| 房间关闭 | 所有 open session 结束 |
| 同一事件重复消费 | 不重复累计 |
| 用户跨天上麦 | daily stats 拆成两天 |
| 用户不是主播时累计 10 小时,后续成为主播 | 读取 lifetime 后主播任务可直接完成 |
| 工资只要求 host 期间时长 | 工资派生层按 host 生效时间过滤,不改基础累计 |
| `mic_session_id` 为空的麦位事件 | 不累计,进入异常队列或告警 |
| host stats 消费用户麦时长派生事件 | 不重复累计 room outbox 同一事件 |
## Critical Rules
- 基础麦位时长按用户累计,不按主播身份累计。
- 任务判断可以检查当前身份,但不要把历史时长清零。
- `pending_publish` 默认不算 `mic_online_ms`
- `change` 不结束 session。
- Redis 不能作为时长事实来源。
- 客户端上报时长不能作为权威,只能作为辅助诊断。
- 所有统计写入必须带 `app_code`
- `mic_session_id` 是会话幂等和新旧事件隔离的核心字段,不能用用户当前是否在麦位替代。
- host stats、任务和活动读取用户麦时长事实不应各自重复消费 room 麦位事件。