# 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 麦位事件。
