Skip to content
bitzorcas
中EN

Reference

AIManage 对话、消息与安全边界

逐步解释对话创建、用户列表、历史读取、消息发送时序,以及已强制执行的所有权与幂等、短事务持久化、隐私和一致性边界。

Last updated

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。

"AIConversationStore""GetHistoryHandler""AIConversationStore""GetHistoryHandler"当前只按 Id 查询"当前用户"ConversationIdGetConversationAsync(id)ConversationInfo(TenantId, UserId)只比较 UserIdGetMessagesAsync(id)按找到的 TenantId 查询消息"当前用户"

防御应下沉到第一条查询: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:

"当前调用方赢得键""provider 成功""provider 失败""provider 调用进行中""其他调用方占有键\n→RequestInProgress""重放 assistant,不调Provider""→ PreviouslyFailed(用新RequestId)""同键但内容/模型不同"

Claimed

AssistantPersisted

Failed

Pending

Completed

Conflict

正文校验只有非空和最多 32,000 字符。SendMessage.Command 实现 INonTransactionalCommand,协调器在 Provider 网络调用前后用有界短事务提交用户消息 + claim(及随后的 assistant 消息),而非在整个推理期间保持一个长事务。

幂等轮次合同 —— RequestId 必填
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
重复 RequestIdRequestInProgress/Conflict同一轮次,不重复计费
Provider 超时User 已保存明确 Failed attempt,可安全恢复
Prompt 含 Secret无防护阻断或策略化脱敏
输出违规内容无防护审核、标记与审计

10. 修复顺序

先完成实例级租户/所有者 Store 契约和 HTTP 安全回归,再引入幂等轮次/attempt 状态,随后收敛模型策略、数据分类和内容安全。不要先增加更多 Provider 或 UI,因为它们会扩大现有越权和重复计费的影响面。

11. 变更审查问题

新增对话能力时必须回答:第一条数据库查询是否已经绑定当前租户与用户;外发内容来自哪些字段和资源;Provider 失败后数据库处于什么可恢复状态;客户端重试使用什么稳定键;历史窗口是否会包含失败或被撤销内容;错误响应是否泄漏其他租户对象或供应商细节。任何答案依赖“调用方应该不会这样传”都不是安全边界。

Terminal window
# 当前 ID-only 入口应在修复后只剩明确的内部、安全范围用途。
rg -n "GetConversationAsync\(|c => c.Id == conversationId" \
src/Platform/AIManage -g '*.cs'

AIManage 总览 · 工作区与 Provider · 流式与用量

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%