444 lines
20 KiB
Markdown
444 lines
20 KiB
Markdown
# 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 麦位事件。
|