mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
20 KiB
20 KiB
服务端基础设施契约
1. Scope / Trigger
- Trigger:服务端首次引入 Fastify 启动、WebSocket 传输和 PostgreSQL ORM。
- Scope:
apps/server/src/的配置、应用组合、健康路由、WebSocket 生命周期和数据库连接。 - Excluded:OneTalk 业务字段规则和 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
- Required environment keys:
HOST,PORT,DATABASE_URL。 - Optional
NODE_ENVis normalized at the configuration boundary; only exact trimmeddevelopmentenables the development-only loopback Mind HTTP configuration, while missing/unknown values remain fail-closed。 GET /healthreturns{ "status": "ok" }。- WebSocket routes are
GET /ws/pluginandGET /ws/mind; each route binds its connection type before upgrade and checks its exact Origin allowlist.GET /wsis reject-only compatibility behavior (426 onetalk_protocol_upgrade_required) and has no legacy handler, outbox or dispatch path. Malformed or unsupported protocol frames close with code1003, and connection/handler errors close with1011。 createAppnever callslisten。entry.ts是唯一进程入口,它调用runtime.ts的双 listener lifecycle;runtime.ts只绑定 publicHOST:PORT与 internal0.0.0.0:7777,并在任一 bind 失败时关闭两者。业务/HTTP 模块不得直接监听端口。- Database resources are closed through the app
onClosehook; URL and credentials never enter responses or logs。 createAppcomposes one injected/defaultOneTalkService,OneTalkProfileServiceandOneTalkReadServiceover the samedatabase.db;AppDependenciesmay inject these domain ports, the connection registry and publisher failure sink for tests or deployment adapters。- WebSocket business frames cross through the service boundary; successful observation order is database commit → plugin
message.ack→ authorized Mindmessage.created。message.created与 HTTP history 都必须从同一 normalized JSONB fact 投影 sharedOneTalkCenterMessage,不得暴露顶层text/contentType或 raw payload。 contact.profile.observedis a Bright persistence path: canonical binding/read/sync authorization → profile service → guarded Bright transaction → post-write fence →contact.profile.ack; it never calls Mind profile HTTP or the message service.AppDependencies.profileServiceis the test/deployment seam.
4. Validation & Error Matrix
| Condition | Result |
|---|---|
Missing/blank HOST |
throw Missing HOST |
Missing/blank PORT |
throw Missing PORT |
PORT outside 1..65535 or non-integer |
throw Invalid PORT: expected integer 1-65535 |
Missing/blank DATABASE_URL |
throw Missing DATABASE_URL |
| Malformed or unsupported WebSocket frame received | send the stable protocol error, then close current socket with 1003 |
| WebSocket error received | close current socket with 1011 |
| Profile future-skew or guarded persistence rejected | stable profile_observed_at_future/database failure; no profile ACK |
| Profile transaction succeeds but authorization/connection/policy fence is stale | no late ACK; plugin pending remains |
5. Good / Base / Bad Cases
- Good: tests inject
{ db, close }, callapp.ready(), verify/health, then observe one close call。 - Base: production startup creates a Drizzle client from
DATABASE_URLand listens on configured host/port。 - Bad: route handler reads
process.env, creates a second postgres client, or returns the connection URL in an error。
6. Tests Required
- Configuration test asserts missing URL and invalid port errors contain field/error class but not secret values。
- Health test asserts HTTP
200and exact{ status: "ok" }response。 - Lifecycle test asserts injected database
closeruns once onapp.close()。 - WebSocket registration test asserts
websocketServerexists,GET /ws/plugin/GET /ws/mindare registered, andGET /wsis reject-only; OneTalk protocol tests assert mock-authorized handshake, heartbeat, version rejection, and authorization failures。 - OneTalk business tests assert raw observation validation, conversation discovery, sync completion, anchor snapshot, plugin-only writes and publish-after-commit behavior。
7. Wrong vs Correct
Wrong
app.get("/health", async () => ({ databaseUrl: process.env.DATABASE_URL }));
Correct
app.get("/health", async () => ({ status: "ok" }));
The health boundary is stable and secret-free; database probing belongs in a later operational contract。
Scenario: Bright v3 authorization and commit fences
1. Scope / Trigger
- Trigger:Mind 授权、Bright WebSocket 生命周期和 OneTalk 事实写入共享多个异步边界,需要避免 pause、连接替换或迟到回报穿透副作用。
- Scope:
cutover-policy.ts、websocket/registry.ts、websocket/handler.ts、onetalk/service.ts和 guarded repository ports。 - Excluded:Mind 的远端 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
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控制 admission;pause/resume 可以恢复 Bright v3,不依赖或保存firstBrightFactWritten/markBrightFactWritten。 sendRequestId的 reserve、phase、terminal transition、timeout、disconnect、pause 和 late confirmation 由 registry 单一 owner 管理;所有 wire send 后结果不可自动重试。- 远程 observation/discovery/sync/confirmation 的 database side effect 必须带同一 canonical connection/generation/policy guard。guard 失效必须使事务回滚,不返回伪造的 accepted/duplicate。
4. Validation & Error Matrix
| 条件 | 结果 |
|---|---|
| 相同 ID 的并发 send 在授权等待期间 | 一个 attempt,另一个 rejected_before_send/duplicate_request |
| 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;后续输入 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。 - 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 后 publish,terminal/guard 失败不写库、不 ACK、不 publish;独立 PostgreSQL 测试配置TEST_DATABASE_URL后再验证真实 rollback/commit 线性化。
7. Wrong vs Correct
Wrong
await authorizeMind();
await repository.insertMessage(context, source, message);
Correct
const epoch = policy.capture();
const guard = registry.createCommitGuard(canonicalConnection, epoch);
await repository.guardedInsertMessage(context, source, message, guard);
guard.assertValid();
Scenario: 环境驱动的 Mind HTTP 授权
1. Scope / Trigger
- Trigger:服务端需要通过独立的本地 Mind HTTP mock 运行 OneTalk HTTP/WS 联调,同时保留非开发环境的安全默认。
- Scope:
apps/server/src/config.ts、apps/server/src/mind-authorization.ts、apps/server/src/app.ts、serverdevscript 和apps/mind-http-mock/。 - Excluded:真实 Mind Session/Cookie 认证、业务授权后台、数据库权限和请求级认证逻辑不由本地 mock 代替。
2. Signatures
loadConfig(environment: Record<string, string | undefined>): ServerConfig
// ServerConfig.environment: "development" | "non_development"
createApp(config: ServerConfig, dependencies?: AppDependencies): FastifyInstance
3. Contracts
loadConfigtrimNODE_ENV;只有值严格为development时返回environment: "development",空值、缺失、大小写变体和其它值返回non_development。loadConfig在NODE_ENV=development时读取MIND_AUTH_BASE_URL、MIND_PAGE_ORIGIN、ONETALK_PLUGIN_ORIGINS和可选MIND_AUTH_TIMEOUT_MS;其中 HTTP 只允许 loopback Origin,供独立mind-http-mock使用。createApp在未显式提供dependencies.authorization时使用配置的createMindAuthorizationReader;显式 reader 在所有环境优先。development 不再从ONETALK_DEV_*构造进程内授权 fixture。- Mind mock 只拥有独立的本地固定 fixture,Bright 仍通过两个 HTTP endpoint 验证 binding/Cookie/status/body;mock 不读取 Mind DB、不推断请求主体,也不改变生产授权边界。
apps/server/package.json的devscript 明确设置NODE_ENV=development;其它启动路径不设置 bypass。installWebsocket未传 authorization 时仍使用 fail-closed reader,不能因 server dev 默认而改变独立安装语义。
4. Validation & Error Matrix
| Condition | Result |
|---|---|
NODE_ENV=development 且 Mind HTTP mock 配置完整 |
createApp 通过 Mind HTTP adapter 授权;mock 返回允许时匹配 scope 的 HTTP/WS 请求可进入已有业务边界 |
NODE_ENV=development 且 MIND_AUTH_* 配置缺失/非法 |
启动失败,不创建任意 scope 的默认授权 |
NODE_ENV=development 但 Mind HTTP mock 不可用 |
授权返回 authorization_unavailable,不绕过网络边界 |
| 缺失、空值、未知值或非精确 development | createApp 默认 authorization_unavailable,fail-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 通过同一个
createMindAuthorizationReader或AppDependencies.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 mock 不可用、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
- Trigger:Bright 事实消息已经由 OneTalk repository 持久化,需要供 Mind 页面读取历史,并通过同一服务接收授权后的实时状态/消息事件。
- Scope:
apps/server/src/http/onetalk.ts、apps/server/src/http/harness.ts、apps/server/src/onetalk/history.ts和apps/server/src/websocket/registry.ts的跨层契约。 - Excluded:Mind 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
GET /harness -> text/html (native browser page)
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。列表/详情返回共享CenterConversation:name/avatarUrl为当前 profile row 的实时值,participantIds固定为空数组,unreadCount固定为 0;latestMessageId来自已确认业务锚点,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。内部7777summary listener 必须同时提供两端时间。 - HTTP history 与
message.created都只返回 sharedOneTalkCenterMessage:语义readStatus加同一content.version=1的text | image | fileunion。read projection 不得解 Base64、custom.data、contentType、文件名或 URL fallback。 /harness只通过同源 Bright HTTP/WS 访问数据;浏览器对 list/detail/history、scope、page、语义消息和稳定错误做运行时形状校验,展示原始 ID,按 scope/account/conversation/messageId 去重。它按content.kind转义渲染文本、以真实<img>加载图片预览并显示加载失败、为文件保留元数据且只在 URL 存在时提供带noopener noreferrer的用户触发链接;不解 raw 字段、不自动下载、不增加媒体发送。- 插件
plugin.status、sync.status和message.created只发送给当前仍通过二次 read 授权的精确 Mind scope;消息必须遵循数据库提交 → plugin ACK → Mind publish。public HTTP CORS 只允许精确 Origin 和Content-Type;internal summary listener 不注册 CORS。
4. Validation & Error Matrix
| Condition | Result |
|---|---|
| Cookie/Session 授权上下文缺失或无效 | HTTP 401 auth_required 或授权适配器稳定拒绝 |
| 路径账号与授权返回 scope 不一致,或请求带未允许身份 header | HTTP 403 scope_mismatch |
| 授权拒绝/撤销/版本变化 | HTTP 403 与对应稳定授权错误码 |
| 授权依赖不可用 | HTTP 503,authorization_unavailable |
limit 非整数或不在 1..100 |
HTTP 400,invalid_limit |
| cursor 无法解码或跨账号/会话使用 | HTTP 400,invalid_cursor |
| internal summary 缺少 from/to,或 from >= to | HTTP 400,invalid_time_range |
| 会话不存在 | HTTP 404,conversation_not_found |
| history 尚未完整且请求 internal summary | HTTP 503 history_incomplete,Retry-After: 30 |
| 数据库读取失败 | HTTP 503,database_unavailable;不返回内部异常 |
| 插件无在线连接 | 仍可读历史;响应/事件状态为 offline,实时发送能力保持禁用 |
5. Good / Base / Bad Cases
- Good:页面先读取 Bright direct list/detail/history,保存并回传 opaque cursor,再连接 WS;断线刷新依靠历史恢复,收到重复
message.created不重复渲染。 - Base:插件状态来自当前进程 registry;页面/接口显示
offline不代表历史不可读,当前 profile 变化可以实时反映到列表名称和头像。 - Bad:HTTP 路由访问 Mind legacy 表或代理插件接口,使用 anchor/latestMessageId 作为 cursor,放宽 direct filter,或把未提交消息先推给页面。
6. Tests Required
- HTTP:public 列表、详情、历史首/后续页、internal summary gate、direct filter、profile 实时内存组合、query、独立 cursor/asOf、半开窗口、scope/CORS 校验、授权失败、未知会话、offline 状态、非法 limit/cursor/time range、数据库失败和无秘密响应;text/image/file 必须只含 normalized content。
- WebSocket:Mind hello/accepted、plugin online/offline、sync status、精确 scope、二次授权、提交后 ACK/publish 顺序、history/live 对同一事实的公开投影等价,以及断线后的连接清理。
- Harness:
GET /harness的 HTML 标记、Bright public HTTP/WS 路径、offline send gate、list/history query paging、异步代际 fence、active-scope guard、runtime shape validation、文本转义、image load error、conditional file links 和去重关键字段。 - PostgreSQL:复合索引上的 direct-only keyset 分页跨页不丢不重,cursor 与 anchor 独立,profile 两次受限读取与内存组合、asOf 和真实 migration 后读取仍按账号/会话隔离。
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 仍只表达插件同步状态。