- ① documents[] 元素增可选 reason(omitempty):装配期在给出 status 的**同一处**填充,取的**就是注入模型清单里那份原因文案**(同一字符串两处用,服务端不为客户端另编一句),只在 unreadable 时下发;随 S3.5 的回合后回写落进 document 列 ⇒ 历史接口 / 握手回放都有。文案只含原因、不含落盘路径/存储名以外内容/凭据(有独特目录名反证断言)。
- ② GET /sessions/{id}/messages:不存在/已删除 ⇒ 404 session_not_found;**存在但无消息仍 200 {data:[]}**(空会话绝不能被答成 404);参数形状校验仍先于 404;存在性判定复用既有窄视图(history.Store.SessionPersona,未知 id 报 ErrSessionNotFound),**未新增 SQL/存储方法**;判不出来(未接该视图)保持旧语义(判不出 ≠ 不存在),判定本身失败回 500 不静默。
- 契约 v0.63(三档:reason 纯增量 / messages 404 行为变更 / 无破坏性):api-client §1 行 6 + §1.2④ 的 10 条逐字文案 + §4.4 错误码 + changelog + banner;api-reference §1.2 行 6 + §3.4 + §3.8 DTO + §8.1 错误码表。
- 主会话独立核对的副作用(隔离会话 sess-* 现在也 404):查活库 chat_sessions 里**根本没有 sess-* 行**(只有 web 与 web:<uuid>),sess-backthink 消息数 0,前端唯一提到 sess-backthink 的是 graph_update 的内存桶测试 ⇒ **没有客户端按 id 读隔离会话的 messages**;且这与 POST /sessions/{id}/read(v0.37 起即 404)一致 ⇒ 接受不加 sess- 例外,并在回执里问安卓确认。
- 主会话独立验收:隔离 worktree(S9 的 13 文件)build/vet 绿、整树连跑 2 次 59 ok / 0 FAIL、reason/404 用例 8 PASS、双平台交叉编译绿;**变异亲自复现**(在调用点短路 404 守卫 ⇒ UnknownSessionIs404 与 DeletedSessionIs404 双红:status = 200, want 404)。
- 如实登记:验收过程中出现过**一次不可复现的子集 FAIL**(restore 之后那次 Session|Reason 子集跑;未记到用例名)—— 随后同一子集、那三条用例 ×5、以及整树 ×2 全绿,判为瞬时;已记入账本,后续留意。
83 lines
3.8 KiB
Go
83 lines
3.8 KiB
Go
package gateway
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
|
||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||
)
|
||
|
||
// 本文件归属读路径上**「这条会话到底还在不在」**的唯一判断(契约 v0.63)。
|
||
//
|
||
// 背景(客户端登记,2026-10-05):`GET /api/v1/sessions/{id}/messages` 此前对
|
||
// **已删除 / 根本不存在**的会话回 `200 {"data":[]}`,客户端因此分不清「这是一条
|
||
// 空会话」与「这条会话已经不在了」(主人的真实遭遇:正在看的临时会话被清理掉,
|
||
// 界面上只剩一片空白)。同一个 API 里 `PATCH /sessions/{id}`、`POST
|
||
// /sessions/{id}/read` 对未知会话本来就是 `404` —— 让 messages 单独例外会让每个
|
||
// 新客户端都踩一次。
|
||
//
|
||
// 因此自 v0.63 起:不存在 / 已删除 ⇒ `404 {error:{code:"session_not_found"}}`;
|
||
// **存在但没有消息**仍是 `200 {"data":[]}`(空会话绝不能被答成 404)。
|
||
//
|
||
// 判据**复用既有存储面**,不新写 SQL、也不新增存储方法:
|
||
// `history.Store.SessionPersona` 是既有存储里唯一一个「按 id 读**单个**会话 +
|
||
// 未知 id 报 `history.ErrSessionNotFound`」的方法(`ListSessions` 是全量列表扫描,
|
||
// 在一个热分页端点上代价不成比例)。该口径由 `internal/runtime/history` 的
|
||
// `TestSessionPersonaUnknownSession` 钉住,装配期角色解析(persona_env.go)用的
|
||
// 也正是这个更窄视图。
|
||
//
|
||
// 名字口径(与既有 API 一致):`chat_sessions` 是**会话注册表**,隔离的 IM 会话
|
||
// (群聊 / 陌生人的 `sess-xxx`,以及 `sess-backthink`)不在其中 —— 它们与
|
||
// `POST /sessions/{id}/read` 拿到的是同一个答案(404:在那条路径上不可寻址),
|
||
// 其消息仍可由 `/api/v1/admin/channels/{platform}/messages` 读出。
|
||
//
|
||
// 未实现该视图的存储(测试替身 / 未接库的窄存储)视为**无从判定**:此时保持旧的
|
||
// 「空集合」语义 —— 判不出来**绝不等于**「不存在」,把一次本可成功的读答成 404
|
||
// 比答成空列表更糟。
|
||
|
||
// sessionNotFoundCode 是「会话不存在 / 已删除」的机器可读错误码(契约 v0.63,
|
||
// 见 Docs/api-client.md §1 行 6 与 §4.4)。
|
||
const sessionNotFoundCode = "session_not_found"
|
||
|
||
// sessionMissing 报告存储是否**明确否认** sessionID 的存在。
|
||
//
|
||
// 第三个返回值是读取本身的失败:调用方必须按「不静默」处理(回 500 并留带来源的
|
||
// 日志),绝不把「问不出来」当成「不存在」。
|
||
func (s *Server) sessionMissing(ctx context.Context, sessionID string) (bool, error) {
|
||
reader, ok := s.history.(sessionPersonaReader)
|
||
if !ok {
|
||
return false, nil
|
||
}
|
||
_, err := reader.SessionPersona(ctx, sessionID)
|
||
switch {
|
||
case errors.Is(err, history.ErrSessionNotFound):
|
||
return true, nil
|
||
case err != nil:
|
||
return false, fmt.Errorf("gateway: session lookup %s: %w", sessionID, err)
|
||
default:
|
||
return false, nil
|
||
}
|
||
}
|
||
|
||
// rejectMissingSession 在会话不存在时写出 `404 session_not_found` 并返回 true,
|
||
// 使调用方一行收口:`if s.rejectMissingSession(w, r, id) { return }`。
|
||
//
|
||
// 判定失败(存储读不出来)回 `500 messages_failed` 并返回 true —— 宁可让这一次读
|
||
// 失败并留下可诊断的日志,也不假装它是空会话。
|
||
func (s *Server) rejectMissingSession(w http.ResponseWriter, r *http.Request, id string) bool {
|
||
missing, err := s.sessionMissing(r.Context(), id)
|
||
if err != nil {
|
||
s.log.Error("history messages session lookup", "err", err, "session", id)
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, "messages_failed", "failed to list messages"))
|
||
return true
|
||
}
|
||
if !missing {
|
||
return false
|
||
}
|
||
writeError(w, apperr.Wrap(apperr.ErrNotFound, sessionNotFoundCode, "session %q not found", id))
|
||
return true
|
||
}
|