Files
AskaEth 902b6eecfd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(api): documents[].reason + messages 对不存在会话回 404(安卓回执九两条;契约 v0.63)
- ① 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 全绿,判为瞬时;已记入账本,后续留意。
2026-10-04 21:50:08 +08:00

83 lines
3.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}