Keep FAV_DEGRADED_READ off by default and mount an independent vault volume beside runtime data. Co-authored-by: Cursor <cursoragent@cursor.com>
219 lines
6.0 KiB
Go
219 lines
6.0 KiB
Go
package server
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"chat-agent-system/internal/artifactvault"
|
|
"chat-agent-system/internal/replyexport"
|
|
)
|
|
|
|
// handleGetExportCapability 获取单条消息权威导出能力
|
|
// GET /api/v1/chat/messages/{message_id}/export-capability
|
|
func (s *Server) handleGetExportCapability(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := s.requireAuthenticatedUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if s.svc == nil || s.svc.ContextManager() == nil {
|
|
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
msgID := extractMessageIDFromPath(r, "export-capability")
|
|
if msgID <= 0 {
|
|
writeJSONResponse(w, http.StatusBadRequest, map[string]string{
|
|
"error_code": "INVALID_MESSAGE_ID",
|
|
"message": "无效的消息ID",
|
|
})
|
|
return
|
|
}
|
|
|
|
msg, err := s.svc.ContextManager().GetMessageByID(r.Context(), msgID)
|
|
if err != nil || msg == nil {
|
|
writeJSONResponse(w, http.StatusNotFound, map[string]string{
|
|
"error_code": "MESSAGE_NOT_FOUND",
|
|
"message": "指定消息不存在或已被删除",
|
|
})
|
|
return
|
|
}
|
|
if !s.assertMessageOwnedByUser(w, r, msg, user.ID) {
|
|
return
|
|
}
|
|
|
|
cap, ok := msg.ExportCapability.(replyexport.MessageExportCapability)
|
|
if !ok {
|
|
// 动态降级重新评估
|
|
hasCard := false
|
|
if msg.Metadata != nil {
|
|
if _, exists := msg.Metadata["interactive_card"]; exists {
|
|
hasCard = true
|
|
}
|
|
}
|
|
_, cap = replyexport.EvaluatePolicy(replyexport.PolicyOptions{
|
|
Role: msg.Role,
|
|
Status: "completed",
|
|
Content: msg.Content,
|
|
HasInteractiveCard: hasCard,
|
|
Revision: msg.ContentRevision,
|
|
})
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"message_id": msg.ID,
|
|
"policy": cap.Policy,
|
|
"formats": cap.Formats,
|
|
"body_selector": "content_only",
|
|
"content_revision": cap.ContentRevision,
|
|
"content_sha256": cap.ContentSha256,
|
|
}
|
|
if cap.ReasonCode != "" {
|
|
resp["reason_code"] = cap.ReasonCode
|
|
}
|
|
|
|
writeJSONResponse(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// handleGetExportSource 获取单条消息受控不可变正文快照
|
|
// GET /api/v1/chat/messages/{message_id}/export-source?content_revision=
|
|
func (s *Server) handleGetExportSource(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := s.requireAuthenticatedUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if s.svc == nil || s.svc.ContextManager() == nil {
|
|
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
msgID := extractMessageIDFromPath(r, "export-source")
|
|
if msgID <= 0 {
|
|
writeJSONResponse(w, http.StatusBadRequest, map[string]string{
|
|
"error_code": "INVALID_MESSAGE_ID",
|
|
"message": "无效的消息ID",
|
|
})
|
|
return
|
|
}
|
|
|
|
msg, err := s.svc.ContextManager().GetMessageByID(r.Context(), msgID)
|
|
if err != nil || msg == nil {
|
|
writeJSONResponse(w, http.StatusNotFound, map[string]string{
|
|
"error_code": "MESSAGE_NOT_FOUND",
|
|
"message": "指定消息不存在或已被删除",
|
|
})
|
|
return
|
|
}
|
|
if !s.assertMessageOwnedByUser(w, r, msg, user.ID) {
|
|
return
|
|
}
|
|
|
|
cap, ok := msg.ExportCapability.(replyexport.MessageExportCapability)
|
|
if !ok {
|
|
hasCard := false
|
|
if msg.Metadata != nil {
|
|
if _, exists := msg.Metadata["interactive_card"]; exists {
|
|
hasCard = true
|
|
}
|
|
}
|
|
_, cap = replyexport.EvaluatePolicy(replyexport.PolicyOptions{
|
|
Role: msg.Role,
|
|
Status: "completed",
|
|
Content: msg.Content,
|
|
HasInteractiveCard: hasCard,
|
|
Revision: msg.ContentRevision,
|
|
})
|
|
}
|
|
|
|
if cap.Policy == replyexport.PolicyNone {
|
|
writeJSONResponse(w, http.StatusBadRequest, map[string]string{
|
|
"error_code": "MESSAGE_NOT_EXPORTABLE",
|
|
"message": "该消息不支持导出为文档",
|
|
})
|
|
return
|
|
}
|
|
|
|
// 校验 revision(若客户端传入了指定版本)
|
|
if reqRevStr := r.URL.Query().Get("content_revision"); reqRevStr != "" {
|
|
if reqRev, err := strconv.Atoi(reqRevStr); err == nil && reqRev > 0 {
|
|
if reqRev != cap.ContentRevision {
|
|
writeJSONResponse(w, http.StatusConflict, map[string]string{
|
|
"error_code": "MESSAGE_REVISION_CHANGED",
|
|
"message": "消息内容版本已更新,请重新加载最新快照",
|
|
})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
markdown := msg.Content
|
|
source := "ledger"
|
|
if strings.TrimSpace(markdown) == "" && s.cfg != nil && s.cfg.FAVDegradedRead {
|
|
env := s.cfg.Env
|
|
if env == "" {
|
|
env = "dev"
|
|
}
|
|
if payload, sha, verr := s.tryVaultDegraded(r.Context(), env, fmt.Sprintf("msg:%d", msg.ID), artifactvault.ProjectKeyFromID(messageProjectIDFromMeta(msg.Metadata))); verr == nil && len(payload) > 0 {
|
|
markdown = string(payload)
|
|
cap.ContentSha256 = sha
|
|
source = "vault/degraded"
|
|
}
|
|
}
|
|
|
|
title := replyexport.DeriveTitle(markdown, msg.CreatedAt)
|
|
snapshotID := fmt.Sprintf("msg:%d:rev:%d", msg.ID, cap.ContentRevision)
|
|
|
|
w.Header().Set("Cache-Control", "private, no-store")
|
|
writeJSONResponse(w, http.StatusOK, map[string]any{
|
|
"snapshot_id": snapshotID,
|
|
"message_id": msg.ID,
|
|
"content_revision": cap.ContentRevision,
|
|
"content_sha256": cap.ContentSha256,
|
|
"title": title,
|
|
"markdown": markdown,
|
|
"capability": cap,
|
|
"source": source,
|
|
})
|
|
}
|
|
|
|
// extractMessageIDFromPath 从路由参数或 URL 路径中提取 message_id
|
|
func messageProjectIDFromMeta(meta map[string]any) int64 {
|
|
if meta == nil {
|
|
return 0
|
|
}
|
|
if pVal, ok := meta["project_id"].(int64); ok {
|
|
return pVal
|
|
}
|
|
if pVal, ok := meta["project_id"].(float64); ok {
|
|
return int64(pVal)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func extractMessageIDFromPath(r *http.Request, actionName string) int64 {
|
|
// 优先尝试 Go 1.22 PathValue
|
|
val := r.PathValue("message_id")
|
|
if val != "" {
|
|
id, err := strconv.ParseInt(val, 10, 64)
|
|
if err == nil && id > 0 {
|
|
return id
|
|
}
|
|
}
|
|
|
|
// 降级:从路径分割提取 /api/v1/chat/messages/{id}/{action}
|
|
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
|
for i, p := range parts {
|
|
if p == "messages" && i+1 < len(parts) {
|
|
id, err := strconv.ParseInt(parts[i+1], 10, 64)
|
|
if err == nil && id > 0 {
|
|
return id
|
|
}
|
|
}
|
|
}
|
|
|
|
return 0
|
|
}
|