Files
Cyrene_v2/internal/services/gateway/rest.go
T
AskaEth 7512d593c2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway,history): REST 流式不再静默失败(G2)+ clientMsgId 幂等(G4)(协同 §G,均标「中高/中」)
- G2:`stream:true` 原经**有损总线**且**先回 200** ⇒ 总线饱和时客户端拿到 200 却什么都没发生。
  改为**直达编排器**(与 ws chat 帧既有先例同一原则):新文件 chat_stream_dispatch.go;200 变成有意义的
  承诺(同步穿过「编排器接收 + 用户消息落库」才回);顺带对齐 `202 {queued,queueDepth}` / `409 chat_busy`;
  兜底路径(未接编排器才走总线)现在**返回错误**、ctx 与请求脱钩(WithoutCancel + 30min 上界)→ `503 chat_dispatch_failed`;
  未接 loop 由 200 改为 `500 chat_unavailable`(与同步路径同口径,已同步改既有用例并新增钉住用例)
- G4:`chat`(WS 帧 + REST 体)新增**可选** `clientMsgId` ⇒ 幂等。
  原子性两层,均非「先查再插」:① 同进程在 `o.mu` 同一临界区内完成「查认领表→查存储→预分配 id→认领」;
  ② 跨重启靠 `(session_id, client_msg_id)` **部分**唯一索引 + `INSERT … ON CONFLICT DO NOTHING`(命中则读回既有行)。
  重复语义:REST `200 {duplicate:true, messageId}`;WS 新帧 `chat_duplicate`(owner/admin 档);重复**不落库、不起回合**。
  `clientMsgId` 回填进 DTO ⇒ `message_persisted` 与读接口都可见(客户端可据此对齐本地气泡,不再靠文本相等)
- 迁移 `0013_chat_client_msg_id`(up:加列 + 部分唯一索引 `idx_chat_messages_client_msg_id`;down:先删索引再删列),
  已在 `data/cyrene.db` **副本**上验 up → 判重 → down → 再 up
- 拆分:store.go 647→498 行(`log_reads.go` 抽走四个读路径);七个 SELECT 清单抽成 `messageColumns` 一处
- 复核:**全树 `go test ./... -count=1` 0 FAIL** + build/vet 绿 + linux/darwin 交叉编译绿;
  四条真断言(总线饱和仍落库出帧且 `ws.chat` 零发布 / 同键两次仅一回合+并发两条也仅一回合 / 省略键逐字不变 /
  迁移副本 up-down-up);**3 条变异反证**均已还原
- 越界说明(均已报备):`history.go` DTO 字段(否则「读接口可见」不可能)、`policy_store_test.go`
  (0013 使「回滚最后一步 == 0012」前提失效,改为按 schema_migrations 现算)、`extra_test.go`(补 runner)
- 顺带补交 P0-A 遗漏的新测试文件 `loop/memory_writeback_test.go`
2026-10-04 14:37:37 +08:00

464 lines
19 KiB
Go
Raw 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 (
"encoding/json"
"net/http"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// tokenTTL 是已签发 JWT 的有效时长。
const tokenTTL = 24 * time.Hour
// PersonaSummary 是 REST/管理列表响应中一个人格的线上结构。
type PersonaSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Character string `json:"character"`
IsDefault bool `json:"isDefault"`
Mode string `json:"mode"`
MemoryNS string `json:"memoryNamespace"`
Avatar string `json:"avatar,omitempty"`
// ChatBackground 持有分方向的壁纸;是本角色自己的,绝不是别人的。
ChatBackground ChatBackgroundSummary `json:"chatBackground,omitempty"`
// Mood 是该角色的当前情绪状态(design/01 §2:情绪跟随角色,
// 绝不是会话)。"" 意味着该角色没有受跟踪的情绪 —— 诚实的
// 空值,绝不是编造的默认。客户端从它给 mood 角标播种,因此
// 刷新不再闪出空 mood。
Mood string `json:"mood"`
// Thinking 携带人格可选的推理/呈现块
// (design/01 §2.1)。人格未配置时它是 nil —— 因而从 JSON 中缺席 ——
// 与写侧「省略的块保留
// 已存块」。仅管理列表:这些是模型调用开关,不是用户 UI 状态
// (用户列表取 Mood,把开关留给面板)。
Thinking *PersonaThinkingSummary `json:"thinking,omitempty"`
}
// PersonaThinkingSummary 在线上镜像人格的 `thinking` 块
// (design/01 §2.1;provider 映射见 design/04 §3.2)。
type PersonaThinkingSummary struct {
// BudgetTokens 是推理预算(Anthropic thinking.budget_tokens)。
BudgetTokens int `json:"budgetTokens"`
// Effort 是 provider 特有的推理强度(reasoning_effort)。
// 模型层会按活动 provider 的白名单校验它,并在它被丢弃时
// 打 WARN —— 这一侧只报告已存的内容。
Effort string `json:"effort"`
// ShowToUser 是三态的,且在线上保持如此:null = 未
// 配置(thinking 帧继续流出 —— 既有行为),
// true/false = 显式选择。普通 bool 会把「未设置」压平到
// 「关闭」上,丢掉写侧用 *bool 保持的区分。
ShowToUser *bool `json:"showToUser"`
// Mode 是显式的硬开关:default | on | off("" = default)。
Mode string `json:"mode"`
}
// ChatBackgroundSummary 在线上镜像人格的 chatBackground。
type ChatBackgroundSummary struct {
Landscape string `json:"landscape,omitempty"`
Portrait string `json:"portrait,omitempty"`
}
// MemorySummary 是管理列表响应中一条记忆条目的线上结构。
type MemorySummary struct {
ID string `json:"id"`
Scope string `json:"scope"`
Text string `json:"text"`
}
// SessionSummary 是列表响应中一个渠道会话的线上结构。
type SessionSummary struct {
Platform string `json:"platform"`
ChannelID string `json:"channelId"`
SessionID string `json:"sessionId"`
ActorRole string `json:"actorRole"`
Owner bool `json:"owner"`
IsGroup bool `json:"isGroup"`
MemWrite bool `json:"memWritable"`
}
// loginRequest 是 POST /api/v1/auth/login 的请求体。
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// loginResponse 是登录成功的载荷。RefreshToken 是 B2 新增的非透明
// 长期凭据(POST /auth/refresh);未接线 refresh 存储时被省略,
// 因此 B2 之前的契约不变。
type loginResponse struct {
Token string `json:"token"`
ExpiresAt int64 `json:"expiresAt"`
Role string `json:"role"`
RefreshToken string `json:"refreshToken,omitempty"`
}
// chatRequest 是 POST /api/v1/chat 的请求体。Stream 为 true 时网关
// 经 WebSocket 通道触发流式并立即返回。Image 是
// 为视觉路径附带的可选 base64 data: URI 或 http(s) URL;
// loop 把它经视觉/OCR 工具或主模型路由(design/06 §0)。
// Image 受 maxChatImageChars 限制(与 POST /api/v1/upload 的 5 MiB 同口径),
// 超限一律 400 image_too_large,不被静默吃下。
// DeviceID 是客户端产出的发送浏览器/设备标识
// (design/16 §4.5),例如 "web:chrome:a1b2c3";它可选且按原样持久化。
// Mode 是客户端发送该回合时的行为模式(design/01 §3、design/16
// §5.1);它可选,并按消息持久化以供气泡渲染,省略时
// 回退到 "chat"。
// ClientMsgID 是客户端自带的幂等键(Android 协同 §G4):它可选,同一会话内
// 同一个非空值只承认第一条 —— 重复到达返回既有消息 id(`{duplicate:true,
// messageId}`)而不再落库、不再起回合,客户端据此把本地乐观气泡对齐到服务端
// 真相(替代「靠文本相等」的脆弱匹配)。省略它时行为与加入该字段之前逐字相同。
type chatRequest struct {
SessionID string `json:"sessionId"`
Text string `json:"text"`
Image string `json:"image,omitempty"`
DeviceID string `json:"deviceId,omitempty"`
Mode string `json:"mode,omitempty"`
ClientMsgID string `json:"clientMsgId,omitempty"`
Stream bool `json:"stream"`
}
// chatResponse 是同步聊天回复的载荷。
type chatResponse struct {
Reply string `json:"reply"`
Steps int `json:"steps"`
StopReason string `json:"stopReason"`
}
// maxChatImageBytes 是 chat.image 允许的**解码后图像字节**上限,与
// POST /api/v1/upload 的 maxUploadBytes(5 MiB,见 upload.go)同口径:
// 图片有上传与内联两条入口,两边必须给出同一个「一张图能有多大」的答案,
// 否则上传的限制可被 base64 内联绕开 —— 客户端发来多大就落库多大,
// 每页历史(GET /sessions/{id}/messages)与每次 WS history 回放都要
// 把整段 base64 再传一遍(协同文档 G3)。
const maxChatImageBytes = 5 << 20
// chatImagePrefixAllowance 是 data: URI 前缀(如 "data:image/png;base64,")
// 所占的字符余量。按常见图片 MIME 的最长前缀取整,不含 base64 载荷本身。
const chatImagePrefixAllowance = 64
// maxChatImageChars 是同一口径换算到 base64 字符串长度的上限。base64 每
// 3 字节编码为 4 字符(膨胀 4/3),因此正好 5 MiB 的整幅图编码后是
// ceil(maxChatImageBytes/3)*4 = 6990508 字符(这就是 (n+2)/3*4 的由来:
// 余 1、2 字节时末组仍需补满 4 字符),再加 data: 前缀余量 —— 恰好 5 MiB
// 的合法图片绝不会被误拒。
//
// 校验按**字符串长度**做,是因为入口层只有这一步是零分配的:vision 层要先
// base64 解码整个字符串才拿到真实字节数(vision.decodeBase64 →
// base64.StdEncoding.DecodeString),也就是先分配再报错。在入口层按长度拦下,
// 超大字符串根本走不到解码那一步。
const maxChatImageChars = ((maxChatImageBytes+2)/3)*4 + chatImagePrefixAllowance
// chatImageTooLargeCode 是 image 超限时返回给客户端的机器可读错误码,
// 与其它 400 文案区分开:客户端只看 code 就能把「图太大,请改走上传」
// 与「请求体格式错」分开处理。
const chatImageTooLargeCode = "image_too_large"
// checkChatImageSize 校验 image 字符串长度是否在 limit 之内,超限时返回
// 400 + image_too_large 的显式错误(带上限与本次长度,并指明改用
// POST /api/v1/upload 取 URL 再发)。limit 作为参数传入,使该判定能被
// 用小上限直接单测(边界与比较运算符因此可被钉住),REST 入口传的是
// maxChatImageChars。
//
// 文案里的体积由 limit 反推(base64 膨胀 4/3 → 解码后约 limit*3/4 字节),
// 因此换上限时文案自动跟随,不会出现「写着 5 MiB、实际是别的数」。
func checkChatImageSize(image string, limit int) error {
if len(image) <= limit {
return nil
}
return apperr.Wrap(apperr.ErrBadRequest, chatImageTooLargeCode,
"图片过大:image 上限约 %.1f MiB(base64 字符串上限 %d 字符,本次 %d 字符);请先 POST /api/v1/upload 上传,再用返回的 url 作为 image 发送",
float64(limit)*3/4/(1<<20), limit, len(image))
}
// handleHealth 报告存活。它不认证,因为它是探针。
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
}
// handleLogin 用管理用户名/密码换取已签名的 JWT。
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
cfg := s.cfg.Auth
if req.Username != cfg.AdminUser || req.Password != cfg.AdminPass {
writeError(w, apperr.Wrap(apperr.ErrUnauthorized, "unauthorized", "invalid username or password"))
return
}
token, err := s.auth.Sign(req.Username, auth.RoleOwner, tokenTTL)
if err != nil {
s.log.Error("login sign", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "login_failed", "could not sign token"))
return
}
resp := loginResponse{
Token: token,
ExpiresAt: time.Now().Add(tokenTTL).Unix(),
Role: string(auth.RoleOwner),
}
// 在签发访问令牌的同时签发长期 refresh 凭据。存储失败会让
// 登录失败,而不是静默交回一个没有所声称 refresh token 的
// 登录;明文绝不记日志。
if s.refresh != nil {
refreshToken, _, rerr := s.refresh.Issue(r.Context(), req.Username, auth.RoleOwner, s.refreshTTL())
if rerr != nil {
s.log.Error("login issue refresh token", "err", rerr)
writeError(w, apperr.Wrap(apperr.ErrInternal, "login_failed", "could not issue refresh token"))
return
}
resp.RefreshToken = refreshToken
}
writeJSON(w, http.StatusOK, resp)
}
// handleChat 运行一次同步 agent 回合(或触发流式)。
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
var req chatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
// image 上限在**任何**解码/落库之前检查,且对同步与 stream 两条分支
// 都生效(stream 只是把该轮转发给 ws.chat,转发前必须先拒),
// 因此超限的图片既不进模型、也不落库、也不会被历史回放重传。
// 日志只带长度与上限,绝不带图片内容。
if err := checkChatImageSize(req.Image, maxChatImageChars); err != nil {
s.log.Warn("chat image rejected", "session", req.SessionID, "chars", len(req.Image), "limit", maxChatImageChars)
writeError(w, err)
return
}
if strings.TrimSpace(req.Text) == "" && strings.TrimSpace(req.Image) == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "text or image is required"))
return
}
if req.Stream {
// 协同 G2:不再「先回 200,再看有没有落地」。与 WS 的 chat 帧同一条
// 直达编排器的路径,由 handleChatStream 按判定回明确的状态码。
s.handleChatStream(w, r, req)
return
}
if s.runner == nil {
writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_unavailable", "chat service is not configured"))
return
}
if req.SessionID == "" {
req.SessionID = "web"
}
// Web 主会话是主人的私密对话(JWT = owner),因此它绑定
// 全访问 env:记忆读+写,行为不变。角色
// 来自会话的绑定(design/22 §4.5)。
msgs := userChatMessages(req.Text, req.Image)
env := s.envResolver.owner(r.Context(), req.SessionID)
// 客户端的 deviceID、行为模式与可选幂等键(协同 G4)都经 ctx 串进编排器。
ctx := withClientMsgHint(withMode(withDeviceID(r.Context(), req.DeviceID), req.Mode), clientMsgHint{ClientMsgID: req.ClientMsgID})
if s.hub.orchestrator != nil {
sr, res := s.hub.orchestrator.SubmitSync(ctx, req.SessionID, msgs, env)
// 重复到达(同一个 clientMsgId):明确告知既有消息 id,而不是再跑一遍
// 回合、再落一条用户消息(协同 G4)。
if sr.Duplicate {
writeJSON(w, http.StatusOK, map[string]any{"duplicate": true, "messageId": sr.MessageID, "sessionId": req.SessionID})
return
}
if !sr.Accepted {
if sr.PendingFull {
writeError(w, apperr.Wrap(apperr.ErrConflict, "chat_busy", "会话正忙,待处理消息已满"))
} else {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "text or image is required"))
}
return
}
// 忙碌会话返回非阻塞 ack(design/15 §2.1),而不是
// 让请求阻塞在一个可能跑一阵子的回合上。
if sr.Pending {
writeJSON(w, http.StatusAccepted, map[string]any{"queued": true, "queueDepth": sr.QueueDepth})
return
}
if sr.Err != nil {
s.log.Error("chat run", "err", sr.Err, "session", req.SessionID)
writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_failed", "chat failed"))
return
}
writeJSON(w, http.StatusOK, chatResponse{Reply: res.Text, Steps: res.Steps, StopReason: res.StopReason})
return
}
// 未接线编排器时的旧同步路径(未接线的 hub
// 保持完全与 design/15 之前相同的回复契约)。
res, err := s.runner.Run(loop.WithSessionEnv(r.Context(), env), req.SessionID, msgs)
if err != nil {
s.log.Error("chat run", "err", err, "session", req.SessionID)
writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_failed", "chat failed"))
return
}
writeJSON(w, http.StatusOK, chatResponse{Reply: res.Text, Steps: res.Steps, StopReason: res.StopReason})
}
// handleListSessions 在接线了 history 存储时返回持久化聊天历史会话
// (main + sub),否则回退到渠道会话视图。回退保持
// design/16 之前的契约:未接 history 的服务器仍列出已知的
// 渠道会话(未接线任何存储时为 [])。
func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
if s.history != nil {
s.handleHistorySessions(w, r)
return
}
out := make([]SessionSummary, 0)
if s.sessions != nil {
list, err := s.sessions.List(r.Context())
if err != nil {
s.log.Error("list sessions", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "sessions_failed", "failed to list sessions"))
return
}
for _, sess := range list {
out = append(out, summaryOfSession(sess))
}
}
writeJSON(w, http.StatusOK, out)
}
// handleListPersonas 返回人格(未接线存储时为 [])。它是
// 用户侧形状:角色级 mood 一并带上,仅管理的 thinking
// 开关则不然。
func (s *Server) handleListPersonas(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.collectPersonas(false))
}
// collectPersonas 从已配置的存储返回非 nil 的人格切片。
// withThinking 加上仅管理的 `thinking` 块;mood 总是报告,
// 因为用户 UI 要渲染角标。
func (s *Server) collectPersonas(withThinking bool) []PersonaSummary {
out := make([]PersonaSummary, 0)
if s.personas == nil {
return out
}
for _, c := range s.personas.List() {
id := c.PersonaID
if id == "" {
id = c.ID
}
sum := PersonaSummary{
ID: id,
Name: c.Name,
Character: c.Name,
IsDefault: c.Default,
Mode: firstMode(c.Modes),
Mood: s.moodOf(id),
}
// 读取该人格自己的视觉资产(头像 / 聊天背景)。每个
// 角色携带自己的;人格缺失或视觉未设置得到空,
// 绝不是别的角色的 —— 前端回退到中性默认。
if p, ok := s.personas.Persona(id); ok && p != nil {
if p.Visual.Avatar != "" {
sum.Avatar = p.Visual.Avatar
}
if p.Visual.ChatBackground.Landscape != "" || p.Visual.ChatBackground.Portrait != "" {
sum.ChatBackground = ChatBackgroundSummary{
Landscape: p.Visual.ChatBackground.Landscape,
Portrait: p.Visual.ChatBackground.Portrait,
}
}
sum.Thinking = thinkingSummaryOf(p.Thinking, withThinking)
}
out = append(out, sum)
}
return out
}
// moodOf 报告该角色的当前情绪状态。情绪归
// 人格存储所有(design/01 §2),经可选的 PersonaMoodStore
// 能力读取;不跟踪情绪的存储,或没有情绪机的角色,
// 报告 "" 而不是编造一个 mood。
func (s *Server) moodOf(id string) string {
store, ok := s.personas.(PersonaMoodStore)
if !ok {
return ""
}
emotions := store.Emotions()
if emotions == nil {
return ""
}
machine, _, ok := emotions.Get(id)
if !ok || machine == nil {
return ""
}
return string(machine.Current())
}
// thinkingSummaryOf 把已存 thinking 块映射到线上。人格未配置时
// 它返回 nil —— 该键因而缺席,使客户端能把「未配置」
// 与显式取值区分开(写侧「省略的块保留已存块」规则的
// 读侧镜像)。用户侧列表的 include 为 false,
// 因为用户侧不携带这些开关。
func thinkingSummaryOf(t loader.ThinkingParams, include bool) *PersonaThinkingSummary {
if !include || !thinkingConfigured(t) {
return nil
}
return &PersonaThinkingSummary{
BudgetTokens: t.BudgetTokens,
Effort: t.Effort,
ShowToUser: t.ShowToUser,
Mode: t.Mode,
}
}
// thinkingConfigured 报告是否有任何 thinking 开关被设置。空块
// 必须读作「未配置」,而不是全零对象。
func thinkingConfigured(t loader.ThinkingParams) bool {
return t.BudgetTokens != 0 || t.Effort != "" || t.ShowToUser != nil || t.Mode != ""
}
// firstMode 返回首个声明的行为模式,未设置时返回 ""。
func firstMode(modes []string) string {
if len(modes) == 0 {
return ""
}
return modes[0]
}
// userChatMessages 为 chat / stream 请求构建用户回合。image
// 存在时该回合成为图片消息(可带文本);否则是
// 纯文本消息。空文本加图片得到仅图片的回合。
func userChatMessages(text, image string) []model.Message {
if strings.TrimSpace(image) != "" {
return []model.Message{model.ImageMessage(model.RoleUser, text, image)}
}
return []model.Message{model.TextMessage(model.RoleUser, text)}
}
// summaryOfSession 把 session.Session 映射为其线上结构。
func summaryOfSession(sess session.Session) SessionSummary {
return SessionSummary{
Platform: sess.Platform,
ChannelID: sess.ChannelID,
SessionID: sess.SessionID,
ActorRole: string(sess.ActorRole),
Owner: sess.Owner,
IsGroup: sess.IsGroup,
MemWrite: sess.MemWritable,
}
}
// writeJSON 把 v 编码为 {data: v} 信封并带上给定状态。
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{"data": v})
}
// writeError 把 err 编码为 {error:{code,message}} 信封,状态由
// apperr.HTTPStatus 解析。
func writeError(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(apperr.HTTPStatus(err))
_ = json.NewEncoder(w).Encode(apperr.ToJSON(err))
}