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