20 KiB
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的结束时间优先取 envelopeoccurred_at_ms;如果后续事件体提供更精确 RTC stop time,再使用事件体时间。- 消费者只消费
room_outbox事实,不订阅腾讯 RTC webhook 原始事件,避免绕过 Room Cell 状态机。
Service Boundary
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
保存每一次上麦会话。它是可审计明细,不直接服务首页大查询。
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 日期聚合,服务任务、活动和个人中心二级页。
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 表。
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 重放重复累计。
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):
- 用
app_code + event_id做幂等。 - 用
app_code + mic_session_idupsertuser_mic_sessions。 - 设置
status=pending_publish。 - 设置
seat_started_at_ms = envelope.occurred_at_ms。 - 不增加
mic_online_ms。
publish_confirmed
处理 RoomMicChanged(action=publish_confirmed):
- 找到
mic_session_id对应 session。 - 如果 session 已经是
publishing/ended,按幂等处理。 - 设置
publishing_started_at_ms。 - 设置
status=publishing。 - 不立即增加 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):
- 找到
mic_session_id对应 session。 - 设置
ended_at_ms和end_reason。 - 计算
seat_occupied_ms = ended_at_ms - seat_started_at_ms。 - 如果已
publishing,计算mic_online_ms = ended_at_ms - publishing_started_at_ms。 - 按天切分写入
user_mic_daily_stats。 - 增量写入
user_mic_lifetime_stats。 - 设置 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
任务系统不要自己消费房间事件来算麦位时间,应该读取用户麦上时长事实。
示例任务:
task: become_host_online_10h
condition:
user must currently be host
user_mic_lifetime_stats.mic_online_ms >= 10 * 60 * 60 * 1000
注意:任务判断时校验“当前是否主播”,但时长使用用户历史累计,不要求这些时长发生在成为主播之后。
如果某个任务明确要求“成为主播之后再累计 10 小时”,应该新增任务条件:
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
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
个人页首屏不要返回麦位明细。二级页可以提供:
GET /api/v1/users/me/mic-stats?range=lifetime
GET /api/v1/users/me/mic-stats?from_ms=...&to_ms=...
返回:
{
"seat_occupied_ms": 39600000,
"mic_online_ms": 36000000,
"session_count": 18,
"first_mic_at_ms": 1777000000000,
"last_mic_at_ms": 1777996800000
}
Implementation Sequence
- 已增加
user_mic_sessions、user_mic_daily_stats、user_mic_lifetime_stats、user_mic_event_consumption。 - 已调整 room-service:释放麦位路径统一产出
RoomMicChanged(action=down),包括 leave、kick、close、RTC stopped、publish timeout。 - 已增加 user-service mic time consumer,消费
RoomMicChanged。 - 已实现 session 状态机:
up -> publish_confirmed -> down,支持change不切断。 - 已实现 daily/lifetime 聚合,跨 UTC 日切分。
- 已增加内部查询 RPC
GetUserMicLifetimeStats,供任务、主播、后台读取 lifetime 聚合。 - 已增加
CompensateMicOpenSessions,由 cron-service 调度关闭异常 open session。 - 已增加 user mic outbox,产出
UserMicSessionClosed、UserMicDailyStatsUpdated、UserMicLifetimeStatsUpdated。 - 任务系统接入
user_mic_lifetime_stats,不要重复消费 room 事件。 - 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 麦位事件。