hyapp-server/docs/用户麦上在线时长架构.md

20 KiB
Raw Permalink Blame History

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-serviceroom_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_msmic_online_ms

LeaveRoomKickUserCloseRoom、RTC audio_stopped/room_exitedpublish_timeout 都必须在释放麦位时产生 RoomMicChanged(action=down)。user-service mic time consumer 只消费这个统一麦位事实,不从 RoomUserLeftRoomUserKickedRoomClosed 反推关闭。

当前释放原因约定:

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_idpublish_statereasonpublish_deadline_mspublish_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

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)

  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_msend_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 关闭 sessionend_reason=compensated_publish_timeoutmic_online_ms=0
publishing 超过 publishing_session_max_age 关闭 sessionend_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_codeuser_idroom_idmic_session_idseat_occupied_msmic_online_msseat_started_at_msended_at_msend_reasonclosed_event_id

daily/lifetime update payload 使用 delta 语义,包含 seat_occupied_delta_msmic_online_delta_mssession_count_deltaupdated_at_msdaily 事件额外包含 stat_dateroom_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_sessionsuser_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

  1. 已增加 user_mic_sessionsuser_mic_daily_statsuser_mic_lifetime_statsuser_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产出 UserMicSessionClosedUserMicDailyStatsUpdatedUserMicLifetimeStatsUpdated
  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 麦位事件。