Skip to content

猫娘串门 · Servers 契约 ​

状态:当前契约(Servers 闭源、独立排期)。 本文是串门功能对 N.E.K.O. Servers 的唯一权威契约:客户端(本机后端)与 Servers 两侧的实现都以本文为准。「端点」一章由设计稿 猫娘串门基础设施设计 §4.7 生成、逐字段对照,tests/unit/test_visit_servers_contract_sync.py 钉住两边每个端点的正文逐字相等、「字段集」行解析出的字段集合相等;改契约时两处同改,测试会拦住只改一处。文中出现的 §x.y 编号都指设计稿的章节。

怎么读 ​

  • 每个端点固定五行:方向(谁调谁)、面(云端 HTTP / 鉴权 / admin)、字段(请求、响应与错误码原文)、字段集(给测试用的机读清单)、上限(限速、幂等、配额与服务端约束)。
  • 「字段集」行的写法:headers / path / query / req / claims / 状态码(200、201)后各跟一段反引号字段列表;a? 表示可选;a.b 表示 a 对象(或数组元素)里的字段 b;错误 段列「状态码 + code」。
  • 基址与鉴权(与设计稿 §4.7 章首同一段):

基址 https://community.project-neko.cn(main_routers/card_drop_router.py:41、utils/social_base.py:12);客户端用 get_external_http_client()(utils/http/external_client.py:65);bearer = 社区 OAuth access_token(_desktop_session_snapshot(),card_drop_router.py:585-600),平台 token 只发给 Servers 自己(既有收紧姿态 :999-1006)。Servers 核验社区账号发生在任何 vendor 连接之前:没有 OAuth 就拿不到 UserSig / JWT。串门进行中不依赖 Servers(凭证与票据都在手里,guest 40 / host 50 min TTL 覆盖 30 min 硬顶 + 重连)。

端点 ​

POST /api/visit/credentials ​

  • 方向: 本机后端 → Servers
  • 面: 云端 HTTP
  • 字段: headers {Authorization: Bearer <access_token>, X-Client-Id: <client_id>};req {role:'host'|'guest', visit_id:str(22), char_tag:str(32hex)(= 本机角色的稳定 character_uid,改名不变,§3.1), display_name?:str(≤64), region_hint:'cn'|'global'|'unknown'(ConfigManager._region_cache 只读,aensure_region_resolved(timeout=1.5),utils/config_manager/core_config.py:529;None → 'unknown'), tier:'sd600', app_version:str, invite_code?:str(10)(guest 必填)} → 200 {transport:'trtc'|'livekit', expires_at:float(身份票), vendor_expires_at:float(vendor 凭证,10 min), vendor:{trtc?:{sdk_app_id, user_id, user_sig, private_map_key, str_room_id, expire:int(600,vendor 凭证 10 min,与身份票有效期分开)}, livekit?:{url, token, ttl_s:int(600)}}, identity_ticket:str, visit_uid:str(24)(自己的), vid:str(26)(自己的), peer_vid?:str(26)(guest 侧:host 的), invite_code?:str(10), invite_expires_at?:float(host 侧,10 min), cross_region:bool=false, entitlement:{tier:'sd600', free_minutes_left_today:int, concurrent_rooms_left:int}} | 401 unauthenticated | 403 {code:'banned'} | 403 {code:'tier_not_entitled'} | 403 {code:'cross_region_unsupported'} | 403 {code:'invite_invalid'|'invite_expired'|'room_full'|'self_invite'} | 410 {code:'room_ended'}(续期 / 重连换发时房间已被强制结束,见下)| 410 {code:'invite_expiring'}(guest 兑换时邀请码距到期 <60 s——拒绝,免得 host 本地等待期限在 hello 核验前到点、误关已扣费的房间) | 409 {code:'role_taken'} | 429 {code:'quota_exceeded', retry_after_s:int} | 5xx
  • 字段集: headers Authorization, X-Client-Id · req role, visit_id, char_tag, display_name?, region_hint, tier, app_version, invite_code? · 200 transport, expires_at, vendor_expires_at, vendor, vendor.trtc?, vendor.trtc.sdk_app_id, vendor.trtc.user_id, vendor.trtc.user_sig, vendor.trtc.private_map_key, vendor.trtc.str_room_id, vendor.trtc.expire, vendor.livekit?, vendor.livekit.url, vendor.livekit.token, vendor.livekit.ttl_s, identity_ticket, visit_uid, vid, peer_vid?, invite_code?, invite_expires_at?, cross_region, entitlement, entitlement.tier, entitlement.free_minutes_left_today, entitlement.concurrent_rooms_left · 错误 401 unauthenticated, 403 banned, 403 tier_not_entitled, 403 cross_region_unsupported, 403 invite_invalid, 403 invite_expired, 403 room_full, 403 self_invite, 410 room_ended, 410 invite_expiring, 409 role_taken, 429 quota_exceeded, 5xx
  • 上限: 房间绑定(C.5):host 领凭证时 Servers 把 visit_id 登记到 host 的 visit_uid 下并返回一次性 invite_code(10 min);guest 必须带 invite_code,校验后绑到该房;guest 的 visit_uid 与该房 host 的 visit_uid 相同 → 403 {code:'self_invite'}(同一社区账号换设备或换角色兑换自己的邀请:签发记录里两侧会是同一个账号,举报推导对端会选中举报人自己、同一个 bearer 能以两种 role 上传转录、同一台机器两侧派生出同名 spool 文件),本机映射为 409 VISIT_INVITE_INVALID 并提示「不能接受自己发出的邀请」;每房 host + guest 各一,第三者领不到(room_full);transport 由 host 区域决定(Servers 以来源 IP 复核 region_hint,不一致以 Servers 为准),guest 拿同一 transport,guest 的 region_hint 只用于判 cross_region;跨区默认 fail-closed → 403 cross_region_unsupported(Servers 侧开关可改为「允许 + 警告」,T9 后由 owner 定);配额(C.7):每账号「每日签发分钟数」按凭证授权时长扣(按凭证授权时长计费,预留制:host 建房时在一次原子操作里扣 10 min 等候额度并预留 40 min(判定「当日余额 − 该账号已有预留 ≥ 50 min」,不足则 429 quota_exceeded、不建房;预留计入当日占用);guest 兑换邀请、凭证签发时:该房 host 的 40 min 预留转为实扣(host 不再另扣),guest 账号实扣 40 min(判定「guest 余额 − guest 已有预留 ≥ 40 min」,不足则 429、邀请保持可用、host 预留不动);邀请到期(签发 host 凭证起 10 min)仍无 guest 凭证签发 → 释放 host 预留、Servers 调 vendor API 结束该房间(防改过的客户端无限占用只有 host 的房间);Servers 在 guest 凭证签发后 40 min 调 vendor API 结束房间——TRTC DismissRoom / LiveKit DeleteRoom——作服务端硬顶,改过的客户端也赖不过这个时长;硬顶要能扛住 Servers 自身故障:每房的截止时刻 hard_deadline_at(guest 凭证签发 + 40 min;无 guest 时为邀请到期时刻)随签发记录落库,不只挂一个进程内定时器——Servers 另有每 60 s 一次的巡检任务(进程启动时先跑一次),对 hard_deadline_at 已过、vendor 侧仍未确认结束的房间重调 DismissRoom / DeleteRoom,失败按退避重试直到 vendor 确认房间已不在(TRTC 房间解散回调 / LiveKit room_finished 或查询确认),才把该房标为已结束;Servers 重启、vendor API 一时报错都只是推迟、不会漏掉;host 主动取消邀请:POST /api/visit/rooms/{visit_id}/cancel(host 的 bearer;只要签发记录里 guest 还没有 joined_at 就有效,与 guest 是否已领凭证无关——guest 领了凭证但还没进 vendor 房时,没有对端能收 leave,只能由 Servers 收回)→ 邀请码立即作废、该房标为已结束(之后 guest 兑换得 403 invite_invalid、续期 / 重连得 410 room_ended)、结束 vendor 房间(LiveKit auto_create:false 下 guest 手里的旧凭证进不去;TRTC 若被它重建,按上面的房间事件回调立即再解散);额度:guest 凭证已签发的,退回 guest 的 40 min 与 host 由预留转来的 40 min,只保留 host 的 10 min 等候额度(没有任何一侧以 guest 身份入过房,不该按完整一场计费);guest 已有 joined_at 后调用返回 409 {code:'guest_joined'}(这时双方都在房,走正常的 leave 与硬顶);本机后端在 host 还没核验到对端 hello(phase ∈ pending / invite_ready / joining,不论 guest 是否已领凭证)时结束这场(取消、关掉 visitEnabled、退出)就后台调用它,失败按 1 / 2 / 4 / 8 s 退避重试,直到成功、409 或邀请到期——不调的话,host 已经不要的邀请在剩余有效期里仍能被兑换,还会把 host 的 40 min 预留转成实扣;于是一场完整串门 host 共扣 50 min、guest 扣 40 min,「每个参与者实际计费分钟 ≤ 其被扣分钟」成立,同账号并发两房时两份预留都计入占用,已发出的邀请在兑换时 host 侧必有额度、当日总扣不超上限),免费档 VISIT_FREE_MINUTES_PER_DAY(占位 120,由 owner 定价时拍板),每账号并发房 ≤2(名额释放以 vendor 房间结束事件为准:TRTC 房间解散回调 / LiveKit room_finished webhook,或 Servers 自行向 vendor 查询确认该参与者 / 房间已不在之后才释放;客户端 finalize 后的 POST /api/visit/transcripts 只触发一次这样的查询、不直接释放(否则改过的客户端可以边在房边上传转录来多开);都没来时凭证 expires_at 到期兜底),签发幂等:同一账号、同一 role、同一 visit_id 重复请求(例如上次已在 Servers 登记 / 消耗邀请码 / 扣额度,但响应在路上丢了)→ 按签发记录换发一份新凭证返回 200,不再消耗邀请码、不重复扣额度、不回 role_taken;只有另一个账号来抢已被占的侧位才 role_taken / 邀请码已用;重连(同 visit_id + 同 visit_uid)同样不消耗配额,但 vendor 入房凭证是短期的:TRTC privateMapKey 与 LiveKit JWT 有效期只有 VISIT_VENDOR_GRANT_TTL_S=600(10 min;身份票仍按侧位 40 / 50 min),两家都只在入房 / 重连时校验,在房期间过期不踢人,所以不影响正常长场;本机在重连前或剩余 <2 min 时重调本端点换发新凭证——Servers 只在该房仍有效时(未被 DismissRoom / DeleteRoom、未过服务端硬顶、邀请未过期或 guest 已入房)才签,房间被强制结束后一律 410 {code:'room_ended'};换发的凭证保留重建空房的能力,但只到这场的服务端硬顶:两家的房间在没人时都会被回收(TRTC 房内无人即解散;LiveKit 按 departure_timeout),而 host 等客期间页面重载、或双方同时闪断,都会让房间短暂变空——此时 §4.3 的重连用的是手里那份换发过的凭证,若它不能建房,承诺的 20 / 35 s 恢复就失败。所以 TRTC 换发的 privateMapKey 保留建房位 1,expire = min(600, 该房服务端硬顶 − now)(过了硬顶的凭证本来就不签);房间被强制结束后的重建由 Servers 兜住:订阅 TRTC 房间事件回调,收到已结束场次的「创建房间」事件立即再 DismissRoom 并记违规(是否有该回调随 T 项核对;没有则按每 30 s 房间查询发现后解散);LiveKit 由 Servers 在 CreateRoom 时设 departure_timeout = VISIT_PEER_REJOIN_GRACE_S + 25 = 60 秒(≥ 35 s 重入宽限加重连余量,房间在双方短暂离开期间不被回收)、empty_timeout = VISIT_INVITE_WAIT_S;LiveKit 自建端配 room.auto_create:false,由 Servers 在 host 首次领凭证时 CreateRoom(max_participants:2)——房间被结束后,手里没过期的旧凭证最多再用 10 min——LiveKit 不能重建房间,TRTC 重建会被 Servers 立即解散——换发被拒(LiveKit Cloud 上线门槛:Cloud 必须能关闭自动建房(T15 实测)——关不掉时,拿着未过期 JWT 的客户端可以在 DeleteRoom 之后立刻重建房间,服务端硬顶与配额就不成立,因此海外不走 Cloud,直接用 GCP 自建并配 auto_create:false;稿子里「服务端硬顶」的承诺以此为前提);付费档只改 entitlement;两种有效期,各管各的:身份票按侧位——guest VISIT_CREDENTIAL_TTL_S=2400(40 min),host VISIT_HOST_CREDENTIAL_TTL_S=3000(50 min = 等待对端 600 + 硬顶 1800 + 余量 600),响应字段 expires_at;vendor 入房凭证(TRTC UserSig + privateMapKey、LiveKit JWT)一律 VISIT_VENDOR_GRANT_TTL_S=600(10 min),响应字段 vendor_expires_at,重连前或剩余 <2 min 时重调本端点换发(见下);TRTC UserSig expire=600(HMAC-SHA256(SDKSecretKey; SDKAppID, UserID, expire),服务端生成,userId ≤32 B [a-zA-Z0-9_-]、strRoomId ≤64 B,https://web.sdk.qcloud.com/trtc/webrtc/v5/doc/en/TRTC.html );TRTC 房间绑定:UserSig 只绑 SDKAppID / userId / 过期时间、不绑房间,所以串门用专用 SDKAppID 并在控制台开启「高级权限控制」(开启后该 SDKAppID 下所有进房都必须带 privateMapKey,因此不能与其他业务共用),Servers 另签 private_map_key = genPrivateMapKeyWithStringRoomID(sdkappid, key, userId=vid, expire, strRoomId=visit_id, privilegeMap)——这张权限票据把 userId、strRoomId、过期时间与权限位绑在一起,拿别的房的 UserSig 或改 strRoomId 都进不去,效果与 LiveKit JWT 的 room grant 对偶;权限位按侧位收紧:host 1|2|32(建房 / 进房 / 收视频),guest 1|2|16(建房 / 进房 / 发视频),音频与屏幕分享位全关(位定义以官方签名库 tls-sig-api-v2 为准:1 建房、2 进房、4 发音频、8 收音频、16 发视频、32 收视频、64 / 128 发 / 收辅路即屏幕分享;https://cloud.tencent.com/document/product/647/32240 ;Web SDK v5 enterRoom 的 privateMapKey 参数随 T 项实测核对),LiveKit JWT HS256 ttl=10m(与 TRTC 同为 vendor 凭证短期,重连前换发)、grants 按侧位收紧——host {roomJoin:true, room:visit_id, canPublish:false, canSubscribe:true, canPublishData:true}(host 只收视频、只发数据),guest {roomJoin:true, room:visit_id, canPublish:true, canPublishSources:['camera'], canSubscribe:true, canPublishData:true}(canPublishSources 只含 camera、不含 microphone)+ 服务端 room.max_participants=2;本类房间零音频:客户端 TRTC 入房 autoReceiveAudio:false、从不 startLocalAudio;Servers 在 TRTC 用量统计 / 事件回调里发现本类房间有任何音频上行 → 踢出该用户并记违规(再犯封禁),与 host 零视频同一套执行;LiveKit 由 canPublishSources:['camera'] 排除麦克风(token 里 canPublishSources 明确不含 microphone);画质档位服务端约束(开源客户端可改 SDK 参数,客户端的 profile / maxBitrate 不可信):LiveKit 侧 Servers 订阅 webhook track_published(带轨道宽高),超出凭证 tier 的尺寸 → RoomService.RemoveParticipant 踢人 + 记违规;同一 webhook 上还强制每房只允许 guest 的一条 camera 视频轨:已有活动视频轨时对新发布的视频轨调 RoomService.MutePublishedTrack 静音并记违规,再犯则 RemoveParticipant;TRTC 侧在用量核对里按「同账号同时多路视频」判违规走封禁;Servers 按签名 role 强制「host 零视频发布」:TRTC 用量统计 / 事件回调里 host 账号(按签发记录里的 host vid)出现任何视频流——哪怕合规的一路——立即 RemoveUserByStrRoomId 踢出房间 + 记违规,再犯走封禁;TRTC 侧 host 能否以观众角色(ROLE_AUDIENCE)进房仍 sendCustomMessage 未确认(§3.12 T13,确认前 host 仍用 ROLE_ANCHOR;T13 确认可用则 host 以观众角色进房、从根上不能推流,T13 结果是上线闸门),Servers 定时拉 TRTC 用量统计 / 事件回调,按账号比对实际分辨率档与码率,超档 → 走封禁流程(POST /admin/visit/bans);检测到并处置之前,单账号最坏按凭证允许的最高档计费(§3.5.8);IP:TRTC / LiveKit 都是 SFU,对端拿不到你的 IP,Servers 因区域复核知道(C.10)

身份票 identity_ticket(claims 全表) ​

  • 方向: Servers → 本机后端 → (hello.ticket)→ 对端后端
  • 面: 鉴权(Ed25519)
  • 字段: 编码 base64url(claims_json) + '.' + base64url(sig_64B)(两段,不是 JWT 三段;cryptography.hazmat.primitives.asymmetric.ed25519,pyproject.toml:62 已依赖);claims = {v:1, iss:'neko-servers', aud:'neko-visit', kid, sub:<visit_uid>, vid, visit_id, role, transport, char_tag, display_name?, iat, exp, jti},每个 claim 的类型、含义与接收侧核验见下表
  • 字段集: claims v, iss, aud, kid, sub, vid, visit_id, role, transport, char_tag, display_name?, iat, exp, jti
  • 上限: 序列化 ≈560 B(估算);不含 email、client_id、access token、community_uuid 原文;pair_id = sha256(min(uid_a, uid_b) + '|' + max(uid_a, uid_b))[:24](两端各算一遍结果相同,G.1);person_id = 'p_' + sha256(own_uid + '|' + peer_uid)[:24](只在本机用:人级记忆主体 participant('neko_visit', person_id) 的单段 id,带本机账号;不用 own_uid:peer_uid 复合段,因为 MemorySubject.participant() 会把 : 转义);short_id = visit_uid[:6].upper()(UI 唯一可见形式);visit_uid 首次派生后按账号落库、之后只读,Servers 换盐 / 换密钥不改变已派发的 id(只影响之后首次使用串门的账号);变异单测五种必红:篡改 sub / 过期 / role 对调 / vid 不符 / 未知 kid
claim类型含义接收侧核验
vint=1票版本必须 1
iss'neko-servers'签发者相等
aud'neko-visit'受众相等
kidstr(≤16)公钥 id查 VISIT_SERVERS_PUBKEYS,miss → GET /api/visit/pubkeys 一次 → 仍 miss fail closed
substr(24 hex)visit_uid = HMAC-SHA256(server_secret, community_uuid) 十六进制前 24 字符(稳定、不透明、Servers 可反查;不是裸 uuid,C.1)∉ visit_blocklist.json;记忆 / 名册 / 举报主键
vidstr(26)vendor userId / identity = role[0] + '_' + sha256(visit_uid + '|' + visit_id).hexdigest()[:24](C.3;落 TRTC userId 字符集)== vendor 盖的 from_vid
visit_idstr(22)房间== 本房
role'host'|'guest'侧位== 对侧
transport'trtc'|'livekit'一房一 transport== 本侧 credentials.transport
char_tagstr(32hex)对端自报角色标签(只在 sub 命名空间下有意义)派生 peer_char_id = 'c_' + sha256(peer_uid + '|' + char_tag)[:24]
display_namestr(≤64)?猫娘显示名经 OD-23 清洗后才进 UI / 名册
iatint签发时刻iat - 300 ≤ now
expint= iat + 2400(guest 40 min)或 iat + 3000(host 50 min,VISIT_HOST_CREDENTIAL_TTL_S)now ≤ exp + 300(VISIT_TICKET_CLOCK_TOLERANCE_S=300)
jtistr(22)票 id同房同 vid 重连允许重放同一 jti;跨房 / 跨 vid 重放无效(visit_id、vid 已绑)

GET /api/visit/pubkeys ​

  • 方向: 本机后端 → Servers
  • 面: 云端 HTTP(公开,无鉴权)
  • 字段: → {keys:[{kid:str, alg:'Ed25519', pub:str(base64url 32 B), not_before:int, not_after:int}], revoked:[kid:str], ttl_s:86400}(not_before / not_after 必填:接收方据此核验票的签名时刻 iat 落在钥匙有效期内,§4.2 hello)(revoked 列出已吊销的 kid,内置表里有也不得再用)
  • 字段集: 200 keys, keys.kid, keys.alg, keys.pub, keys.not_before, keys.not_after, revoked, ttl_s
  • 上限: 客户端缓存 VISIT_PUBKEYS_CACHE_S=86400;内置表 config/visit_settings.py::VISIT_SERVERS_PUBKEYS = {kid: {pub: base64url, not_before, not_after}} 随发版更新(kid 轮换靠发版,本端点是轮换期第二来源);kid 不命中且拉不到 → fail closed(C.8);内置键也可吊销:本端点返回上面的 {keys, revoked},客户端在每次领凭证时(此时必然连得上 Servers)顺带刷新,VISIT_PUBKEYS_CACHE_S 只作缓存上限;核验时 kid 在最近一次刷新的 revoked 里 → 即使内置表命中也拒绝;刷新失败且缓存已过期 → fail closed(领不到凭证本来也串不了门);Servers 不复用已吊销的 kid;开发环回:NEKO_VISIT_DEV_KEYFILE=<path> 指向本地 Ed25519 私钥,scripts/visit_dev_mint.py 用它签票并把公钥追加进开发键位,核验代码路径与生产完全相同(没有「跳过验签」分支,C.9);本地起 LiveKit 用 NEKO_VISIT_DEV_LIVEKIT_URL / _API_KEY / _SECRET

GET /api/visit/invites/{invite_code}/preview ​

  • 方向: 本机后端(代转 4.6 同名端点)→ Servers(bearer)
  • 面: 云端 HTTP(只读)
  • 字段: headers {Authorization: Bearer <access_token>, X-Client-Id: <client_id>};path invite_code:str(10) → 200 {visit_id:str(22), host_display_name:str(≤64), host_short_code:str(6)(= host visit_uid[:6] 大写), cross_region:bool(host 区域 vs 请求者区域,按 POST /api/visit/credentials 同一规则由 Servers 以来源 IP 判定), expires_at:float}(邀请码到期时刻), host_visit_uid:str(24)(只给本机后端代理比对本机黑名单,代理不转给前端)| 401 unauthenticated | 404 {code:'invite_invalid'} | 410 {code:'invite_expired'} | 403 {code:'banned'} | 429 {code:'rate_limited', retry_after_s:int}
  • 字段集: headers Authorization, X-Client-Id · path invite_code · 200 visit_id, host_display_name, host_short_code, cross_region, expires_at, host_visit_uid · 错误 401 unauthenticated, 404 invite_invalid, 410 invite_expired, 403 banned, 429 rate_limited
  • 上限: 每账号 30 次/分钟,超限 429;邀请码是 10 位 base32(50 bit)、10 min 有效,按此限速枚举不可行;只读、不消耗邀请码(一次性 invite_code 只在 guest 的 POST /api/visit/credentials 成功时被消耗);不写签发记录、不扣配额;只返回确认框需要的五个字段加 host_visit_uid(本机后端拿它比对 visit_blocklist.json,不转给前端),不返回 vid 或任何 vendor 信息

POST /api/visit/reports ​

  • 方向: 本机后端 → Servers(bearer)
  • 面: 云端 HTTP
  • 字段: req {visit_id, reason:'harassment'|'sexual'|'privacy'|'spam'|'other', note?:str(≤500), include_transcript:bool —— 不再由客户端附带转录正文:include_transcript:true 时由 Servers 引用自己已存的双方两份转录(POST /api/visit/transcripts 上传的那两份)及差异标记;本机未上传成功时先触发一次上传再提交举报, anomalies:int, app_version} → 201 {report_id} | 200 {report_id, duplicate:true}(同一账号对同一 visit_id 已举报过,客户端视同受理)| 401 | 404 unknown_visit(Servers 只接受自己签发过的 visit_id 且举报人是该房成员)| 429 | 5xx
  • 字段集: req visit_id, reason, note?, include_transcript, anomalies, app_version · 201 report_id · 200 report_id, duplicate · 错误 401, 404 unknown_visit, 429, 5xx
  • 上限: 被举报方由 Servers 推导:以 visit_id + 举报人 bearer 账号查签发记录,取该房另一侧的 visit_uid 作被举报方;请求不带 peer_uid(客户端无法借举报指认一个不在场的人;本机在「清除这个人」把名册与 spool 里的 peer_uid 抹掉之后仍能举报——清除只清本机记忆,不影响举报能力);每 visit_id 每账号 1 次;举报附带的云端证据是已到的转录与逐行差异标记(agree / only_host / only_guest / differ,同 details)——每侧独立上传,对端可能永远不传(被改的客户端、离线、卸载),所以 Servers 不假设两份都在:举报记录带 transcripts_present:{host:bool, guest:bool},受理时有几份引用几份;对端那份之后(7 天补传期内)到达时自动挂到这条举报上并刷新差异标记,管理员后台对缺失的一侧明确显示「对方未上传」;对端始终没传本身也记作一条异常证据;Servers 不替任何一方裁决哪份为真;无第三方盖章(双侧 spool / outbox JSONL + 双方各自上传的转录(POST /api/visit/transcripts)是全部证据链,§3.8 如实写)

POST /api/visit/transcripts ​

  • 方向: 本机后端 → Servers(bearer;每场 finalize 后由后台任务上传一次,双方各传自己那份(每份都是该侧收到的完整转录,含双方的行;Servers 据两份比对逐行标差异),OD-26 v3)
  • 面: 云端 HTTP
  • 字段: headers {Authorization: Bearer <access_token>, X-Client-Id: <client_id>};req {visit_id:str(22), role:'host'|'guest', started_at:float, ended_at:float, finalized_reason:str, usage:{duration_s:int, llm_input_tokens:int, llm_output_tokens:int, tts_requests:int, tts_chars:int}, lines:[{lp:int, side:'host'|'guest', from:'own_cat'|'peer_cat'|'own_human'|'peer_human', ts:float, text:str(≤4096 B), truncated:bool}], anomalies:int, app_version:str, part?:int(0 起), parts?:int(≥1;不分块时省略,等价 part:0, parts:1)} → 201 {ok:true, accepted_parts?:[int], complete?:bool} | 200 {ok:true, duplicate:true, accepted_parts?:[int], complete?:bool}(同 visit_id + role 已收齐,或同一组里该 part 已收;分块上传时每个成功响应都带 accepted_parts(当前这一代 Servers 实际已收下的块)与 complete,客户端以它为准)| 401 unauthenticated | 403 {code:'not_participant'}(签发记录里该账号不是本房该 role)| 404 unknown_visit(Servers 未签发过该 visit_id)| 400 {code:'parts_out_of_range'}(终态:parts 超 VISIT_UPLOAD_MAX_PARTS 或 part 不在 0..parts-1)| 409 {code:'visit_not_started', final:bool}(final:false = 房间还没结束、尚无双方入房证据,客户端保留文件按退避重试;final:true = 房间已结束且事后查询确认某侧从未入房,终态)| 413 {code:'too_large'}(单个请求体超 1 MiB:parts 翻倍重切后重传,不是终态)| 413 {code:'transcript_budget_exceeded'}(行数超 VISIT_UPLOAD_MAX_LINES 或该侧累计字节超 VISIT_UPLOAD_ROLE_BUDGET_BYTES:终态)| 429 {code:'rate_limited', retry_after_s:int}(请求频率)| 429 {code:'transcript_quota_exceeded', retry_after_s:int}(每账号每日留存预算:延后重试、不删文件)| 5xx(重试)
  • 字段集: headers Authorization, X-Client-Id · req visit_id, role, started_at, ended_at, finalized_reason, usage, usage.duration_s, usage.llm_input_tokens, usage.llm_output_tokens, usage.tts_requests, usage.tts_chars, lines, lines.lp, lines.side, lines.from, lines.ts, lines.text, lines.truncated, anomalies, app_version, part?, parts? · 201 ok, accepted_parts?, complete? · 200 ok, duplicate, accepted_parts?, complete? · 错误 401 unauthenticated, 403 not_participant, 404 unknown_visit, 400 parts_out_of_range, 409 visit_not_started, 413 too_large, 413 transcript_budget_exceeded, 429 rate_limited, 429 transcript_quota_exceeded, 5xx
  • 上限: 按 visit_id + role 幂等(第二次上传返回 200 duplicate:true,不覆盖;分块时按 visit_id + role + parts + part 幂等(parts 进键:413 后翻倍重切是新一代分块,与上一代已受理的同号块互不冲突;客户端重切时清空已受理块集合,按新 parts 整组重传));请求体上限:Servers 接受的请求体上限不得低于协议最大值(80 行 × 4096 B × 双方 + 用量与信封),定为 1 MiB;服务端上限(防改过的客户端灌存储):上限从协议已有的约束推出、不按猫娘句数估(人类发言也逐条进转录、没有条数上限,只受数据通道每发送方总量 ≤5 KB/s 约束):单份转录(一侧上传的、含双方的行)合法上限 VISIT_UPLOAD_MAX_BYTES = 2 方 × 5 KB/s × (1800 s 硬顶 + 60 s 收尾) × 2(JSON 转义最坏)≈ 37.2 MB,取 40 MB;parts ≤ VISIT_UPLOAD_MAX_PARTS=128(40 MB / 512 KiB ≈ 80,翻倍序列的下一档),超出 → 400 parts_out_of_range;part 必须是 0 ≤ part < parts 的整数;单行 text ≤4096 B;行数上限也从发送速率推出:每发送方必达 text ≤20 条 / 10 s——发送侧自限,接收侧也对对端执行同一上限(§4.2 text:令牌桶每秒补 2、容量封顶 90,超出的回 ack 但不进转录 / spool,并计入异常),所以合规接收方的转录里对端行同样有界,改过的对端灌消息不会把本侧转录撑过上限,单份转录 ≤ 本侧 20/10 s × 1860 s(3720)+ 对端整场硬上限 VISIT_INBOUND_TEXT_MAX(3750)= 7470 行,取 VISIT_UPLOAD_MAX_LINES=7500(人类发言再多、对端再怎么灌也到不了;超出 → 413 transcript_budget_exceeded);只接受真正开始过的场次:签发记录里双方都已有 joined_at——入房证据在房间存活期间就落库:Servers 订阅 vendor 的参与者入房事件(LiveKit participant_joined webhook、TRTC 进房回调),核对事件里的 vid 与签发记录相符后写该行的 joined_at(只写第一次);回调漏收时由每 30 s 的 ListParticipants / 房间成员查询补写;任何带参与者身份的 vendor 事件都算入房证据(LiveKit participant_left / track_published、TRTC 退房回调,与入房事件同样核对 vid 后写 joined_at),房间结束事件到达时若某侧仍无 joined_at,再向 vendor 的事后记录查一次(TRTC 历史用户 / 通话记录查询、LiveKit Cloud 会话分析;自建 LiveKit 没有事后记录,靠 webhook——是否有可用的事后查询接口随 T 项核对);事后查询不可用或失败时不判死:受理上传并在记录上标 join_unverified:true,该场的转录与举报照常保留,但带这个标的场次不作为封禁的唯一依据——入房、退房都发生在一次轮询间隔内的短场次,回调与轮询可能同时漏掉,直接拒收会永久丢掉合法记录;上传通常发生在双方都退房之后,那时再向 vendor 实时查询已查不到人,所以受理上传只看这份持久记录、不现查 vendor。判定:双方都有 joined_at → 受理;房间尚未结束、仍缺某侧 → 409 visit_not_started(客户端按退避重试,房间结束后再判);房间已结束、事后查询确认某侧从未入房 → 409 visit_not_started(终态,防只建房、无人入房就上传整份伪造转录;host 在 guest 入房前崩溃的场次本来就没有可计费的对话);每账号留存预算:每个 visit_uid 每天新受理的转录合计 ≤ VISIT_UPLOAD_ACCOUNT_DAILY_BYTES=200 MB 且 ≤ VISIT_UPLOAD_ACCOUNT_DAILY_LINES=50000 行,超出 → 429 transcript_quota_exceeded(客户端按 retry_after_s 延后重试,不删文件);每个 (visit_id, role) 跨所有分块代累计接收字节 ≤ VISIT_UPLOAD_ROLE_BUDGET_BYTES = 2 × 40 MB(够一次整组重切重传,含被新一代丢弃的旧块),超出 → 413 transcript_budget_exceeded 并丢弃该侧所有未收齐的块;同一时刻每个 (visit_id, role) 只保留一代未收齐的组;未收齐的组保留 8 天(长于客户端 7 天的补传期,补传期内续传不会遇到先收的块已被清掉);每个分块的成功响应都带 accepted_parts:[int](当前这一代 Servers 实际已收下的块),客户端以它为准更新本地「已受理」集合——若 Servers 那边少了客户端以为已受理的块(被清理、换代或任何原因),客户端照常把缺的块补上,不会只补自己记得没传的;只有该房签发过凭证的 visit_uid 能以对应 role 上传(既有角色绑定);客户端收到 transcript_budget_exceeded / parts_out_of_range 不再重试,记一条本地诊断事件、删上传文件(合法客户端到不了这些上限);客户端分块:序列化后的请求体 > VISIT_UPLOAD_CHUNK_BYTES=512 KiB 时按行分块上传——lines 按 (lp, side_rank) 顺序连续等分成 parts 块(确定性规则:第 k 块取第 ceil(k·N/parts) 到 ceil((k+1)·N/parts)-1 行),每块都带完整的头字段与 usage / anomalies,只有 lines 是该块的切片;收到 413 → parts 翻倍、按同一规则重新切、整组重传,直到单块 ≤ 上限(单行 ≤4096 B,最坏一行一块也必然能过),不得原样重试同一块;Servers 以 (visit_id, role, parts) 为一组收集 part,收齐一组才算该侧上传完成(之后同 visit_id + role 的任何请求回 200 duplicate:true),同一 visit_id + role 出现更大 parts 的新组时丢弃未收齐的旧组,未收齐前 details 的 uploaded.<role>=false;客户端在 .upload.json 里记当前 parts 与已受理的 part,重试只补未受理的块,收齐才删文件;待传内容与 spool 解耦、一律临时存盘,与 visitMemoryEnabled 无关:串门进行中就增量落盘 <visit_id>.upload.jsonl(每条 text{final}——双方的行——追加一行,外加每次计费事件后一行用量增量;原子追加、0o600,与 visitMemoryEnabled 无关;首行是上传头 {kind:'header', visit_id, role, own_visit_uid, started_at, own_char_uid, app_version, transport}(own_visit_uid = 占这个 role 的社区账号;上传与排队举报的补传只在当前登录账号就是它时进行,换成别的账号或登出时文件原样保留、等原账号回来,7 天到期规则不变,并记「账号不符,暂缓」诊断——Servers 只认原账号上传这个 role 的转录,用别的账号重试只会被 403、白白耗掉重试;登出或切换社区账号之前(main_routers/community_oauth.py 的切换与登出入口),若有在飞串门先按 route_end 结束、等 .upload.json 落盘,再清登录态——续期凭证与上传都要用占房那个账号的 bearer),进入本场(host 建房成功 / guest 入房)时先于任何台词行写出;台词行 {kind:'line', lp, side, from, ts, text, truncated};(本场用量只计到 finalize 收口:.upload.jsonl 收口成 .upload.json 之后的回家仪式句、debrief 简述与日记生成属于回家后的普通对话,不计入本场,_record_usage 对已收口的场次是 no-op、不会重建无头流水)用量行 {kind:'usage', ts, d:{llm_input_tokens?, llm_output_tokens?, tts_requests?, tts_chars?}} 记增量——每次 LLM 调用结束(含被打断:有 provider usage 用它,没有则按已发 prompt 与已流出文本估算)、每次 TTS 请求打开时追加一行 {tts_requests:1}、此后每次清洗后的文本真正入 TTS 队列时追加一行 {tts_chars:n}(n = _enqueue_tts_text_chunk 在 normalizer / markdown / 括号 / 符号清洗之后 tts_request_queue.put 的那段文本长度——覆盖全部三处文本入队:_enqueue_tts_text_chunk 的 :356 / :362(TTS 未就绪时先进 tts_pending_chunks、就绪后由 _flush_tts_pending_chunks :1683 经同一函数入队)与收尾时清洗器尾段直接入队的 :589(main_logic/core/tts_runtime.py);被清洗成空、没入队的不计;流式一行只开一次请求、正文边生成边推,按入队段记才不会少计);已知偏差:打断时队列里已入队、worker 还没送出的少量文本(通常不超过一个分句)已被计入,details 的 TTS 字数按约数展示;异常行 {kind:'anomaly', ts} 每记一次异常追加一行),finalize 时收口成 .upload.json;启动补录扫描全部 <visit_id>.upload.jsonl:只要没有对应的 .upload.json、且该 visit_id 不属于当前进程里活着的 VisitRuntime,就用流水构建并提交上传,不看 state.json.finalized 是否为空(finalize 的顺序是先原子写 .upload.json、再写 finalized;任何「流水还在、上传文件没写成」的中断都照样补传)(finalized_reason 取 state.json.finalized,为空或没有 state.json 记 'crash';visit_id / role / started_at / app_version 取自头行,ended_at = 流水里最后一个带 ts 的行(台词 / 用量 / 异常)的 ts,一个都没有(只写了头行就崩溃)则取头行的 started_at(duration_s=0,这场照样补传、留账单记录),usage = 全部用量行增量逐项求和(一行都没有 = 本场确实还没产生计费,各项记 0)、anomalies = 异常行条数——崩溃最多漏记当时还没结束的那一次 LLM 调用,lines = 全部台词行,与记忆开关无关——记忆关着时没有 .jsonl 与 spool 头行,上传所需字段全靠这条上传头;没有头行的流水按损坏计一条本地诊断事件并删除)——否则硬崩溃的场次没有任何待传数据;清理次序:finalize 收口时先原子写 .upload.json,再删 .upload.jsonl,两步都在写 state.json.finalized 之前;上传成功后删 .upload.json(流水此时已不存在);崩溃场次由补录从 .upload.jsonl 构建上传,上传成功后删流水——上传成功的场次本机不留流水也不留上传文件;finalize 时写只含上传字段的 config_dir/visit_spool/<visit_id>.upload.json(原子写,0o600,上传成功即删;关机 stop_all('shutdown') 也在 ≤3 s 预算内从内存转录 + 用量同步写出它,下次启动补传),所以 debrief 选「不记」删 .jsonl(串门区 digest 完成后)、spool 7 天清理、记忆开关关着都不影响待传转录;失败下次启动重试;自结束起 7 天仍未上传成功则放弃并记一条本地诊断事件;role 由 Servers 以 bearer 账号对照签发记录核验;lines 按 (lp, side_rank) 排序,文本是本侧收口的 text{final}(对端行 = 对端已过出站清洗的文本,本侧猫娘行 = 已放出分片拼接,本侧亲人行 = 本侧实际发出的 text.txt(已过 OD-23 出站清洗与截断,不是原始输入——否则含亲人名的行在两份上传里必然 differ,还会把清洗本来挡下的隐私传上云);隔离会话历史里的本侧亲人句仍用原始输入(只在本机、她要看懂家里人说的话));与 visitMemoryEnabled 无关(记忆开关管「她记不记」,上云管账单与举报);失败重试只看 .upload.json(进程内退避 + 下次启动重试),不看记忆开关;Servers 长期保留(与账单记录同期);本机「清除这个人」不删云端转录;披露只写进隐私政策;用量计数另经现有遥测 counter / histogram 上报(低基数维度、不带 visit_id),本端点是唯一带 visit_id 的用量记录

GET /api/visit/history ​

  • 方向: 本机后端(代转 4.6 同名端点)→ Servers(bearer)
  • 面: 云端 HTTP(只读)
  • 字段: headers {Authorization: Bearer <access_token>};?cursor= → 200 {items:[{visit_id, role, peer_display_name, peer_short_code, started_at, ended_at}], next_cursor?} | 401
  • 字段集: headers Authorization · query cursor · 200 items, items.visit_id, items.role, items.peer_display_name, items.peer_short_code, items.started_at, items.ended_at, next_cursor? · 错误 401
  • 上限: 只返回当前账号(签发记录里的 visit_uid)参与过的场次,按转录保留期;按 started_at 倒序分页;不含转录正文与用量(点开走 details)

GET /api/visit/details/ ​

  • 方向: 本机后端(代转 4.6 同名端点)→ Servers(bearer);管理员后台用同一数据
  • 面: 云端 HTTP(只读)
  • 字段: headers {Authorization: Bearer <access_token>};?cursor=&limit≤500(转录行分页:按 (lp, side_rank) 顺序,响应带 next_cursor;除最后一页外每页必须恰好返回 limit 行(只有没有 next_cursor 的最后一页可以少于 limit),否则客户端按页数上限算出的总量不成立;头部字段每页都带) → 200 {visit_id, transport, started_at, ended_at, duration_s:int, free_minutes_deducted:int, usage:{llm_input_tokens, llm_output_tokens, tts_requests, tts_chars}|null, lines:[{lp, side, host:{from, ts, text, truncated}|null, guest:{from, ts, text, truncated}|null, status:'agree'|'only_host'|'only_guest'|'differ', diff_fields?:[str]}], uploaded:{host:bool, guest:bool}, requester_role:'host'|'guest', next_cursor?:str} | 401 | 403 {code:'not_participant'} | 404 unknown_visit
  • 字段集: headers Authorization · query cursor, limit · 200 visit_id, transport, started_at, ended_at, duration_s, free_minutes_deducted, usage, usage.llm_input_tokens, usage.llm_output_tokens, usage.tts_requests, usage.tts_chars, lines, lines.lp, lines.side, lines.host, lines.host.from, lines.host.ts, lines.host.text, lines.host.truncated, lines.guest, lines.guest.from, lines.guest.ts, lines.guest.text, lines.guest.truncated, lines.status, lines.diff_fields?, uploaded, uploaded.host, uploaded.guest, requester_role, next_cursor? · 错误 401, 403 not_participant, 404 unknown_visit
  • 上限: 只有该场双方账号(签发记录里的两个 visit_uid)与管理员可读;duration_s 与 free_minutes_deducted(本场计入该账号「每日签发分钟数」的分钟数,C.7)来自签发记录;requester_role = 请求者在本场的侧位(取自签发记录;本机 spool / 上传文件 / 内存转录都已清掉时,云端回退靠它从 lines[].<role> 取回本侧那份);usage 取请求者本侧上传的那份,请求者本侧还没上传(uploaded.<本侧 role>==false:刚结束、上传失败或排队中)时为 null——服务端不编造 0,UI 显示「用量待本机记录上传后可见」;两份都保留、不把任何一方当权威(客户端开源,任一侧上传的都可能被篡改):不再附整份 transcripts 数组(否则每页仍可能带回两份各 40 MB 的全文,分页形同虚设):每个对齐行的 host / guest 字段就是双方各自上传里那一行的原样字段(该侧没有这一行时为 null),一侧的完整转录 = 逐页拼接 lines[].<role> 中非空的项,lines 按 (lp, side_rank) 对齐并对每行标 agree / only_host / only_guest / differ:比对前先把各自上传里相对上传方的 from(own_cat / own_human / peer_cat / peer_human)按该份的 role 换算成绝对说话人(host_cat / host_human / guest_cat / guest_human),agree 要求 text、绝对说话人与 truncated 三者都相同,任一不同即 differ(两个版本都给出,并附 diff_fields:['text'|'speaker'|'truncated'])——否则改过的客户端只改 from 或 truncated、保留正文,就能把人说的话改记成猫娘说的、或藏起截断而不被标出;管理员后台与举报核对展示同一结构;usage 只返回请求者本侧那份(对方的消耗不给看);某侧未上传时 uploaded.<role>=false

POST /admin/visit/bans ​

  • 方向: 管理员 → Servers
  • 面: 云端 HTTP(admin)
  • 字段: req {visit_uid:str(24), reason:str, until?:int} → 204;对偶 DELETE /admin/visit/bans/{visit_uid} → 204;GET /admin/visit/bans?visit_uid= → 列表
  • 字段集: req visit_uid, reason, until?
  • 上限: 效果 ① POST /api/visit/credentials 对该 sub 回 403 banned(下一场立即发不出去);② 客户端黑名单立即生效(hello 阶段拒,与服务端封禁独立);③ 在飞场踢人 = Servers 侧 follow-up(不阻塞 v1):接口名 POST /admin/visit/kick {visit_id, visit_uid} → Servers 按签发记录找到 vid 与 transport → TRTC 服务端 RemoveUserByStrRoomId(RoomId 字符串 + UserIds.N ≤10,https://cloud.tencent.com/document/product/647/50426 )/ LiveKit RoomService.RemoveParticipant(room, identity)(https://docs.livekit.io/home/server/managing-participants/ )→ 客户端收 KICKED_OUT{reason:'banned'} / Disconnected 当终态(4.3 state{kicked}),无需改客户端(C.6)

Servers 侧需要实现的清单(供排期) ​

  • 方向: —
  • 面: 云端
  • 字段: ① POST /api/visit/credentials(账号核验 → 封禁表 → 房间绑定 / invite_code → 配额扣减 → host 区域 → transport → 铸 UserSig 或 JWT → 签 Ed25519 票);② GET /api/visit/pubkeys;③ POST /api/visit/reports;④ POST|DELETE|GET /admin/visit/bans;⑤ 密钥托管:腾讯云 SDKAppID / SDKSecretKey、LiveKit API key / secret(Cloud 或自建)、Ed25519 私钥 + kid 轮换;⑥ 签发记录表 {visit_id, role, visit_uid, vid, transport, issued_at, expires_at, joined_at?, display_name, short_code, minutes_reserved, free_minutes_deducted?} + 每房一行 {visit_id, started_at?(双方都有 joined_at 的时刻), ended_at?(房间结束事件或服务端硬顶的时刻), duration_s?}——history / details 里的对方显示名、短码、开始 / 结束时间、时长与本场扣的分钟数都从这里读,房间结束、Servers 重启后照样能返回(joined_at 由 vendor 入房事件写入,转录上传据此判断这场是否真正开始过)(配额、房间绑定、踢人、举报核对、转录上传与详情鉴权都靠它);⑦ POST /api/visit/transcripts + 转录 / 用量存储(请求体上限 ≥1 MiB;支持 part / parts 分块收集、收齐一组才算完成;按 visit_id + role,长期保留、与账单记录同期,本机清除不删);⑧ GET /api/visit/details/{visit_id}(该场双方与管理员可读);⑧b GET /api/visit/history?cursor=(当前账号参与过的场次列表,记忆浏览器历史入口);⑪ GET /api/visit/invites/{invite_code}/preview(guest 确认框用的只读预览,不消耗邀请码,每账号 30 次/分钟限速);⑫ 画质档位服务端约束:LiveKit token 按侧位收紧(host canPublish:false、guest canPublishSources:['camera'],不含 microphone)+ 订阅 track_published webhook 超档踢人记违规(除宽高外还查 TrackInfo.simulcast == false 且 layers 只有 1 层,多编码层直接踢)+ 每 30 s ListParticipants 复核轨数与层数;码率:LiveKit 没有服务端强制单轨码率上限的配置,所以由接收方执行——host iframe 上报的入站码率(stats.rx_kbps,getStats 差分)连续 10 s 超过档位 1.5 倍 → host 本地取消订阅、finalize('peer_protocol_violation')(被改过的 guest 最多占用约 10 s 的 SFU 入口带宽);这个信号只用于本地自保、不作封禁依据——它由 host 自己的客户端测得并上报,改过的 host 可以诬告任何 guest;封禁只依据 vendor / SFU 侧可独立观测的数据(LiveKit webhook 与 ListParticipants 的轨 / 层信息、LiveKit Cloud 与 TRTC 的用量统计),上传 anomalies 里的这一条只作参考,TRTC 侧本类房间出现任何音频上行即踢 + 记违规(再犯封禁),并强制每房只允许 guest 的一条 camera 视频轨:已有活动视频轨时对新发布的视频轨调 RoomService.MutePublishedTrack 静音并记违规,再犯则 RemoveParticipant;TRTC 侧在用量核对里按「同账号同时多路视频」判违规走封禁;TRTC 定时拉用量统计 / 事件回调按账号比对分辨率档与码率,超档走封禁,并强制 host 零视频发布(host 出现任何视频流即踢 + 记违规,再犯封禁);⑭ POST /api/visit/rooms/{visit_id}/cancel(host 在 guest 入房前取消(以签发记录里 guest 的 joined_at 为界,guest 已领凭证但未入房时同样有效):作废邀请码、释放预留、结束 vendor 房间);⑮ 硬顶落库与巡检(hard_deadline_at 随签发记录持久化,每 60 s 与启动时重调过期未结束房间的 DismissRoom / DeleteRoom 直到 vendor 确认);⑬ 并发名额释放:以 vendor 房间结束事件为准(TRTC 房间解散回调 / LiveKit room_finished webhook),或 Servers 自行向 vendor 查询确认参与者 / 房间已不在之后释放;POST /api/visit/transcripts 只触发一次该查询、不直接释放;凭证到期兜底;(follow-up)⑨ POST /admin/visit/kick;⑩ Servers 侧「跨区允许 + 警告」开关
  • 上限: 复用既有 /api/users/me、/api/auth/session/bootstrap、/api/clients/register;Servers 宕机时新场次建不了;在飞场次只要不断线就不受影响,但 vendor 凭证只有 10 min、续期要找 Servers——凭证过期后再遇到 SDK 重连或页面重载就入不了房,这场按 relay_lost / local_page_lost 结束(§3.2.1 第 3 条)

派生规则、有效期与时钟 ​

下面是上面各端点里散落的派生规则与数字的汇总,数值以 config/visit_settings.py 为准(设计稿 §4.8)。

项规则 / 数值
visit_uidHMAC-SHA256(server_secret, community_uuid) 十六进制前 24 字符;首次派生后按账号落库、之后只读(换盐只影响还没有 visit_uid 的账号)
vidrole[0] + '_' + sha256(visit_uid + '|' + visit_id).hexdigest()[:24](26 字符,落 TRTC userId 字符集)
short_idvisit_uid[:6].upper()(UI 唯一可见形式)
身份票有效期guest exp = iat + 2400(40 min,VISIT_CREDENTIAL_TTL_S);host exp = iat + 3000(50 min,VISIT_HOST_CREDENTIAL_TTL_S)
vendor 入房凭证有效期一律 600 s(VISIT_VENDOR_GRANT_TTL_S):TRTC UserSig / privateMapKey expire=600,LiveKit JWT ttl=10m;重连前或剩余 <2 min 时重调 POST /api/visit/credentials 换发
时钟容差±300 s(VISIT_TICKET_CLOCK_TOLERANCE_S):iat - 300 ≤ now ≤ exp + 300
邀请码10 位 base32、10 min(VISIT_INVITE_CODE_TTL_S = VISIT_INVITE_WAIT_S = 600)、一次性;guest 必带;每房 host + guest 各一,第三者 403 room_full
公钥缓存VISIT_PUBKEYS_CACHE_S = 86400;每次领凭证顺带刷新;revoked 覆盖内置表
LiveKit 房间参数Servers CreateRoom 带 max_participants:2、departure_timeout:60(≥ VISIT_PEER_REJOIN_GRACE_S=35 加重连余量)、empty_timeout = VISIT_INVITE_WAIT_S;自建端 room.auto_create:false(部署模板在 N.E.K.O.Connect 仓库)

计费、免费额度与并发房 ​

细则以上面 POST /api/visit/credentials 的「上限」为准,这里只列结论:

  • 按凭证授权时长计费,预留制:host 建房时原子地扣 10 min 等候额度并预留 40 min(当日余额 − 已有预留 ≥ 50 min 才建,否则 429 quota_exceeded);guest 凭证签发时 host 的预留转为实扣、guest 实扣 40 min(guest 余额不足 → 429,邀请保持可用、host 预留不动);邀请到期仍无 guest 凭证 → 释放 host 预留、结束 vendor 房间。一场完整串门 host 共扣 50 min、guest 扣 40 min。
  • 服务端硬顶:guest 凭证签发后 40 min,Servers 调 TRTC DismissRoom / LiveKit DeleteRoom 结束房间;截止时刻 hard_deadline_at 随签发记录落库,每 60 s 与启动时巡检重调,直到 vendor 确认房间已不在。
  • 免费额度:VISIT_FREE_MINUTES_PER_DAY 占位 120,由 owner 定价时拍板;付费档只改响应里的 entitlement。
  • 并发房:每账号同时 ≤2 房(VISIT_MAX_CONCURRENT_ROOMS_PER_ACCOUNT);名额以 vendor 房间结束事件(TRTC 房间解散回调 / LiveKit room_finished)或 Servers 自行向 vendor 查询确认后释放;POST /api/visit/transcripts 只触发一次查询、不直接释放;凭证到期兜底。
  • host 取消邀请:POST /api/visit/rooms/{visit_id}/cancel(guest 还没有 joined_at 时有效,之后 409 guest_joined),作废邀请码、退回预留、结束 vendor 房间。

封禁与在飞踢人(follow-up) ​

  • POST /admin/visit/bans {visit_uid, reason, until?} → 下一场 POST /api/visit/credentials 回 403 banned;客户端黑名单在 hello 阶段立即生效(与服务端封禁独立)。
  • 在飞场踢人是 Servers 侧 follow-up,不阻塞 v1:POST /admin/visit/kick {visit_id, visit_uid} → 按签发记录找到 vid 与 transport → TRTC RemoveUserByStrRoomId / LiveKit RoomService.RemoveParticipant(两个 API 均已核实存在);客户端把 KICKED_OUT{reason:'banned'} / Disconnected 当终态,无需改客户端。

Servers 实现清单 ​

与设计稿 §5「N.E.K.O. Servers(闭源,独立排期)」一节同一份清单(其中「字段见 §4.7」即本文「端点」一章):

  1. POST /api/visit/credentials:校验社区 OAuth bearer → 查封禁表 → host:登记 visit_id → host visit_uid、按 host 来源 IP 区域定 transport、签 vendor 凭证 + 身份票、返回一次性 invite_code(10 min);guest:必带 invite_code,绑到该房(guest visit_uid == host visit_uid → 403 self_invite,Servers 侧测试:同账号兑换自己的邀请 → 403 且不签任何凭证、房间仍可被他人兑换,变异:不比 visit_uid 必红),拿同一 transport,两侧区域不同 → 403 cross_region_unsupported(开关可改「允许 + 警告」);每房 host + guest 各一;每账号并发房 ≤2(名额以 vendor 房间结束事件释放:TRTC 房间解散回调 / LiveKit room_finished webhook,或 Servers 自行向 vendor 查询确认该参与者 / 房间已不在之后释放;客户端 finalize 后的 POST /api/visit/transcripts 只触发一次这样的查询、不直接释放;凭证到期兜底;Servers 侧测试:上传转录时 vendor 查询显示仍在房 → 名额不释放,变异:上传即释放必红);每日签发分钟数按凭证授权时长扣(预留制,与 §4.7 同一规则:host 建房原子地扣 10 min 等候额度并预留 40 min(当日余额 − 已有预留 ≥ 50 才建);guest 签发时 host 的预留转为实扣、guest 实扣 40 min(guest 余额不足 → 429、host 预留不动);邀请到期无 guest 则释放预留并结束房间;签发后 40 min Servers 调 TRTC DismissRoom / LiveKit DeleteRoom 结束房间作服务端硬顶;Servers 侧测试:同账号已用 40 min 时连建两房 → 第二间因预留不足被拒(变异:只查余额不计预留必红);房间被强制结束后不能续用(host 邀请到期房间被 DismissRoom 后再调 credentials → 410 room_ended、零签发;换发凭证的 TRTC privateMapKey 保留建房位但 expire ≤ 服务端硬顶 − now,已结束场次被重建时 Servers 立即 DismissRoom;双方同时离开后用换发凭证恢复(续期之后 host 页面重载、guest 同时闪断 20 s:TRTC 用换发的 key 重建房间成功,LiveKit 房间因 departure_timeout=60 未被回收,两侧都在 35 s 内回到同一房;变异:换发去掉建房位、或 departure_timeout=20 必红);LiveKit 自建端 auto_create:false 下旧 JWT 入房失败;变异:重连返回同一份长期凭证必红——改过的客户端可在房间结束后重建房间);host 建房后 guest 入房 → host 共扣 50 min、guest 扣 40 min(预留转实扣、不重复扣;变异:只扣 guest 一方、或签发时再给 host 另扣 40 必红);同账号余额 120、连建两房且都被兑换 → 当日总扣 100 ≤ 120;邀请到期无人兑换 → host 预留释放、只扣 10 min(变异:到期不释放预留必红);客户端不离房、第 40 min 房间被结束,变异:不调结束房间 API 即红),免费档 VISIT_FREE_MINUTES_PER_DAY(占位 120,owner 拍板);tier != sd600 → 403 tier_not_entitled。配套只读端点 GET /api/visit/invites/{invite_code}/preview(bearer):guest 出门确认框的数据源,返回 {visit_id, host_display_name, host_short_code, cross_region, expires_at};不消耗邀请码(一次性 invite_code 只在 guest 领凭证成功时消耗)、不写签发记录、不扣配额;每账号 30 次/分钟限速,超限 429(邀请码 10 位 base32,枚举不可行);404 invite_invalid / 410 invite_expired / 403 banned。字段见 §4.7。
  2. GET /api/visit/pubkeys(公开,24 h 缓存)→ {keys, revoked:[kid]};kid 轮换靠发版 + 此端点;客户端每次领凭证顺带刷新,revoked 里的 kid 即使在内置表也拒(客户端测试:内置表含 kid K、刷新结果把 K 列入 revoked → 用 K 签的票被拒;变异:内置表命中就不查 revoked 必红——旧私钥可伪造身份;缓存过期且刷新失败(stale=True)时用内置 kid 签的票 → PubkeysStale 被拒,变异:stale 时仍查内置表放行必红)。
  3. POST /admin/visit/bans{visit_uid, until?} → 拒发新凭证;客户端黑名单在 hello 阶段拒(立即生效);follow-up(不阻塞 v1):封禁时对该 visit_uid 的在飞房调 vendor 服务端踢人(TRTC RemoveUserByStrRoomId、LiveKit RoomService.RemoveParticipant)。
  4. POST /api/visit/reports{visit_id, reason, note?, include_transcript, anomalies, app_version}(无转录正文,Servers 引用已存两份转录与差异标记):被举报方由 Servers 以 visit_id + 举报人账号从签发记录推导,请求不带 peer_uid(Servers 侧测试:本机「清除这个人」后 → 仍可举报——清除后本机名册与 spool 已无 peer_uid,举报照样受理、被举报方为签发记录里的另一侧;变异:要求请求带 peer_uid 即红);管理员按 visit_uid 反查社区账号。
  5. GET /api/visit/history?cursor=(bearer;只返回当前账号参与过的场次 visit_id, role, peer_display_name, peer_short_code, started_at, ended_at,按保留期分页;Servers 侧测试:只列本账号场次、不含正文,变异:返回他人场次即红)。
  6. 转录与用量(OD-26 v3):POST /api/visit/transcripts(bearer;按 visit_id + role 幂等;请求体上限 ≥1 MiB(协议最大值);支持 part / parts 分块:按 visit_id + role + parts + part 幂等、以 (visit_id, role, parts) 为一组收齐才算该侧完成(parts 必须进键,否则 413 重切后新一代的 part:0 会被当成上一代已受理的 part:0 回 duplicate);服务端上限(从 5 KB/s 与 20 条 / 10 s × 31 min 推出,不按猫娘句数):parts ≤ 128(超出 400 parts_out_of_range)、0 ≤ part < parts、单行 ≤4096 B、单份 ≤7500 行、只受理双方都有持久入房证据 joined_at 的场次(房间未结束时缺证据、或事后查询确认无人入房 → 409 visit_not_started;事后查询不可用 → 受理并标 join_unverified)、每账号每天 ≤200 MB / 50000 行(超出 429 transcript_quota_exceeded,客户端延后重试不删文件)、details 转录分页每页 ≤500 行、跨分块代累计接收 ≤80 MB(超出 413 transcript_budget_exceeded 并丢弃未收齐的块)、每侧同时只留一代未收齐组、未收齐组保留 8 天、每个分块响应带 accepted_parts(Servers 侧测试:parts=129 → 400;同一 visit_id + role 换不同 parts 反复上传直到累计超 80 MB → 413 且未收齐块被清空;人类发言很多的正常长场(双方猫娘各 40 句 + 人类 600 条短句)全部受理、不触发任何上限;部分块受理后隔 3 天续传 → 先收的块仍在、收齐成功;Servers 丢了一块后客户端续传 → 依响应的 accepted_parts 把它也补上、最终收齐;海量小行被拒:一份 20 万行、每行 1 字的转录 → 413,零留存;无人入房的房间上传转录(事后查询确认无人入房)→ 409 visit_not_started;短场次入房证据不丢(guest 入房 20 s 即退、入房 webhook 被丢弃且两次轮询之间就退房:participant_left 事件补写 joined_at;连它也丢 → 房间结束时事后查询补写;事后查询也不可用 → 受理并标 join_unverified,变异:只认入房 webhook 与轮询必红——合法转录被永久 409);同一账号一天内上传超 200 MB → 429;details 对 7500 行转录分 15 页返回;非最后一页恰好 500 行、本机请求恒带 limit=500(Servers 侧测试:非末页少于 limit 行必红);两侧行键完全不重合(各 7500 行、全是 only_host / only_guest)→ 30 页、/transcript 云端回落取全不报 cloud_transcript_incomplete(变异:页数上限按单份 15 页必红);每页响应只含该页对齐行、不含整份转录数组(变异:附整份 transcripts 必红——单页体积随全文增长);变异:按猫娘句数设行数上限、不设行数上限、不查双方入房、未收齐组 24 h 清理、或客户端只信本地已受理集合必红)、更大 parts 的新组替换未收齐的旧组(§4.7);role 对照签发记录核验;双方各传本侧那份;请求体含本场 usage{duration_s, llm_input_tokens, llm_output_tokens, tts_requests, tts_chars} 与按 (lp, side) 排序的转录)+ 存储(长期保留,与账单记录同期;本机清除不删);GET /api/visit/details/{visit_id}(时长、扣减免费分钟、token / TTS 消耗、双方两份转录原样保留并逐行标 agree / only_host / only_guest / differ(不以任何一方为准;举报与管理员后台展示同一结构;Servers 侧测试:一侧只改某行的 from(人改成猫)或 truncated、正文不变 → 该行标 differ 且 diff_fields 含 speaker / truncated(变异:只比正文必红);一侧篡改某行 → 该行标 differ 且两个版本都返回,变异:以说话方为准覆盖即红);只有该场双方账号与管理员可读);隐私政策补两段披露:(a) 对话全文上云、长期保留、用于账单与举报(确认框不提,owner 选择);(b) 隐私政策补一段「串门记忆」披露(owner 2026-10-01 同意写;只陈述事实),草稿:「使用串门功能时,你的猫娘与对方猫娘、双方亲人在串门中说的话,会保存在你和对方各自设备上的串门记忆中。串门记忆与你和猫娘的日常对话记忆相互隔离,只在她之后与同一个人串门时被用到;只有你在串门结束后选择『记成日记』并确认预览内容,相关内容才会写入日常记忆。你可以在本机清除某个人或全部串门记忆,但无法清除对方设备上的记录。」字段与错误码见 §4.7。
  7. 密钥托管:腾讯云 SDKAppID / SecretKey(UserSig tls-sig-api-v2)、LiveKit API key / secret(HS256)、Ed25519 签票私钥、server_secret(visit_uid HMAC 盐;visit_uid 首次派生后按账号落库、之后只读——换盐只影响还没有 visit_uid 的账号,已派发的 id 不变,写进运维文档)。
  8. IP:Servers 以来源 IP 复核区域,所以 Servers 知道用户 IP;对端经 SFU 拿不到(OD-01 v2 风险段已写)。
  9. 画质档位服务端约束(开源客户端可改 SDK 参数):LiveKit token 按侧位收紧(host canPublish:false、只 canPublishData;guest canPublishSources:['camera']),订阅 LiveKit webhook track_published(带宽高),超出凭证 tier 即 RoomService.RemoveParticipant 踢人 + 记违规;同一 webhook 上强制每房只允许 guest 的一条 camera 视频轨:已有活动视频轨时对新发布的视频轨调 RoomService.MutePublishedTrack 静音并记违规,再犯则 RemoveParticipant;TRTC 侧在用量核对里按「同账号同时多路视频」判违规走封禁(Servers 侧测试:guest 发第二条 camera 轨 → 被 mute + 违规记录,再犯(又发一条)→ 移除参与者;变异:只看尺寸不看轨数即红);TRTC 定时拉用量统计 / 事件回调,按账号比对实际分辨率档与码率,超档 → 封禁流程;本类房间零音频(TRTC 用量统计 / 事件回调发现任何音频上行 → 踢出该用户 + 记违规、再犯封禁,与 host 零视频同一套执行;LiveKit token canPublishSources 只含 camera、不含 microphone;Servers 侧测试:guest 推一路音频 → 被踢并记违规,变异:只查视频即红);Servers 按签名 role 强制「host 零视频发布」:TRTC 用量统计 / 事件回调里 host 账号(按签发记录里的 host vid)出现任何视频流——哪怕合规的一路——立即 RemoveUserByStrRoomId 踢出房间 + 记违规,再犯走封禁(Servers 侧测试:host 推一路合规视频 → 被踢并记违规,变异:只查多路 / 超档必红);T13 结果写进上线闸门:观众角色可用则 host 以观众角色进房;host 能否以 TRTC 观众角色进房待 T13。字段见 §4.7。

清单第 3 条的请求体是简写、省略了必填的 reason;字段以「端点」一章 POST /admin/visit/bans 为准。