AI 对话不是普通聊天记录:它同时包含租户、用户、工作区、系统提示词和会发送给外部 Provider 的正文。正确实现必须把身份边界、外发数据边界和消息状态机放在同一条链路审查。
1. 对话创建
var created = await mediator.Send(new ManageConversation.CreateCommand( WorkspaceId: "ws-001", Title: "合同风险复核", SystemPrompt: "只识别风险,不生成最终法律意见。"), cancellationToken);
// 返回摘要包含最终 Id、WorkspaceId、Status、MessageCount 和 CreateTime。return created.Value;Handler 从当前用户读取 TenantId,UserId 为空时退化为字符串 "0"。只有 SystemPrompt 为 null 时才查询工作区以继承 Prompt;即使工作区不存在或已停用,也会创建对话。HTTP 组要求认证,降低了 UserId=0 的入口概率,但应用用例本身没有使用 RequireUserId,内部调用仍可能创建错误归属。
GA 应先验证当前用户、工作区租户归属和激活状态,再创建对话;WorkspaceId 不能只是一段未验证字符串。
2. 列表与历史读取不是同一安全强度
列表查询使用 TenantId + UserId,按 CreateTime、Id 倒序 Offset 分页,因此能够隔离当前用户。历史读取先只按 ConversationId 获取对话,再比较 conversation.UserId;它没有在 Store 谓词中绑定 TenantId,也使用 UserId=0 fallback。
防御应下沉到第一条查询:GetOwnedConversationAsync(currentTenantId, currentUserId, id)。这样跨租户 ID 不会先恢复出其他租户对象,也不会依赖 UserId 是否全局唯一。
3. Send 与 Stream 强制执行所有者校验
两个请求都实现 IAuthorizedRequest(ai/conversation · Use),Handler 注入 ICurrentUser。读取对话后,Handler 拒绝任何 TenantId 或 UserId 不匹配调用方的请求,在任何 Provider 调用或消息写入前返回 AI.Conversation.NotOwner(Forbidden)。授权管线在 Handler 运行前求值,所有权守卫在 Provider 调用前执行。
4. 幂等轮次状态机
SendMessage.Command 与 StreamMessage.Command 都要求客户端提供 RequestId。IAIMessageRequestCoordinator.ClaimAsync 通过数据库唯一约束原子占用该键,返回驱动响应的 AIMessageRequestState:
正文校验只有非空和最多 32,000 字符。SendMessage.Command 实现 INonTransactionalCommand,协调器在 Provider 网络调用前后用有界短事务提交用户消息 + claim(及随后的 assistant 消息),而非在整个推理期间保持一个长事务。
var command = new SendMessage.Command( ConversationId: conversationId, RequestId: "web-019f-turn-0042", // 重试间保持不变 Content: userText, ModelId: requestedModel);
// 唯一键至少包含 TenantId、ConversationId、RequestId。Completed 轮次重放已持久化的// assistant 消息而不调 Provider;Failed 轮次须用新 RequestId;重复 in-flight 返回// RequestInProgress。5. 历史构造规则
Store 返回完整历史并按 CreateTime 排序,ChatMessageBuilder.Build 再保留末尾最多 50 条,同时跳过最后一条当前用户消息,并把 Command Content 作为新的 User 消息加入。系统提示词位于首条。
“50 条”不是 Token 窗口:50 条长消息仍可能超过模型上下文,50 条短消息又可能浪费容量。没有摘要、token 估算、附件/RAG 上下文预算、角色合法性过滤或被拒内容标记。Provider 失败留下的 User 消息也会进入后续历史。
6. 消息持久化与计数
AIConversationMessageWriter 把每条消息在独立短事务中持久化(BeginAsync → AppendMessageAsync → CommitAsync),回滚时使用不可取消的 CancellationToken.None,若回滚也失败则保留原始写入异常。Writer 不携带领域事件或 Outbox;若消息日志将来要发事件,必须迁移到完整的事务-事件管线。用户消息作为协调器 claim 事务的一部分持久化,因此 RequestId 唯一约束保护它。
7. 数据分类与外发
当前 Content 和 SystemPrompt 原样进入历史并发送到 Provider。模块没有:
- PII/Secret 分类和脱敏;
- 租户允许的 Provider/区域/模型策略;
- 附件与 RAG 来源授权;
- Prompt Injection 检测与不可信上下文分隔;
- 输入/输出内容审核;
- Provider retention/训练开关证据;
- 对消息正文的加密、保留、导出、法律留存和删除流程。
日志已避免直接写正文和 ApiKey,但数据库、Tracing、异常响应、供应商日志和调试转储仍需要统一数据治理。
8. 错误语义
非流式使用类型化 Result<MessageDto>,失败携带稳定 Error 码。流式使用类型化 NDJSON 帧协议:失败表现为恰好一个终态 {"kind":"error",...} 帧,其 detail 仅来自本地化 i18n key(L.GetString(error.Code)),绝不来自原始异常文本。响应开始后的未处理异常映射到稳定码 AI.Conversation.StreamFailed。幂等冲突表现为 AI.Message.RequestInProgress / RequestConflict / PreviouslyFailed(均为 Conflict)。所有权违规表现为 AI.Conversation.NotOwner(Forbidden)。
9. 安全测试矩阵
| 场景 | 当前证据 | GA 预期 |
|---|---|---|
| 匿名请求 | 路由组认证 | 401/统一错误 |
| 同租户他人 ConversationId | 未覆盖 | NotFound/Forbidden,无 Provider 调用 |
| 跨租户相同 UserId | 未覆盖 | Store 谓词拒绝 |
| 失效工作区创建对话 | 当前可创建 | Validation/NotFound |
| 重复 RequestId | RequestInProgress/Conflict | 同一轮次,不重复计费 |
| Provider 超时 | User 已保存 | 明确 Failed attempt,可安全恢复 |
| Prompt 含 Secret | 无防护 | 阻断或策略化脱敏 |
| 输出违规内容 | 无防护 | 审核、标记与审计 |
10. 修复顺序
先完成实例级租户/所有者 Store 契约和 HTTP 安全回归,再引入幂等轮次/attempt 状态,随后收敛模型策略、数据分类和内容安全。不要先增加更多 Provider 或 UI,因为它们会扩大现有越权和重复计费的影响面。
11. 变更审查问题
新增对话能力时必须回答:第一条数据库查询是否已经绑定当前租户与用户;外发内容来自哪些字段和资源;Provider 失败后数据库处于什么可恢复状态;客户端重试使用什么稳定键;历史窗口是否会包含失败或被撤销内容;错误响应是否泄漏其他租户对象或供应商细节。任何答案依赖“调用方应该不会这样传”都不是安全边界。
# 当前 ID-only 入口应在修复后只剩明确的内部、安全范围用途。rg -n "GetConversationAsync\(|c => c.Id == conversationId" \ src/Platform/AIManage -g '*.cs'