Composition roots copy Parts once so HTTP/CLI handlers stop calling locator getters on every request. Co-authored-by: Cursor <cursoragent@cursor.com>
190 lines
5.1 KiB
Go
190 lines
5.1 KiB
Go
package server
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"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 {
|
|
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.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 {
|
|
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.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
|
|
}
|
|
}
|
|
}
|
|
|
|
title := replyexport.DeriveTitle(msg.Content, 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": msg.Content,
|
|
"capability": cap,
|
|
})
|
|
}
|
|
|
|
// extractMessageIDFromPath 从路由参数或 URL 路径中提取 message_id
|
|
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
|
|
}
|