Files
weichen a9c9a9182e refactor(server): remove 24 mirrored Runtime fields via nil-safe accessors; fix Shutdown-vs-background-loop DATA RACE and goose 00071 duplicate version
- Server no longer mirrors app.Runtime services (gov/svc/pwSvc/...): 574 call sites now use accessors in runtime_accessors.go
- backgroundLoop (mutex+sync.Once) stops sweeper/publisher/reminder/wsGC goroutines race-free (baseline 44161d2a reproduced DATA RACE; now green under -race)
- goose 00071 duplicate (dialogue merge 1da17a28 brought two 00071): context-conflict-adjudications renumbered to 00072; dev DB applied only through 00070 so zero data migration
- task_finalize tests build minimal app.Runtime; test injection via serverTestOverrides + setOverride* (explicit-nil markers for fail-closed cases)
2026-09-25 13:17:22 +00:00

297 lines
8.5 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 server
import (
"fmt"
"net/http"
"strconv"
"strings"
"chat-agent-system/internal/artifactvault"
"chat-agent-system/internal/ctxmgr"
"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.ctxMgr() == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
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.ctxMgr().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.ctxMgr() == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
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.ctxMgr().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,
})
}
// handlePinExport archives a completed copy_only assistant reply after an explicit pin.
// POST /api/v1/chat/messages/{message_id}/pin-export
func (s *Server) handlePinExport(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireAuthenticatedUser(w, r)
if !ok {
return
}
if s.ctxMgr() == nil || s.vaultIngest == nil {
http.Error(w, "artifact vault not initialized", http.StatusServiceUnavailable)
return
}
msgID := extractMessageIDFromPath(r, "pin-export")
if msgID <= 0 {
writeJSONResponse(w, http.StatusBadRequest, map[string]string{
"error_code": "INVALID_MESSAGE_ID",
"message": "无效的消息ID",
})
return
}
msg, err := s.ctxMgr().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
}
if msg.Role != "assistant" {
writeJSONResponse(w, http.StatusBadRequest, map[string]string{
"error_code": "MESSAGE_NOT_PINNABLE",
"message": "仅助手回复可显式 pin",
})
return
}
turn, err := s.ctxMgr().GetChatTurnByAssistantMessageID(r.Context(), msg.SessionID, msg.ID)
if err != nil || turn == nil || turn.Status != ctxmgr.TurnStatusCompleted || turn.CompletedAt == nil {
writeJSONResponse(w, http.StatusConflict, map[string]string{
"error_code": "TURN_NOT_COMPLETED",
"message": "未完成回合不得 pin 进保险库",
})
return
}
cap, okCap := msg.ExportCapability.(replyexport.MessageExportCapability)
if !okCap {
_, cap = replyexport.EvaluatePolicy(replyexport.PolicyOptions{
Role: msg.Role, Status: "completed", Content: msg.Content, Revision: msg.ContentRevision,
})
}
if cap.Policy != replyexport.PolicyCopyOnly {
writeJSONResponse(w, http.StatusBadRequest, map[string]string{
"error_code": "PIN_POLICY_REJECTED",
"message": "仅 copy_only 需要显式 pin;full 已自动归档,none 永不进入",
})
return
}
eventID := fmt.Sprintf("pin:%d:%d", msg.ID, cap.ContentRevision)
err = s.vaultIngest.AssistantReplyPin(
r.Context(), turn.ProjectID, fmt.Sprintf("msg:%d", msg.ID), eventID,
msg.ID, "", string(cap.Policy), turn.CompletedAt, []byte(msg.Content),
)
if err != nil {
writeJSONResponse(w, http.StatusConflict, map[string]string{
"error_code": "PIN_REJECTED",
"message": "保险库拒绝该 pin",
})
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{
"message_id": msg.ID,
"policy": cap.Policy,
"status": "requested",
})
}
// 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
}