Files
trade-message-center/.trellis/spec/server/backend/service-foundation.md
T

45 KiB
Raw Blame History

服务端基础设施契约

1. Scope / Trigger

  • Trigger:服务端首次引入 Fastify 启动、WebSocket 传输和 PostgreSQL ORM。
  • Scopeapps/server/src/ 的配置、应用组合、健康路由、WebSocket 生命周期和数据库连接。
  • ExcludedOneTalk 业务字段规则和 repository 细节由数据库规范与 src/onetalk/ 领域模块定义;发送队列仍不在本服务基础设施内。协议帧与可替换授权端口由共享 contract 包定义,数据库迁移由数据库规范单独定义。Mind 生产认证通过两个 HTTP 接口接入,具体目标契约见 Mind HTTP 授权

2. Signatures

loadConfig(environment: Record<string, string | undefined>): ServerConfig
createDatabase(databaseUrl: string): { db: Database; close(): Promise<void> }
createApp(config: ServerConfig, dependencies?: AppDependencies): FastifyInstance
createServerRuntime(config: ServerConfig, dependencies?: ServerRuntimeDependencies): ServerRuntime
startServer(): Promise<void>

3. Contracts

  • 必填环境变量:HOSTPORTDATABASE_URL
  • 可选的 NODE_ENV 在配置边界规范化;只有 trim 后精确等于 development 的值才启用仅开发环境的 loopback Mind HTTP 配置,缺失或未知值保持 fail-closed。
  • GET /health 返回 { "status": "ok" }
  • WebSocket 路由是 GET /ws/pluginGET /ws/mind;每条路由在 upgrade 前绑定自己的连接类型并检查精确的 Origin allowlist。GET /ws 是仅拒绝的兼容行为(426 onetalk_protocol_upgrade_required),没有 legacy handler、outbox 或分发路径。畸形或不支持的协议帧以 1003 关闭,连接/handler 错误以 1011 关闭。
  • createApp 永不调用 listenentry.ts 是唯一进程入口,它调用 runtime.ts 的双 listener lifecycleruntime.ts 只绑定 public HOST:PORT 与 internal 0.0.0.0:7777,并在任一 bind 失败时关闭两者。业务/HTTP 模块不得直接监听端口。
  • 数据库资源通过应用 onClose hook 关闭;URL 和凭证不进入响应或日志。
  • createApp 在同一个 database.db 上组合注入/默认的 OneTalkServiceOneTalkProfileServiceOneTalkReadServiceAppDependencies 可以为测试或部署适配器注入这些领域端口、连接 registry 和 publisher 失败 sink。
  • WebSocket 业务帧经过 service boundary;成功观察的顺序是数据库提交 → 插件 message.ack → 已授权 Mind message.createdmessage.created 与 HTTP history 都必须从同一 normalized JSONB fact 投影 shared OneTalkCenterMessage,不得暴露顶层 text/contentType 或 raw payload。
  • contact.profile.observed 是 Bright 持久化路径:canonical binding/read/sync 授权 → profile service → guarded Bright transaction → post-write fence → contact.profile.ack;它不调用 Mind profile HTTP 或消息服务。AppDependencies.profileService 是测试/部署接缝。

4. Validation & Error Matrix

条件 行为
缺失或空白的 HOST 抛出 Missing HOST
缺失或空白的 PORT 抛出 Missing PORT
PORT 不在 1..65535 或非整数 抛出 Invalid PORT: expected integer 1-65535
缺失或空白的 DATABASE_URL 抛出 Missing DATABASE_URL
收到畸形或不支持的 WebSocket 帧 发送稳定协议错误,然后以 1003 关闭当前 socket
收到 WebSocket 错误 1011 关闭当前 socket
profile future-skew 或 guarded 持久化被拒绝 稳定的 profile_observed_at_future/数据库失败;无 profile ACK
profile 事务成功但授权/连接/policy fence 已过期 无迟到 ACK;插件 pending 保留

5. Good / Base / Bad Cases

  • Good:测试注入 { db, close },调用 app.ready(),验证 /health,然后观察 close 恰好被调用一次。
  • Base:生产启动从 DATABASE_URL 创建 Drizzle client,并在配置的 host/port 上监听。
  • Bad:路由 handler 读取 process.env、创建第二个 postgres client,或在错误中返回连接 URL。

6. Tests Required

  • 配置测试断言缺失 URL 和非法端口的错误包含字段/错误类别,但不包含秘密值。
  • Health 测试断言 HTTP 200 和精确的 { status: "ok" } 响应。
  • Lifecycle 测试断言注入的数据库 closeapp.close() 时恰好执行一次。
  • WebSocket 注册测试断言 websocketServer 存在、GET /ws/pluginGET /ws/mind 已注册、GET /ws 为仅拒绝;OneTalk 协议测试断言 mock 授权握手、heartbeat、版本拒绝和授权失败。
  • OneTalk 业务测试断言原始观察校验、会话发现、同步完成、anchor snapshot、仅插件写入和提交后发布行为。

7. Wrong vs Correct

Wrong

app.get("/health", async () => ({ databaseUrl: process.env.DATABASE_URL }));

Correct

app.get("/health", async () => ({ status: "ok" }));

health 边界稳定且不含秘密;数据库探测属于后续的运维契约。

Scenario: OneTalk authenticated WebSocket route metadata

1. Scope / Trigger

  • Trigger:新增、迁移或重构已认证 OneTalk client frame 的 endpoint ownership、authorization 或生产分发。
  • Scopewebsocket/authenticated-router.ts 拥有 non-hello route metadataendpoint-routes.ts 用它校验固定 endpoint 的 per-socket handler table 并分发。handler.ts/protocol-kernel.ts 继续拥有 decode、固定 endpoint/scope gate 和 socket FIFO;业务 Flow、registry 与 pending-send coordinator 继续拥有状态及最终副作用。
  • Excluded:不改 shared wire/decoder、ws.hello bootstrap、connection registry 或业务 Flow 的授权/commit/ACK/publish 次序。

2. Signatures

createOneTalkAuthenticatedRouter() -> OneTalkAuthenticatedRouter
createOneTalkEndpointAuthenticatedRouter(endpoint, router, handlers) -> { dispatch(frame): Promise<void> }
defineOneTalkEndpointRouteHandler(type, handler) -> OneTalkEndpointRouteHandler

3. Contracts

  • ONETALK_AUTHENTICATED_ROUTE_DEFINITIONS 是每个 non-hello client frame 的 endpoint ownership、session | pending_send authorization 与 operation 的唯一声明;必须从 ONETALK_CLIENT_FRAME_TYPES 完整校验 missing、duplicate、unknown 与 ws.hello
  • installWebsocket 只构造一次 shared router 并注入 Plugin/Mind endpoint。每个已建立 socket 都从同一 metadata 建立 fixed-endpoint viewview 的 handler table 必须与该 endpoint 的可拥有 type 完全相等,不能有 no-op/fallback handler 或另一份 endpoint type list。
  • dispatch 在调用业务 collaborator 前检查 frame connection type 与 endpoint ownership。正常生产顺序保持 decode -> admission -> fixed endpoint/scope -> FIFO -> endpoint router -> existing handlerrouter 不得重新解析 raw data、创建第二个 session/FIFO 或把 pending_send 路径改为通用 session authorization。
  • ws.hello 永不注册为 authenticated route;它继续由 endpoint bootstrap 处理。

4. Validation & Error Matrix

条件 必须行为
metadata 缺少、重复、未知或包含 ws.hello router construction 失败
endpoint handler 缺少、重复或不归属该 endpoint endpoint view construction 失败
合法 client frame 发往另一个 fixed endpoint kernel 在业务 collaborator 前发 ws.error(scope_mismatch) 并 close 1008
send.request / send.confirmation 仍走原 pending-send coordinator;不得改为 session authorization
同 endpoint 的非 client/未认证/重复 hello 保持 kernel 既有 error/close 优先级,不进入 router

5. Good / Base / Bad Cases

  • GoodPlugin 的 handler table 从 shared metadata 验证后只把收窄 frame 委派给既有 sync/profile/buyer/confirmation FlowMind 的 send.request 仍委派现有 request Flow。
  • Baseheartbeat 是唯一双 endpoint route,但两个 endpoint 各有自己的 authorization adapter 与 handler closure。
  • Bad:在 Plugin/Mind endpoint 保留 if (frame.type === ...) 作为第二份 owner selector,或只在启动期检查独立 ONETALK_*_ROUTE_TYPES

6. Tests Required

  • router tests 必须从 canonical metadata 断言全量 non-hello coverage、metadata failure、exact endpoint handler table 与 wrong-ownership collaborator 零调用。
  • injectWS 必须发送合法 Mind frame 到 /ws/plugin 和合法 Plugin frame 到 /ws/mind,断言 scope_mismatch、close 1008,并观察目标业务 collaborator 为零调用。
  • 保留 full client-frame wire matrix 与 pending confirmation、profile/buyer post-write fence、observation ACK/publish、sync completion flush 的 focused tests;重构不能以私有 router 调用替代这些 wire 断言。

7. Wrong vs Correct

// Wrong: endpoint type list 与生产选择器可能各自漂移。
if (frame.type === "send.request") return sendRequestFlow.handleRequest(socket, frame, epoch);

// Correct: 固定 endpoint 分发由 shared metadata 校验过的精确 handler table。
const router = createOneTalkEndpointAuthenticatedRouter("mind_page", metadata, handlers);
return router.dispatch(frame);

Scenario: Bright v3 authorization and commit fences

1. Scope / Trigger

  • TriggerMind 授权、Bright WebSocket 生命周期和 OneTalk 事实写入共享多个异步边界,需要避免 pause、连接替换或迟到回报穿透副作用。
  • Scopecutover-policy.tswebsocket/registry.tswebsocket/handler.tsonetalk/service.ts 和 guarded repository ports。
  • ExcludedMind 的远端 revocation push/lease、真实 Mind 页面、浏览器/TLS、跨仓库 legacy 删除和 PostgreSQL 环境验收。

2. Signatures

createOneTalkCutoverPolicy(initial?: { enabled: boolean; paused: boolean }): OneTalkCutoverPolicy
registry.requestSend({ mindSocket, frame }): Promise<OneTalkSendResultFrame["payload"]>
registry.createCommitGuard(connection, policyEpoch): OneTalkCommitGuard
store.isPluginLeaseFresh(connection): boolean
repository.guardedInsertMessage(context, source, message, guard): Promise<InsertResult>
repository.guardedUpdateSyncState(context, update, conversationId, guard): Promise<Conversation | null>

3. Contracts

  • Bright v3 policy 只通过 enabled/paused/epoch 控制 admissionpause/resume 可以恢复 Bright v3,不依赖或保存 firstBrightFactWritten/markBrightFactWritten
  • OneTalkConnectionRegistry 是 handler-facing façade;其内部 connection-store 是 canonical connection、generation 和 plugin presence 的唯一 ownermind-publisher 只做 Mind 二次授权/帧发送/失败上报,pending-send-coordinator 是 in-flight sendRequestId reserve、phase、terminal transition、timeout、disconnect、pause 和 late confirmation 的唯一 owner。它只保存进行中的 attempt;settle 必须清除 pendingSendspendingConversationSends,不得持有终态 payload、终态 ID 去重历史或重放缓存。三个协作者必须注入同一 store,不能在 handler 或其它模块复制 socket/generation/pending map;所有 wire send 后结果不可自动重试。
  • Plugin 的可路由性由 store 的 canonical lease 唯一决定:isPluginLeaseFresh 只接受仍注册的 plugin,且 lastHeartbeatAtMs > now() - heartbeatTimeoutMsrequestSend 必须在候选选择和全部异步授权后的无 await 最终 fence 都调用它;expired lease 即使 sweep 尚未运行也只能得到既有 rejected_before_send/waiting_for_page,不得发出 send.command
  • HTTP 读取只依赖 OneTalkPluginPresenceReader.isPluginOnline(scope),不得为读取路径注入或依赖完整 registry 的发送、连接管理或发布能力。
  • 远程 observation/discovery/sync/confirmation 的 database side effect 必须带同一 canonical connection/generation/policy guard。guard 失效必须使事务回滚,不返回伪造的 accepted/duplicate。

4. Validation & Error Matrix

条件 结果
相同 ID 的并发 send 在 attempt 仍进行中 一个 attempt,另一个 rejected_before_send/duplicate_request;终态后复用 ID 不属于 coordinator 协议契约
候选 plugin 已过 heartbeat lease,或在授权期间 lease 过期 rejected_before_send/waiting_for_page;无 send.command
wire send 前断线、替换或 pause rejected_before_send/waiting_for_page
wire send 后断线、替换或 pause delivery_unknown/send_connection_lost
confirmation timeout/non-success/duplicate/late 唯一 terminal;清 timer 与全部 pending 索引;终态后的 confirmation no-op
pause 期间的既有 WS close 1013/authorization_unavailable,不先发 ws.error
guarded transaction 在任一异步边界失效 rollback;不 ACK、publish 或返回 confirmed result

5. Good / Base / Bad Cases

  • Good:最后一次授权返回后只做同步 canonical/generation/epoch/open 检查,再调用 socket.send
  • Good:store 在一个查询内同时确认 plugin 身份、canonical membership 与 heartbeat deadlinecoordinator 在选择和发送前均复用该查询。
  • Base:本地 fake repository 和 fake Mind fetch 证明调用顺序与 fail-closed 逻辑;真实 PostgreSQL/Mind/浏览器仍需独立环境验收。
  • Bad:把 pending 记录放在 authorization await 之后、在 confirmation 中重新授权原 Mind Session、或为 guarded write fallback 到普通 insert。

6. Tests Required

  • 使用可控 Promise latch 覆盖 authorization、service、transaction、publish 各 await 点的 pause/disconnect/replacement;关键竞态连续 100 次运行。
  • 断言 message.created 只在 DB commit 后 publishterminal/guard 失败不写库、不 ACK、不 publish;独立 PostgreSQL 测试配置 TEST_DATABASE_URL 后再验证真实 rollback/commit 线性化。
  • registry 重构测试必须锁定 façade 的 canonical cleanup/plugin replacement、精确 scope 发布与二次授权、presence 通知、pending-send timeout/connection-loss 映射和 confirmation 单次 claim;拆分后 connection/generation 与 pending/terminal 状态各自只能有一个 owner。
  • send admission 测试以可控 clock 覆盖 lease 边界和授权 await 后过期;两种情况都断言无 send.command,非过期 lease 保持既有 dispatch/confirmation 行为。

7. Wrong vs Correct

Wrong

const pending = new Map<string, PendingSend>();
const connections = new Map<WebSocket, Connection>();
// handler 或 publisher 再维护自己的 connection/generation/pending 真相。

Correct

const store = createOneTalkConnectionStore({ cutoverPolicy });
const publisher = createOneTalkMindPublisher({ authorization, store, onPublishFailure });
const pendingSends = createOneTalkPendingSendCoordinator({ authorization, store });
const registry = { ...store, ...publisher, ...pendingSends };

协作者共享同一 canonical storefaçade 维持调用方兼容;不会因拆文件而改变 guard、publish 或 send 的既有时序。

Scenario: 环境驱动的 Mind HTTP 授权

1. Scope / Trigger

  • Trigger:服务端需要通过独立的本地 Mind 授权模拟运行 OneTalk HTTP/WS 联调,同时保留非开发环境的安全默认。
  • Scopeapps/server/src/config.tsapps/server/src/mind-authorization.tsapps/server/src/app.ts、server dev script 和 apps/mind-test-harness/
  • Excluded:真实 Mind Session/Cookie 认证、业务授权后台、数据库权限和请求级认证逻辑不由本地模拟代替。

2. Signatures

loadConfig(environment: Record<string, string | undefined>): ServerConfig
// ServerConfig.environment: "development" | "non_development"
createApp(config: ServerConfig, dependencies?: AppDependencies): FastifyInstance

3. Contracts

  • loadConfig trim NODE_ENV;只有值严格为 development 时返回 environment: "development",空值、缺失、大小写变体和其它值返回 non_development
  • loadConfigNODE_ENV=development 时读取 MIND_AUTH_BASE_URLMIND_PAGE_ORIGINONETALK_PLUGIN_ORIGINS 和可选 MIND_AUTH_TIMEOUT_MS;其中 HTTP 只允许 loopback Origin,供 mind-test-harness 的 Mind 授权模拟使用。
  • createApp 在未显式提供 dependencies.authorization 时使用配置的 createMindAuthorizationReader;显式 reader 在所有环境优先。development 不再从 ONETALK_DEV_* 构造进程内授权 fixture。
  • Mind 授权模拟只拥有独立的本地固定 fixture,Bright 仍通过两个 HTTP endpoint 验证 binding/Cookie/status/body;模拟不读取 Mind DB、不推断请求主体,也不改变生产授权边界。
  • apps/server/package.jsondev script 明确设置 NODE_ENV=development;其它启动路径不设置 bypass。
  • installWebsocket 未传 authorization 时仍使用 fail-closed reader,不能因 server dev 默认而改变独立安装语义。

4. Validation & Error Matrix

Condition Result
NODE_ENV=development 且 Mind 授权模拟配置完整 createApp 通过 Mind HTTP adapter 授权;模拟返回允许时匹配 scope 的 HTTP/WS 请求可进入已有业务边界
NODE_ENV=developmentMIND_AUTH_* 配置缺失/非法 启动失败,不创建任意 scope 的默认授权
NODE_ENV=development 但 Mind 授权模拟不可用 授权返回 authorization_unavailable,不绕过网络边界
缺失、空值、未知值或非精确 development createApp 默认 authorization_unavailablefail-closed
development 下显式注入拒绝 reader 按注入 reader 拒绝,不被 bypass 覆盖
development 下显式注入允许 reader 按注入 reader 返回其 binding/version/permissions
独立 installWebsocket 未注入 reader 仍返回 authorization_unavailable

5. Good / Base / Bad Cases

  • Good:需要时显式运行 pnpm dev:harness 启动本地 Mind 授权模拟,createApp 通过固定 HTTP adapter 授权;pnpm dev 与 server/extension 主流程不依赖该低优先级工具,测试/生产仍按各自 URL/HTTPS 规则运行。
  • Base:真实 Mind HTTP auth 通过同一个 createMindAuthorizationReaderAppDependencies.authorization 接入,环境选择不改变业务授权调用。
  • Bad:在 createApp 中无条件 allow、把 NODE_ENV 缺失当作 development,或在 HTTP header/host 上推断开发模式。

6. Tests Required

  • 配置测试覆盖 development(含首尾空格)、loopback HTTP、缺失、空值、test/staging/production、大小写变体和拼写错误。
  • App/adapter 测试覆盖默认 development HTTP/WS 授权、Mind 授权模拟不可用、non-development/unknown 默认拒绝,以及显式允许/拒绝 reader 优先级。
  • WebSocket 安装测试覆盖独立 installWebsocket 的默认 fail-closed;断言 bypass 标识和凭证不出现在可见响应。
  • 根级 typecheck、build、test、format check 和 server db:check 必须保持通过。

7. Wrong vs Correct

Wrong

const authorization = dependencies.authorization ?? createUnavailableAuthorizationReader();

Correct

const authorization =
    dependencies.authorization ??
    (config.mindAuthorization
        ? createMindAuthorizationReader(config.mindAuthorization)
        : createUnavailableAuthorizationReader());

授权默认值必须来自固定的 Mind HTTP 配置或显式注入,不能在应用组合层无条件 allow。

Scenario: Bright 历史读取与 Mind 联调页

1. Scope / Trigger

  • TriggerBright 事实消息已经由 OneTalk repository 持久化,需要供 Mind 页面读取历史,并通过同一服务接收授权后的实时状态/消息事件。
  • Scopeapps/server/src/http/onetalk/apps/server/src/onetalk/read-cursor.tsapps/server/src/websocket/registry.ts 的跨层契约;Mind 联调页本身归 apps/mind-test-harness/server 不注册 /harness 页面路由。
  • ExcludedMind legacy 消息源、插件同步代理、发送 outbox 和未确认发送事实不属于此读取边界。

2. Signatures

GET /api/bright/onetalk/accounts/:channelAccountId/conversations
GET /api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId
GET /api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId/messages?fromSentAtMs=<inclusive>&toSentAtMs=<exclusive>&limit=1..100&cursor=<opaque>

OneTalkReadService.listConversations({ scope, query?, cursor?, limit? }) -> result
OneTalkReadService.readConversation({ scope, conversationId }) -> result
OneTalkReadService.readHistory({ scope, conversationId, fromSentAtMs?, toSentAtMs?, cursor?, limit?, purpose? }) -> result
encodeOneTalkListCursor(cursor) -> string
decodeOneTalkListCursor(value) -> cursor | null
encodeOneTalkHistoryReadCursor(cursor) -> string
decodeOneTalkHistoryReadCursor(value) -> cursor | null

3. Contracts

  • HTTP scope 只取路径 channelAccountId 加 Mind Session 授权返回的完整 mindScope;不接受客户端 user/workspace/device header 作为主体。每个请求都使用 OneTalkAuthorizationReader.authorize({ connectionType: "mind_page", operation: "read", scope: { channelAccountId }, cookie? })
  • 会话只读取 conversation_kind = "direct" 的显式 direct fact。列表/详情返回共享 CenterConversationname/avatarUrl 为当前 profile row 的实时值,participantIds 固定为空数组,unreadCount 固定为 0latestMessageId 来自已确认业务锚点,latestMessageAtMs 来自 OneTalk 会话列表活动时间,两者允许独立为空。
  • 列表 query 先 trim,按名称或 conversationId 做 Unicode-insensitive substring;列表直接读取 onetalk_conversation.last_message_at_ms,排序与 cursor 都使用绑定账号、query、asOf、(latestMessageAtMs, conversationId) 的同一 keyset。实时消息只单调推进会话时间;活跃会话在跨页期间前移时由列表刷新重新出现。profile 必须以同一账号与页面会话复合键另行受限读取,再在内存组合;不得把实时资料当作 SQL JOIN 例外。
  • 历史 cursor 不透明且独立绑定账号、会话、from/to 半开窗口、asOf 和 (sentAtMs, messageId) keyset;时间窗为 from <= sentAtMs < to。内部 7777 summary listener 必须同时提供两端时间。
  • HTTP history 与 message.created 都只返回 shared OneTalkCenterMessage:语义 readStatus 加同一 content.version=1text | image | file | business_card | inquiry | order | product union。business_card 在消息事实中严格是 { version: 1, kind: "business_card" } markerread projection 先按同一 channelAccountId + conversationId 受限读取当前 profile,再以内存方式扩展为 contactNamecompanyNamecountryCodeavatarUrl 四项 view 字段。没有 profile 时返回 markerprofile 部分缺失时对应字段为 null,且 view 不得写回 message/profile。read projection 不得解 Base64、custom.datacontentType、卡片原始正文/params、文件名或 URL fallback;它只复制 shared contract 已批准的字段。
  • Mind 联调页由 apps/mind-test-harness/ 提供,只通过同源 Bright HTTP/WS 访问数据;页面侧的运行时形状校验、文本转义、图片/文件展示和去重边界见 mind-test-harness 规范,不属于 server 路由契约。
  • 插件 plugin.statussync.statusmessage.created 只发送给当前仍通过二次 read 授权的精确 Mind scope;消息必须遵循数据库提交 → plugin ACK → Mind publish。public HTTP CORS 只允许精确 Origin 和 Content-Typeinternal summary listener 不注册 CORS。

4. Validation & Error Matrix

Condition Result
Cookie/Session 授权上下文缺失或无效 HTTP 401 auth_required 或授权适配器稳定拒绝
路径账号与授权返回 scope 不一致,或请求带未允许身份 header HTTP 403 scope_mismatch
授权拒绝/撤销/版本变化 HTTP 403 与对应稳定授权错误码
授权依赖不可用 HTTP 503authorization_unavailable
limit 非整数或不在 1..100 HTTP 400invalid_limit
cursor 无法解码或跨账号/会话使用 HTTP 400invalid_cursor
internal summary 缺少 from/to,或 from >= to HTTP 400invalid_time_range
会话不存在 HTTP 404conversation_not_found
history 尚未完整且请求 internal summary HTTP 503 history_incompleteRetry-After: 30
数据库读取失败 HTTP 503database_unavailable;不返回内部异常
插件无在线连接 仍可读历史;响应/事件状态为 offline,实时发送能力保持禁用
消息 kind 为 business_card 且存储 content 含客户资料字段 数据库 CHECK/应用 exact-shape 拒绝;迁移先归一化为 marker
名片读取时同账号同会话没有 profile 返回 marker,不回退到登录人或 item.contact
名片 profile 仅有部分批准字段 返回 view,其余批准字段为 null
profile 属于其他账号或会话 不参与组合;按无 profile 处理

5. Good / Base / Bad Cases

  • Good:页面先读取 Bright direct list/detail/history,保存并回传 opaque cursor,再连接 WS;断线刷新依靠历史恢复,收到重复 message.created 不重复渲染。
  • Base:插件状态来自当前进程 registry;页面/接口显示 offline 不代表历史不可读,当前 profile 变化可以实时反映到列表名称和头像;名片没有 profile 时仍能稳定返回 marker。
  • BadHTTP 路由访问 Mind legacy 表或代理插件接口,使用 anchor/latestMessageId 作为 cursor,放宽 direct filter,在消息观察时使用登录人的 item.contact,把读取 view 写回 message,或把未提交消息先推给页面。

6. Tests Required

  • HTTP:public 列表、详情、历史首/后续页、internal summary gate、direct filter、profile 实时内存组合、query、独立 cursor/asOf、半开窗口、scope/CORS 校验、授权失败、未知会话、offline 状态、非法 limit/cursor/time range、数据库失败和无秘密响应;六类 content 都必须只含 shared normalized 字段,业务卡必须验证 marker→同账号同会话 profile view、无 profile marker、部分 profile null,且不能恢复 Base64 或 raw SDK 字段。
  • WebSocketMind hello/accepted、plugin online/offline、sync status、精确 scope、二次授权、提交后 ACK/publish 顺序、history/live 对同一事实的公开投影等价,以及断线后的连接清理。
  • Mind 联调页行为(offline send gate、list/history query paging、异步代际 fence、active-scope guard、runtime shape validation、文本转义、image load error、conditional file links 和去重关键字段)按 mind-test-harness 规范手工验证;server 自动化测试只覆盖 Bright public HTTP/WS 路径与稳定错误。
  • PostgreSQL:复合索引上的 direct-only keyset 分页跨页不丢不重,cursor 与 anchor 独立,profile 两次受限读取与内存组合、asOf 和真实 migration 后读取仍按账号/会话隔离;历史业务卡仅能存 marker,旧行清理后数据库 CHECK 拒绝附带资料字段。

7. Wrong vs Correct

Wrong

const cursor = conversation.latestMessageId;
return mindLegacyMessages(conversationId, cursor);

Correct

const cursor = decodeOneTalkHistoryReadCursor(request.query.cursor);
const page = await readService.readHistory({ scope, conversationId, cursor, limit });
return reply.send(page);

读取边界只消费 Bright 事实表和独立 keyset cursor;同步 anchor 仍只表达插件同步状态。

// Wrong: 把消息观察时拿到的登录人/发送者 contact 当成会话客户。
const content = normalizeBusinessCard(item, item.contact);
await messageRepository.insertMessage(context, source, { ...message, content });

// Correct: message 事实只存 marker;读取时按受限会话 profile 组合 view。
const stored = { version: 1, kind: "business_card" } as const;
const conversation = await conversationRepository.read(scope, conversationId);
const profile = await contactProfileRepository.read(scope.channelAccountId, conversation.id);
return projectCenterMessage({ ...message, content: stored }, profile ?? undefined);

Scenario: OneTalk 渲染卡补全的 Mind 公共投影

1. Scope / Trigger

  • Trigger:插件已经独立持久化 rendered.card.observed,而 Bright 要经 HTTP history、message.createdmessage.updated 把对应展示信息提供给 Mind。
  • Scopepackages/onetalk-contract/src/content.ts 的 Center-only content union 与 apps/server/src/onetalk/read-projection.ts 的唯一投影;读取、live sync、send confirmation 和 rendered-card flow 都是该投影的消费者。
  • Excluded:插件 wire、ACK、onetalk_rendered_card_content schema、补全去重/冲突规则、Trade-Mind 解码或展示;它们不得把内部 rendered_* type 当作 public content。

2. Signatures

toOneTalkCenterMessage(
    message: OneTalkMessage,
    renderedCardContent?: OneTalkRenderedCardContent,
    customerProfile?: OneTalkReadCustomerProfile,
): OneTalkCenterMessage;

OneTalkCenterMessageContent 的 legacy inquiryproductorder 分支可保留已批准的历史展示字段;新的 type-agnostic named supplement 通过 OneTalkCenterMessage.renderedCard?: OneTalkRenderedCard 公开。OneTalkMessageContentOneTalkRenderedCardContent 仍是彼此独立的持久化/输入契约。

3. Contracts

  • 输出的判别字段始终是基础 inquiry | product | order,绝不输出 rendered_inquiry | rendered_product | rendered_order。无补全时,基础内容保持 exact shape,不用 null 或空对象伪造展示字段。
  • rendered_inquiry 只能补充 productpurchaseQuantityrequirementTextinquiryReferenceactionsrendered_product 只能补充 titleimageUrlpriceDisplayminimumOrderserviceBadgesrendered_order 只能补充 titleproductsproductCountstatuspaymentdeliveryaction
  • product 的 sourceUrlproductId 永远来自基础消息,且必须与补全中的身份一致。补全 kind 或 product 身份不一致、补全形状非法时显式失败,不能丢弃补全、猜测类型或发布部分内容。
  • projectCenterMessage、rendered-card update、live/history sync 和 send-confirmation 都只把基础 message 与 optional supplement 传给 toOneTalkCenterMessage;不得在调用点以 renderedCardContent ?? message.content 替换内容。
  • 补全先到时,后续单条或批量 accepted 基础消息都必须按同一 channelAccountId + conversationId + messageId 受限读取已有 supplement,并在 message.created 投影前带回。提交、guard、ACK 和 publish 顺序保持不变。
  • 新写入的 renderedCard 是 type-agnostic exact shape { version: 1, ...namedFields },没有 kindactions、通用 fields、裸 texts/imageUrls 或 DOM/template metadata;商品 MOQ 只用 { value, unit }。它只能作为顶层 renderedCard 附加,绝不替换或补写基础 content,也不要求基础消息先存在。legacy typed supplement 仅为既有事实的读取兼容保留。

4. Validation & Error Matrix

Condition Result
无 supplement 公开基础 content exact shape,不新增可选字段
supplement 与基础 kind 不一致 投影失败,不发布 HTTP/WS 伪内容
rendered product 的 URL 或 product ID 不一致 投影失败,不以补全覆盖基础身份
unknown key、错误嵌套或 ledger 字段进入 Center content strict guard 拒绝
supplement 先到、base message 后由 batch live accepted repository 带回 supplementmessage.created 使用基础 kind 加批准字段
generic supplement 先到、base message 后到 repository 带回同一复合 identity 的 snapshotmessage.created.content 不变,仅有顶层 renderedCard
duplicate/conflict supplement 或补全写失败 保持既有 ACK/冲突语义;不改写基础消息事实

5. Good / Base / Bad Cases

  • GoodHTTP、message.createdmessage.updated 都调用同一个投影;generic snapshot 只作为顶层 renderedCard,基础 content.kind 不变。
  • Base:基础 message 先到且没有 supplement 时,后续读取保留原始 shape;补全到达后只发一次同一基础 kind 的 message.updated
  • Badrepository 直接构造 Center message、每个 WebSocket flow 各自 merge 字段,或 batch accepted 分支遗漏既有 supplement;这些都会导致 HTTP/live 形状分叉。

6. Tests Required

  • Contractgeneric renderedCard 的 exact shape、unknown key/错误嵌套拒绝,以及 legacy supplement kind/product identity 不一致拒绝。
  • Read/WebSocketHTTP 与 message.created/message.updated 对同一事实完全一致;覆盖 generic supplement 后到、先到后单条 live、先到后 batch live,断言基础 content 不变而仅顶层 snapshot 出现。
  • Repositorybatch accepted 以完整复合键读取已有 supplement,并保留 guard、transaction、ACK 与 publish 的既有顺序;测试不以 SQL JOIN 替代受限读取。

7. Wrong vs Correct

// Wrong: internal supplement replaces the public discriminated content.
toOneTalkCenterMessage({ ...message, content: renderedCardContent ?? message.content });

// Correct: the sole projection validates two separate facts and adds generic DOM data beside content.
toOneTalkCenterMessage(message, renderedCardContent);

Scenario: OneTalk WS 固定协议内核与静态已认证路由

1. Scope / Trigger

  • Trigger:新增、删除或重组 OneTalk server 的 inbound WebSocket frame,或调整 Plugin 与 Mind endpoint 的职责边界。
  • Scopewebsocket/protocol-kernel.tswebsocket/handler.tswebsocket/endpoint-routes.tswebsocket/plugin/index.tswebsocket/plugin/flows/{sync-flows,profile-flow,observation-batcher,send-confirmation-flow}.tswebsocket/mind/{index,send-request-flow,session-authorization,publisher}.tswebsocket/authenticated-router.ts;不改变 shared contract、registry/store、pending-send 或领域事实的状态所有权。
  • Excludedwire version/error-code 变更、动态模块注册、extension frame routing、send/profile/buyer-fact 的业务顺序调整。唯一既定的 output 收紧是 confirmed send.result.message 投影为既有 OneTalkCenterMessage,不允许以修改 shared decoder 接受 raw message 字段代替。

2. Signatures

createOneTalkProtocolKernel<TAuthenticatedFrame extends OneTalkFrame>({
    session,
    canAdmit,
    expectedConnectionType,
    isAuthenticatedClientFrame,
    onHello,
    onAuthenticatedFrame,
    // exact error/close/diagnostic callbacks
}): OneTalkProtocolKernel;

createOneTalkAuthenticatedRouter(routes): OneTalkAuthenticatedRouter<TContext>;
// routes: type + connectionType + authorization + operation + handler

createOneTalkWebSocketHandler(
    { expectedConnectionType, authorization, registry, ...sharedPorts },
    { create: endpointCallbacks },
): (socket: WebSocket) => void;
createOneTalkPluginWebSocketHandler(pluginOptions): (socket: WebSocket) => void;
createOneTalkMindWebSocketHandler(mindOptions): (socket: WebSocket) => void;
createOneTalkEndpointAuthenticatedRouter(endpoint, { plugin, mind_page }): OneTalkAuthenticatedRouter<T>;
assertOneTalkEndpointFrameSets({ plugin, mind_page }): void;

createOneTalkSendRequestFlow({ registry, sendFrame, ...narrowPorts }): OneTalkSendRequestFlow;
createOneTalkSendConfirmationFlow({ registry, service, ...narrowPorts }): OneTalkSendConfirmationFlow;
createOneTalkProfileFlow({ profileService, registry, reauthorize, ...narrowPorts }): OneTalkProfileFlow;

TAuthenticatedFrame 必须由 type predicate 从完整 decoded OneTalkFrame 收窄;handler 不得在 callback 边界用类型断言伪造已认证 client frame。

3. Contracts

  • handler.ts 是每 socket 唯一的共享 transport runtimeFIFO、字节限制、JSON parse、strict decode、cutover admission、固定 endpoint connection type、inbound/outbound diagnostics、socket close/unregister 与共享 session reauthorization primitive。它注入 endpoint callback,不能再按 connectionType 选择 Plugin/Mind hello 或业务 Flow,也不能建立 shadow authentication/session state。
  • plugin/index.ts 只拥有 Plugin hello/binding authorization、heartbeat、discovery/sync/observation/profile/buyer facts 与 send.confirmationmind/index.ts 只拥有 Mind Cookie/session hello、heartbeat、send.request 和 Mind subscriber lifecycle。index.ts 仍是唯一 Fastify WebSocket 安装点,只按 URL 组合这两个 fixed handler。
  • 未认证连接只允许 ws.hellohello 后再次收到 ws.hellounknown_requestws.hello 永远不进入 authenticated routerendpoint bootstrap 保留 accepted -> register -> anchor snapshot 顺序。
  • 已认证 frame 先检查 session connection type 与 scope,再分类是否为 client route。因此 decoder 接受但不是 client frame 的错 scope 输入必须是 scope_mismatch 并关闭 1008;同 scope 输入才是 unknown_request,且不关闭连接。
  • authenticated route set 从 ONETALK_CLIENT_FRAME_TYPES 排除 ws.hello 推导。assertOneTalkEndpointFrameSets 要求 Plugin/Mind 两端都恰有一个 heartbeat,其余 frame 必须恰有一个 endpoint owner;它在 Fastify route 注册前拒绝缺失、重复、未知和 hello route。endpoint router 在调用 collaborator 前同时校验 frame 的 connection type 和该 endpoint 的 ownership;禁止模块副作用、运行时自动发现、fallback route 或直接 dispatch 绕过 endpoint view。
  • 每个 route metadata 显式声明 connectionTypeauthorization: "session" | "pending_send" 和 operation。普通 session route 仍经过现有 binding/scope/version/permission/cutover guardsend.request / send.confirmationpending_send 策略只委托既有 registry coordinator,不能复制或绕过其授权窗口、pending/terminal state。
  • endpoint reauthorization 共用同一 binding/scope/version/canonical permission primitive,但 endpoint 自己选择 operation。授权 reader 明确返回 allowed: false 时,Plugin/Mind 都必须先发送稳定 ws.error,再以 1008 关闭;reader 已允许、但现有连接或返回 permission 缺失时,仍只发送 authorization_rejected,保持原有 error-only 行为。Cookie 只在 Mind endpoint 的单次 session authorization 内存中转发,不能进入 wire 或 diagnostics。
  • kernel/router/handler 只做固定 guard、选择和委托。connection/generation、pending-send、commit guard 与 checkpoint 保持原有唯一 owner;每条 plugin connection 的 sync Flow 独占 discovery fragment timer/Set 和 ObservationBatcher。Mind request Flow 只编排 send.request -> registry.requestSend -> send.resultPlugin confirmation Flow 才拥有 confirmation observation/publish。profile Flow 只编排 guarded ingestion、post-write fences、conversation update 与 ACK;这些 Flow 都不能建立第二份连接、pending、timer 或 durable state。
  • send Flow 在 request 前后保留 policy fenceconfirmation 只能使用 coordinator 已同步 claim 的 pending snapshot 与 guard。accepted 的顺序固定为 durable observation -> message.created -> conversation update -> terminal send.resultmatching duplicate 不重发 message.created。唯一 server output seam 必须用 toOneTalkCenterMessage 把 accepted 与 duplicate 的 send.result.message 投影为 strict center fields。
  • profile Flow 只接收 typed contact.profile.observed、canonical source context、store-created guard 与 handler 注入的 reauthorization/close/writer ports。future-skew 必须在 second authorization 前以 profile_observed_at_future 结束;guarded write 成功后仍须依次确认 policy、guard、canonical connection、reauthorization,再对 writtenConversationIds 发布 update,最后用 decoded input 的原始 profile count ACK。
  • observation Flow 保持 database commit -> plugin ACK -> Mind publish;仅 live + accepted 发布 message.createdconversation.updatedduplicate/anomaly/rejected/history/incremental 不发布。completion 必须先等待同一 Flow 的 observation flush,再调用 completeSync、发布 sync.status 与 completion conversation.updated

4. Validation & Error Matrix

Condition Result
payload 超限、非 text 或 strict decode 失败 既有稳定 error 后关闭 1003;不执行 admission 或业务 handler
cutover 不准入 关闭 1013/authorization_unavailable;不发业务 frame
endpoint connection type 不匹配 scope_mismatch 后关闭 1008
未认证的非 hello frame auth_required 后关闭 1008
已认证后再次 hello unknown_request;保持既有连接行为
已认证且 scope/connection type 不匹配(包括 non-client frame scope_mismatch 后关闭 1008,优先于 client classification
已认证、scope 匹配但 frame 非 client unknown_request;不调用 route handler
route 表缺失/重复/未知/hello entry factory 构造失败;服务不能以不完整 registry 启动
route metadata connection type 不匹配 authorization_rejected;不调用业务 collaborator
endpoint router 收到非本端 frame 或其 route 无本端 ownership 拒绝且不调用 route collaborator
endpoint route set 遗漏任一端 heartbeat、业务 frame 双归属或无归属 Fastify route 注册失败
reauthorization reader 返回 allowed: false 稳定 ws.error 后关闭当前 socket 1008
reader 已允许但连接/decision 缺必要 permission authorization_rejected,保持既有 error-only 行为
confirmed send.result 含完整 OneTalkMessage 的 raw-only 字段 shared decoder 拒绝;Flow 必须投影为 OneTalkCenterMessage,不得放宽 contract
profile write 后 policy/guard/canonical/reauthorization 任一失效 不 publish 新 update,且无 late contact.profile.ack

5. Good / Base / Bad Cases

  • Goodshared handler 只组合 transport callback 与窄错误 callbackPlugin/Mind endpoint 分别拥有 hello 和业务 dispatchkernel 拥有固定分派顺序,静态 endpoint 表完整列出当前 authenticated v5 framesend/profile Flow 只接收已收窄 frame 与窄端口。
  • Base:新增 client frame 时,contract list 变化使 typed route table 与 runtime validation 都要求在同一改动中添加 metadata/handler/test;这不是协议兼容层。
  • Bad:在 shared handler 中以 connectionType 分派业务 Flow、在 Flow handler 中 parse raw WebSocket data、先判 unknown 再判 session scope、把 hello 注册为普通 route、让 endpoint router dispatch 绕过 ownership、为 send 再实现 authorization/pending map,或将完整 OneTalkMessage 直接写入 send.result

6. Tests Required

  • router unit test 从 contract list 推导所有非 hello client type;覆盖完整 endpoint 表、两端 heartbeat、missing、duplicate、unknown、hello、单 owner 与 metadata/endpoint connection mismatch,且 mismatch 不调用 collaborator。
  • WebSocket regression 必须验证 FIFO/strict decode、unauthenticated gate、hello/repeated-hello、endpoint/cutover,以及同 scope non-client 为 unknown_request、错 scope non-client 为 scope_mismatch + 1008 的优先级。
  • WebSocket regression 必须分别覆盖 Plugin/Mind 的 operation authorization denialws.error1008 close)及 allowed-but-missing-readerror-only、不关闭);Mind Cookie 只作为 authorization adapter 输入,不能出现在 diagnostics 或 wire。
  • 保留真实 wire matrix 与 sync/observation/send/profile/buyer focused tests,验证所有 client frame 的可见输出、commit -> ACK -> publish、pending-send terminal 和 post-write fenceaccepted 与 matching-duplicate send.result 都必须可被 decodeOneTalkFrame round-trip 接受,并断言 duplicate 不发布;sync completion 用 deferred observation latch 验证 flush 未完成前不得调用 completeSync
  • 运行 server typecheck/build、focused source 与 compiled tests、Oxfmt 和 git diff --check;若遗留 generated test 无法导入未导出的旧 contract symbol,单独记录为非本路由 diff 的基础设施问题,不修改生成产物掩盖它。

7. Wrong vs Correct

// Wrong: non-client 分类可能让跨会话 frame 绕过 scope guard。
if (!isAuthenticatedClientFrame(frame)) return sendUnknown(frame);
if (!isSameOneTalkScope(session.scope, frame.scope)) return closeScopeMismatch(frame);
// Correct: 在路由分类之前,decoder 合法的 frame 必须先确认属于当前会话。
if (!isSameOneTalkScope(session.scope, frame.scope)) return closeScopeMismatch(frame);
if (!isAuthenticatedClientFrame(frame)) return sendUnknown(frame);
return router.dispatch(context, frame);
// Wrong: 共享 transport 决定 endpoint 的业务行为。
if (state.connectionType === "plugin") return pluginSyncFlow.handle(frame);
return mindSendFlow.handle(frame);

// Correct: 每个固定 endpoint 只注入自己的 hello 和已认证回调。
return createOneTalkWebSocketHandler(
    { ...sharedOptions, expectedConnectionType: "plugin" },
    { create: createPluginEndpointCallbacks },
);