猫娘串门 · 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}}| 401unauthenticated| 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· reqrole, visit_id, char_tag, display_name?, region_hint, tier, app_version, invite_code?· 200transport, 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 文件),本机映射为 409VISIT_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」,不足则 429quota_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 结束房间——TRTCDismissRoom/ LiveKitDeleteRoom——作服务端硬顶,改过的客户端也赖不过这个时长;硬顶要能扛住 Servers 自身故障:每房的截止时刻hard_deadline_at(guest 凭证签发 + 40 min;无 guest 时为邀请到期时刻)随签发记录落库,不只挂一个进程内定时器——Servers 另有每 60 s 一次的巡检任务(进程启动时先跑一次),对hard_deadline_at已过、vendor 侧仍未确认结束的房间重调DismissRoom/DeleteRoom,失败按退避重试直到 vendor 确认房间已不在(TRTC 房间解散回调 / LiveKitroom_finished或查询确认),才把该房标为已结束;Servers 重启、vendor API 一时报错都只是推迟、不会漏掉;host 主动取消邀请:POST /api/visit/rooms/{visit_id}/cancel(host 的 bearer;只要签发记录里 guest 还没有joined_at就有效,与 guest 是否已领凭证无关——guest 领了凭证但还没进 vendor 房时,没有对端能收leave,只能由 Servers 收回)→ 邀请码立即作废、该房标为已结束(之后 guest 兑换得 403invite_invalid、续期 / 重连得 410room_ended)、结束 vendor 房间(LiveKitauto_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 房间解散回调 / LiveKitroom_finishedwebhook,或 Servers 自行向 vendor 查询确认该参与者 / 房间已不在之后才释放;客户端 finalize 后的POST /api/visit/transcripts只触发一次这样的查询、不直接释放(否则改过的客户端可以边在房边上传转录来多开);都没来时凭证expires_at到期兜底),签发幂等:同一账号、同一role、同一visit_id重复请求(例如上次已在 Servers 登记 / 消耗邀请码 / 扣额度,但响应在路上丢了)→ 按签发记录换发一份新凭证返回 200,不再消耗邀请码、不重复扣额度、不回role_taken;只有另一个账号来抢已被占的侧位才role_taken/ 邀请码已用;重连(同visit_id+ 同visit_uid)同样不消耗配额,但 vendor 入房凭证是短期的:TRTCprivateMapKey与 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;两种有效期,各管各的:身份票按侧位——guestVISIT_CREDENTIAL_TTL_S=2400(40 min),hostVISIT_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 UserSigexpire=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 的roomgrant 对偶;权限位按侧位收紧:host1|2|32(建房 / 进房 / 收视频),guest1|2|16(建房 / 进房 / 发视频),音频与屏幕分享位全关(位定义以官方签名库 tls-sig-api-v2 为准:1 建房、2 进房、4 发音频、8 收音频、16 发视频、32 收视频、64 / 128 发 / 收辅路即屏幕分享;https://cloud.tencent.com/document/product/647/32240 ;Web SDK v5enterRoom的privateMapKey参数随 T 项实测核对),LiveKit JWT HS256ttl=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 订阅 webhooktrack_published(带轨道宽高),超出凭证tier的尺寸 →RoomService.RemoveParticipant踢人 + 记违规;同一 webhook 上还强制每房只允许 guest 的一条 camera 视频轨:已有活动视频轨时对新发布的视频轨调RoomService.MutePublishedTrack静音并记违规,再犯则RemoveParticipant;TRTC 侧在用量核对里按「同账号同时多路视频」判违规走封禁;Servers 按签名role强制「host 零视频发布」:TRTC 用量统计 / 事件回调里 host 账号(按签发记录里的 hostvid)出现任何视频流——哪怕合规的一路——立即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 | 类型 | 含义 | 接收侧核验 |
|---|---|---|---|
v | int=1 | 票版本 | 必须 1 |
iss | 'neko-servers' | 签发者 | 相等 |
aud | 'neko-visit' | 受众 | 相等 |
kid | str(≤16) | 公钥 id | 查 VISIT_SERVERS_PUBKEYS,miss → GET /api/visit/pubkeys 一次 → 仍 miss fail closed |
sub | str(24 hex) | visit_uid = HMAC-SHA256(server_secret, community_uuid) 十六进制前 24 字符(稳定、不透明、Servers 可反查;不是裸 uuid,C.1) | ∉ visit_blocklist.json;记忆 / 名册 / 举报主键 |
vid | str(26) | vendor userId / identity = role[0] + '_' + sha256(visit_uid + '|' + visit_id).hexdigest()[:24](C.3;落 TRTC userId 字符集) | == vendor 盖的 from_vid |
visit_id | str(22) | 房间 | == 本房 |
role | 'host'|'guest' | 侧位 | == 对侧 |
transport | 'trtc'|'livekit' | 一房一 transport | == 本侧 credentials.transport |
char_tag | str(32hex) | 对端自报角色标签(只在 sub 命名空间下有意义) | 派生 peer_char_id = 'c_' + sha256(peer_uid + '|' + char_tag)[:24] |
display_name | str(≤64)? | 猫娘显示名 | 经 OD-23 清洗后才进 UI / 名册 |
iat | int | 签发时刻 | iat - 300 ≤ now |
exp | int | = iat + 2400(guest 40 min)或 iat + 3000(host 50 min,VISIT_HOST_CREDENTIAL_TTL_S) | now ≤ exp + 300(VISIT_TICKET_CLOCK_TOLERANCE_S=300) |
jti | str(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>};pathinvite_code:str(10)→ 200{visit_id:str(22), host_display_name:str(≤64), host_short_code:str(6)(= hostvisit_uid[:6]大写), cross_region:bool(host 区域 vs 请求者区域,按POST /api/visit/credentials同一规则由 Servers 以来源 IP 判定), expires_at:float}(邀请码到期时刻), host_visit_uid:str(24)(只给本机后端代理比对本机黑名单,代理不转给前端)| 401unauthenticated| 404{code:'invite_invalid'}| 410{code:'invite_expired'}| 403{code:'banned'}| 429{code:'rate_limited', retry_after_s:int} - 字段集: headers
Authorization, X-Client-Id· pathinvite_code· 200visit_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 | 404unknown_visit(Servers 只接受自己签发过的visit_id且举报人是该房成员)| 429 | 5xx - 字段集: req
visit_id, reason, note?, include_transcript, anomalies, app_version· 201report_id· 200report_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,客户端以它为准)| 401unauthenticated| 403{code:'not_participant'}(签发记录里该账号不是本房该role)| 404unknown_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· reqvisit_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?· 201ok, accepted_parts?, complete?· 200ok, 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幂等(第二次上传返回 200duplicate: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,翻倍序列的下一档),超出 → 400parts_out_of_range;part必须是0 ≤ part < parts的整数;单行text≤4096 B;行数上限也从发送速率推出:每发送方必达text≤20 条 / 10 s——发送侧自限,接收侧也对对端执行同一上限(§4.2text:令牌桶每秒补 2、容量封顶 90,超出的回 ack 但不进转录 / spool,并计入异常),所以合规接收方的转录里对端行同样有界,改过的对端灌消息不会把本侧转录撑过上限,单份转录 ≤ 本侧 20/10 s × 1860 s(3720)+ 对端整场硬上限VISIT_INBOUND_TEXT_MAX(3750)= 7470 行,取VISIT_UPLOAD_MAX_LINES=7500(人类发言再多、对端再怎么灌也到不了;超出 → 413transcript_budget_exceeded);只接受真正开始过的场次:签发记录里双方都已有joined_at——入房证据在房间存活期间就落库:Servers 订阅 vendor 的参与者入房事件(LiveKitparticipant_joinedwebhook、TRTC 进房回调),核对事件里的vid与签发记录相符后写该行的joined_at(只写第一次);回调漏收时由每 30 s 的ListParticipants/ 房间成员查询补写;任何带参与者身份的 vendor 事件都算入房证据(LiveKitparticipant_left/track_published、TRTC 退房回调,与入房事件同样核对vid后写joined_at),房间结束事件到达时若某侧仍无joined_at,再向 vendor 的事后记录查一次(TRTC 历史用户 / 通话记录查询、LiveKit Cloud 会话分析;自建 LiveKit 没有事后记录,靠 webhook——是否有可用的事后查询接口随 T 项核对);事后查询不可用或失败时不判死:受理上传并在记录上标join_unverified:true,该场的转录与举报照常保留,但带这个标的场次不作为封禁的唯一依据——入房、退房都发生在一次轮询间隔内的短场次,回调与轮询可能同时漏掉,直接拒收会永久丢掉合法记录;上传通常发生在双方都退房之后,那时再向 vendor 实时查询已查不到人,所以受理上传只看这份持久记录、不现查 vendor。判定:双方都有joined_at→ 受理;房间尚未结束、仍缺某侧 → 409visit_not_started(客户端按退避重试,房间结束后再判);房间已结束、事后查询确认某侧从未入房 → 409visit_not_started(终态,防只建房、无人入房就上传整份伪造转录;host 在 guest 入房前崩溃的场次本来就没有可计费的对话);每账号留存预算:每个visit_uid每天新受理的转录合计 ≤VISIT_UPLOAD_ACCOUNT_DAILY_BYTES=200 MB且 ≤VISIT_UPLOAD_ACCOUNT_DAILY_LINES=50000行,超出 → 429transcript_quota_exceeded(客户端按retry_after_s延后重试,不删文件);每个(visit_id, role)跨所有分块代累计接收字节 ≤VISIT_UPLOAD_ROLE_BUDGET_BYTES = 2 × 40 MB(够一次整组重切重传,含被新一代丢弃的旧块),超出 → 413transcript_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的任何请求回 200duplicate: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· querycursor· 200items, 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'}| 404unknown_visit - 字段集: headers
Authorization· querycursor, limit· 200visit_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回 403banned(下一场立即发不出去);② 客户端黑名单立即生效(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 )/ LiveKitRoomService.RemoveParticipant(room, identity)(https://docs.livekit.io/home/server/managing-participants/ )→ 客户端收KICKED_OUT{reason:'banned'}/Disconnected当终态(4.3state{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}(该场双方与管理员可读);⑧bGET /api/visit/history?cursor=(当前账号参与过的场次列表,记忆浏览器历史入口);⑪GET /api/visit/invites/{invite_code}/preview(guest 确认框用的只读预览,不消耗邀请码,每账号 30 次/分钟限速);⑫ 画质档位服务端约束:LiveKit token 按侧位收紧(hostcanPublish:false、guestcanPublishSources:['camera'],不含microphone)+ 订阅track_publishedwebhook 超档踢人记违规(除宽高外还查TrackInfo.simulcast == false且layers只有 1 层,多编码层直接踢)+ 每 30 sListParticipants复核轨数与层数;码率: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 房间解散回调 / LiveKitroom_finishedwebhook),或 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_uid | HMAC-SHA256(server_secret, community_uuid) 十六进制前 24 字符;首次派生后按账号落库、之后只读(换盐只影响还没有 visit_uid 的账号) |
vid | role[0] + '_' + sha256(visit_uid + '|' + visit_id).hexdigest()[:24](26 字符,落 TRTC userId 字符集) |
short_id | visit_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/ LiveKitDeleteRoom结束房间;截止时刻hard_deadline_at随签发记录落库,每 60 s 与启动时巡检重调,直到 vendor 确认房间已不在。 - 免费额度:
VISIT_FREE_MINUTES_PER_DAY占位 120,由 owner 定价时拍板;付费档只改响应里的entitlement。 - 并发房:每账号同时 ≤2 房(
VISIT_MAX_CONCURRENT_ROOMS_PER_ACCOUNT);名额以 vendor 房间结束事件(TRTC 房间解散回调 / LiveKitroom_finished)或 Servers 自行向 vendor 查询确认后释放;POST /api/visit/transcripts只触发一次查询、不直接释放;凭证到期兜底。 - host 取消邀请:
POST /api/visit/rooms/{visit_id}/cancel(guest 还没有joined_at时有效,之后 409guest_joined),作废邀请码、退回预留、结束 vendor 房间。
封禁与在飞踢人(follow-up)
POST /admin/visit/bans {visit_uid, reason, until?}→ 下一场POST /api/visit/credentials回 403banned;客户端黑名单在 hello 阶段立即生效(与服务端封禁独立)。- 在飞场踢人是 Servers 侧 follow-up,不阻塞 v1:
POST /admin/visit/kick {visit_id, visit_uid}→ 按签发记录找到vid与 transport → TRTCRemoveUserByStrRoomId/ LiveKitRoomService.RemoveParticipant(两个 API 均已核实存在);客户端把KICKED_OUT{reason:'banned'}/Disconnected当终态,无需改客户端。
Servers 实现清单
与设计稿 §5「N.E.K.O. Servers(闭源,独立排期)」一节同一份清单(其中「字段见 §4.7」即本文「端点」一章):
POST /api/visit/credentials:校验社区 OAuth bearer → 查封禁表 → host:登记visit_id → host visit_uid、按 host 来源 IP 区域定transport、签 vendor 凭证 + 身份票、返回一次性invite_code(10 min);guest:必带invite_code,绑到该房(guestvisit_uid== hostvisit_uid→ 403self_invite,Servers 侧测试:同账号兑换自己的邀请 → 403 且不签任何凭证、房间仍可被他人兑换,变异:不比visit_uid必红),拿同一transport,两侧区域不同 → 403cross_region_unsupported(开关可改「允许 + 警告」);每房 host + guest 各一;每账号并发房 ≤2(名额以 vendor 房间结束事件释放:TRTC 房间解散回调 / LiveKitroom_finishedwebhook,或 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 调 TRTCDismissRoom/ LiveKitDeleteRoom结束房间作服务端硬顶;Servers 侧测试:同账号已用 40 min 时连建两房 → 第二间因预留不足被拒(变异:只查余额不计预留必红);房间被强制结束后不能续用(host 邀请到期房间被DismissRoom后再调 credentials → 410room_ended、零签发;换发凭证的 TRTCprivateMapKey保留建房位但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→ 403tier_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,枚举不可行);404invite_invalid/ 410invite_expired/ 403banned。字段见 §4.7。GET /api/visit/pubkeys(公开,24 h 缓存)→{keys, revoked:[kid]};kid轮换靠发版 + 此端点;客户端每次领凭证顺带刷新,revoked里的 kid 即使在内置表也拒(客户端测试:内置表含 kid K、刷新结果把 K 列入revoked→ 用 K 签的票被拒;变异:内置表命中就不查 revoked 必红——旧私钥可伪造身份;缓存过期且刷新失败(stale=True)时用内置 kid 签的票 →PubkeysStale被拒,变异:stale 时仍查内置表放行必红)。POST /admin/visit/bans{visit_uid, until?}→ 拒发新凭证;客户端黑名单在 hello 阶段拒(立即生效);follow-up(不阻塞 v1):封禁时对该visit_uid的在飞房调 vendor 服务端踢人(TRTCRemoveUserByStrRoomId、LiveKitRoomService.RemoveParticipant)。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反查社区账号。GET /api/visit/history?cursor=(bearer;只返回当前账号参与过的场次visit_id, role, peer_display_name, peer_short_code, started_at, ended_at,按保留期分页;Servers 侧测试:只列本账号场次、不含正文,变异:返回他人场次即红)。- 转录与用量(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(超出 400parts_out_of_range)、0 ≤ part < parts、单行 ≤4096 B、单份 ≤7500 行、只受理双方都有持久入房证据joined_at的场次(房间未结束时缺证据、或事后查询确认无人入房 → 409visit_not_started;事后查询不可用 → 受理并标join_unverified)、每账号每天 ≤200 MB / 50000 行(超出 429transcript_quota_exceeded,客户端延后重试不删文件)、details转录分页每页 ≤500 行、跨分块代累计接收 ≤80 MB(超出 413transcript_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,零留存;无人入房的房间上传转录(事后查询确认无人入房)→ 409visit_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。 - 密钥托管:腾讯云 SDKAppID / SecretKey(UserSig tls-sig-api-v2)、LiveKit API key / secret(HS256)、Ed25519 签票私钥、
server_secret(visit_uidHMAC 盐;visit_uid首次派生后按账号落库、之后只读——换盐只影响还没有visit_uid的账号,已派发的 id 不变,写进运维文档)。 - IP:Servers 以来源 IP 复核区域,所以 Servers 知道用户 IP;对端经 SFU 拿不到(OD-01 v2 风险段已写)。
- 画质档位服务端约束(开源客户端可改 SDK 参数):LiveKit token 按侧位收紧(host
canPublish:false、只canPublishData;guestcanPublishSources:['camera']),订阅 LiveKit webhooktrack_published(带宽高),超出凭证tier即RoomService.RemoveParticipant踢人 + 记违规;同一 webhook 上强制每房只允许 guest 的一条 camera 视频轨:已有活动视频轨时对新发布的视频轨调RoomService.MutePublishedTrack静音并记违规,再犯则RemoveParticipant;TRTC 侧在用量核对里按「同账号同时多路视频」判违规走封禁(Servers 侧测试:guest 发第二条 camera 轨 → 被 mute + 违规记录,再犯(又发一条)→ 移除参与者;变异:只看尺寸不看轨数即红);TRTC 定时拉用量统计 / 事件回调,按账号比对实际分辨率档与码率,超档 → 封禁流程;本类房间零音频(TRTC 用量统计 / 事件回调发现任何音频上行 → 踢出该用户 + 记违规、再犯封禁,与 host 零视频同一套执行;LiveKit tokencanPublishSources只含camera、不含microphone;Servers 侧测试:guest 推一路音频 → 被踢并记违规,变异:只查视频即红);Servers 按签名role强制「host 零视频发布」:TRTC 用量统计 / 事件回调里 host 账号(按签发记录里的 hostvid)出现任何视频流——哪怕合规的一路——立即RemoveUserByStrRoomId踢出房间 + 记违规,再犯走封禁(Servers 侧测试:host 推一路合规视频 → 被踢并记违规,变异:只查多路 / 超档必红);T13 结果写进上线闸门:观众角色可用则 host 以观众角色进房;host 能否以 TRTC 观众角色进房待 T13。字段见 §4.7。
清单第 3 条的请求体是简写、省略了必填的
reason;字段以「端点」一章POST /admin/visit/bans为准。
