- 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 (baseline44161d2areproduced DATA RACE; now green under -race) - goose 00071 duplicate (dialogue merge1da17a28brought 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)
297 lines
8.5 KiB
Go
297 lines
8.5 KiB
Go
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
|
||
}
|