From f3f23a3018118640df00b772705b4f7ecc4dde94 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 28 Sep 2026 02:27:16 +0000 Subject: [PATCH] =?UTF-8?q?feat(errcode):=20=E7=94=A8=E7=A8=B3=E5=AE=9A?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E7=A0=81=E8=AE=B0=E5=BD=95=20HTTP=20?= =?UTF-8?q?=E4=B8=8E=E5=BC=82=E6=AD=A5=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 失败响应此前不写日志,request_id 无法对照原因。注册表不覆盖处理器已经给出的 HTTP 状态。 --- .doc/system-design/module-boundaries.yaml | 15 +- .../00078_2026-09-28-error-events-audit.sql | 36 ++ internal/app/app.go | 11 + internal/ctxmgr/async_error.go | 19 + internal/ctxmgr/manager.go | 4 + internal/ctxmgr/shadow_task.go | 1 + internal/ctxmgr/summarizer.go | 2 + internal/docgov/types.go | 9 + internal/errcode/code.go | 414 ++++++++++++++++++ internal/errcode/code_test.go | 64 +++ internal/errcode/codes_domain.go | 233 ++++++++++ internal/errcode/legacy_extra.go | 165 +++++++ internal/errorevents/store.go | 151 +++++++ internal/errorevents/store_pg_test.go | 52 +++ internal/events/dispatcher.go | 22 +- internal/httpx/envelope.go | 61 ++- internal/httpx/envelope_test.go | 33 ++ internal/projectinstance/errors.go | 2 + internal/server/access_log_middleware.go | 14 +- internal/server/access_log_middleware_test.go | 2 +- internal/server/admin_error_events_test.go | 32 ++ internal/server/admin_roles_handlers.go | 8 +- internal/server/admin_system_handlers.go | 58 ++- internal/server/admin_template_handlers.go | 6 +- internal/server/admin_workflow_handlers.go | 16 +- internal/server/asset_handlers.go | 8 +- internal/server/auth_handlers.go | 24 +- internal/server/auth_handlers_test.go | 4 +- internal/server/auth_helpers.go | 2 +- internal/server/chat_digest_api.go | 2 +- internal/server/chat_turn_api.go | 2 +- internal/server/chat_unread_api.go | 2 +- internal/server/chat_window_api.go | 2 +- internal/server/chatflow_http.go | 2 +- internal/server/document_revision_handlers.go | 2 +- internal/server/domain_http.go | 133 +++++- internal/server/domain_http_test.go | 12 +- internal/server/handlers_chat_history.go | 8 +- internal/server/handlers_chat_task.go | 12 +- internal/server/handlers_context.go | 8 +- internal/server/handlers_docgov.go | 44 +- internal/server/handlers_docgov_lease.go | 18 +- internal/server/handlers_http.go | 93 +++- internal/server/maintenance_handlers.go | 10 +- internal/server/me_settings_handlers.go | 6 +- internal/server/notification_handlers.go | 4 +- internal/server/notification_reminder_loop.go | 8 + internal/server/project_doc_handlers.go | 2 +- internal/server/project_handlers.go | 20 +- internal/server/project_members_handlers.go | 14 +- internal/server/project_settings_handlers.go | 6 +- internal/server/reconcile_handlers.go | 2 +- internal/server/server.go | 1 + internal/server/software_project_handlers.go | 2 +- internal/server/sse.go | 12 +- internal/server/swboot_handlers.go | 4 +- internal/server/task_run_handlers.go | 26 +- internal/server/trace_handlers.go | 12 +- internal/server/user_directory_handlers.go | 4 +- internal/server/workflow_catalog_handlers.go | 2 +- .../server/workflow_terminate_handlers.go | 2 +- internal/server/workspace_admin_handlers.go | 8 +- internal/stream/classify.go | 18 + internal/stream/classify_test.go | 18 + internal/stream/types.go | 1 + internal/taskrun/manager.go | 32 ++ 66 files changed, 1846 insertions(+), 176 deletions(-) create mode 100644 deploy/postgres/goose/00078_2026-09-28-error-events-audit.sql create mode 100644 internal/ctxmgr/async_error.go create mode 100644 internal/errcode/code.go create mode 100644 internal/errcode/code_test.go create mode 100644 internal/errcode/codes_domain.go create mode 100644 internal/errcode/legacy_extra.go create mode 100644 internal/errorevents/store.go create mode 100644 internal/errorevents/store_pg_test.go create mode 100644 internal/server/admin_error_events_test.go diff --git a/.doc/system-design/module-boundaries.yaml b/.doc/system-design/module-boundaries.yaml index 4350fef4..8f0612d6 100644 --- a/.doc/system-design/module-boundaries.yaml +++ b/.doc/system-design/module-boundaries.yaml @@ -84,6 +84,7 @@ imports: ctxmgr: - app/uow - docgov + - errcode - replyexport - storage conflictresolve: @@ -115,6 +116,8 @@ imports: - ctxmgr - docgov - docrevision + - errcode + - errorevents - events - freedoc - intent @@ -247,6 +250,8 @@ imports: - docgov - docquery - docrevision + - errcode + - errorevents - events - freedoc - handoff @@ -284,7 +289,8 @@ imports: - workflowdef storage: - config - stream: [] + stream: + - errcode stream/transport: - stream swboot: [] @@ -293,6 +299,7 @@ imports: - artifactvault - bizdev - ctxmgr + - errcode - events - projectworkflow - stream @@ -313,8 +320,12 @@ imports: - workflowdef workspace: - app/uow + errcode: [] + errorevents: + - errcode events: - app/uow + - errcode artifactvault: - app/uow - fsutil @@ -463,6 +474,8 @@ table_owners: - sysadmin system_setting_policy_audits: - sysadmin + error_events: + - errorevents maintenance_windows: - maintenance # FIND-ARCH-01 回归补登记(2026-09-26):以下为既有写者的真实表。 diff --git a/deploy/postgres/goose/00078_2026-09-28-error-events-audit.sql b/deploy/postgres/goose/00078_2026-09-28-error-events-audit.sql new file mode 100644 index 00000000..55106afd --- /dev/null +++ b/deploy/postgres/goose/00078_2026-09-28-error-events-audit.sql @@ -0,0 +1,36 @@ +-- +goose Up +-- 统一错误事件审计:HTTP 与异步失败按 code + request_id 检索。 +CREATE TABLE IF NOT EXISTS error_events ( + id BIGSERIAL PRIMARY KEY, + code TEXT NOT NULL, + class TEXT NOT NULL DEFAULT 'fatal', + http_status INT, + request_id TEXT, + session_id TEXT, + workspace_id TEXT, + module TEXT, + phase_id TEXT, + path TEXT, + method TEXT, + source TEXT NOT NULL DEFAULT 'http', + upstream TEXT, + actor_user_id BIGINT, + retryable BOOLEAN NOT NULL DEFAULT false, + user_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_error_events_request_id + ON error_events (request_id); + +CREATE INDEX IF NOT EXISTS idx_error_events_code_created + ON error_events (code, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_error_events_created + ON error_events (created_at DESC); + +-- +goose Down +DROP INDEX IF EXISTS idx_error_events_created; +DROP INDEX IF EXISTS idx_error_events_code_created; +DROP INDEX IF EXISTS idx_error_events_request_id; +DROP TABLE IF EXISTS error_events; diff --git a/internal/app/app.go b/internal/app/app.go index f8229a81..2d160c4b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -26,6 +26,8 @@ import ( "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/docgov" "chat-agent-system/internal/docrevision" + "chat-agent-system/internal/errcode" + "chat-agent-system/internal/errorevents" "chat-agent-system/internal/events" "chat-agent-system/internal/freedoc" "chat-agent-system/internal/intent" @@ -184,6 +186,9 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) { intentflow.SetLLMSegmentProposer(&PiSegmentProposer{Backend: dialogueBackend, Model: model, DataDir: dataDir}) } pool := parts.Pool + if pool != nil { + errorevents.Install(errorevents.NewStore(pool)) + } stores, storeErr := auth.NewStores(context.Background(), pool, cfg) if storeErr != nil { return nil, fmt.Errorf("auth store: %w", storeErr) @@ -608,6 +613,12 @@ func (r *Runtime) runStageAdvanceReminders(ctx context.Context) { n, err := r.Notify.ProcessStageAdvanceReminders(ctx) if err != nil { slog.Warn("stage advance reminder scan failed", "error", err) + errcode.Report(ctx, errcode.Event{ + Code: "REMINDER_SCAN_FAILED", + Module: "notification", + Source: "async", + Upstream: err.Error(), + }) return } if n > 0 { diff --git a/internal/ctxmgr/async_error.go b/internal/ctxmgr/async_error.go new file mode 100644 index 00000000..883ffdf8 --- /dev/null +++ b/internal/ctxmgr/async_error.go @@ -0,0 +1,19 @@ +package ctxmgr + +import ( + "context" + + "chat-agent-system/internal/errcode" +) + +func reportBackground(err error) { + if err == nil { + return + } + errcode.Report(context.Background(), errcode.Event{ + Code: "CTXMGR_BACKGROUND", + Module: "ctxmgr", + Source: "async", + Upstream: err.Error(), + }) +} diff --git a/internal/ctxmgr/manager.go b/internal/ctxmgr/manager.go index 1b14befc..76f2363d 100644 --- a/internal/ctxmgr/manager.go +++ b/internal/ctxmgr/manager.go @@ -400,6 +400,7 @@ func (m *ContextManager) GetUserContext(ctx context.Context, projectID, userID i } if err := rows.Err(); err != nil { slog.Warn("ctxmgr_recent_chats_scan_incomplete", "error", err) + reportBackground(err) } } } else { @@ -432,6 +433,7 @@ func (m *ContextManager) GetUserContext(ctx context.Context, projectID, userID i } if err := rows.Err(); err != nil { slog.Warn("ctxmgr_user_activities_scan_incomplete", "error", err) + reportBackground(err) } } } else { @@ -506,6 +508,7 @@ func (m *ContextManager) GetProjectContext(ctx context.Context, projectID int64) } if err := rows.Err(); err != nil { slog.Warn("ctxmgr_project_activities_scan_incomplete", "error", err) + reportBackground(err) } pCtx.AllActivities = ApplyTimeDecayToActivities(rawActs, m.decayCfg) } @@ -755,6 +758,7 @@ func (m *ContextManager) ReflectUserPreference(ctx context.Context, userID int64 } if err := rows.Err(); err != nil { slog.Warn("ctxmgr_reflect_msgs_scan_incomplete", "error", err) + reportBackground(err) } } } else { diff --git a/internal/ctxmgr/shadow_task.go b/internal/ctxmgr/shadow_task.go index 4ddd4757..4cb78fc4 100644 --- a/internal/ctxmgr/shadow_task.go +++ b/internal/ctxmgr/shadow_task.go @@ -73,6 +73,7 @@ func (m *ContextManager) EnqueueSemanticShadow(ctx context.Context, job Semantic job.WatermarkToMessageID, job.PriorRevision, payload) if err != nil { slog.Warn("semantic shadow job persist failed; using in-process queue", "session_id", job.SessionID, "err", err) + reportBackground(err) m.rememberShadowJob(job) } m.kickShadowWorker() diff --git a/internal/ctxmgr/summarizer.go b/internal/ctxmgr/summarizer.go index 8629b6c8..571192f3 100644 --- a/internal/ctxmgr/summarizer.go +++ b/internal/ctxmgr/summarizer.go @@ -83,6 +83,7 @@ func (m *ContextManager) GetChatSummary(ctx context.Context, sessionID string) ( } if err != nil && !errors.Is(err, pgx.ErrNoRows) { slog.Warn("chat summary load failed", "session_id", sessionID, "err", err) + reportBackground(err) } } @@ -162,6 +163,7 @@ func (m *ContextManager) SaveChatSummary(ctx context.Context, sum *ChatSummary) noteSummaryNotDurable(sum) m.rememberSummary(sum) slog.Warn("chat summary persist failed", "session_id", sum.SessionID, "err", err) + reportBackground(err) return fmt.Errorf("chat summary persist failed: %w", err) } sum.Persisted = true diff --git a/internal/docgov/types.go b/internal/docgov/types.go index 1ecac027..5e7862c4 100644 --- a/internal/docgov/types.go +++ b/internal/docgov/types.go @@ -97,6 +97,13 @@ func (e *ContentError) Unwrap() error { return e.cause } +func (e *ContentError) ErrorCode() string { + if e == nil || e.Code == "" { + return "DOCUMENT_CONTENT_ERROR" + } + return e.Code +} + func NewContentError(code string, cause error, retryable, needsAdmin bool) *ContentError { msg := code if cause != nil { @@ -125,6 +132,8 @@ func (e *DocumentBusyError) Error() string { func (e *DocumentBusyError) Unwrap() error { return ErrDocumentBusy } +func (e *DocumentBusyError) ErrorCode() string { return "DOC_BUSY" } + // DocumentConfig 受控文档流转与退回配置 type DocumentConfig struct { RejectRollbackTo DocumentState `json:"reject_rollback_to"` // 驳回退回目标: drafting 或 reviewing diff --git a/internal/errcode/code.go b/internal/errcode/code.go new file mode 100644 index 00000000..fef7b5f6 --- /dev/null +++ b/internal/errcode/code.go @@ -0,0 +1,414 @@ +// Package errcode 是全系统错误码注册表。 +// 领域包保留哨兵错误;server 把它们绑定到这里的稳定码。 +// 日志与 error_events 审计只记录码、request_id 和脱敏后的摘要。 +package errcode + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "sort" + "strings" + "sync" + "time" + "unicode/utf8" +) + +const ( + ClassTransient = "transient" + ClassResumable = "resumable" + ClassFatal = "fatal" + ClassCancelled = "cancelled" +) + +// Def 是一条可检索的错误码。Message 与 Guide 必须是可公开文案。 +type Def struct { + ID string + Status int + Message string + Class string + Module string + Guide string + Retryable bool + Actions []string +} + +// Event 是一条错误审计记录。Upstream 在 Report 时脱敏。 +type Event struct { + ID int64 `json:"id,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` + Code string `json:"code"` + Class string `json:"class,omitempty"` + HTTPStatus int `json:"httpStatus,omitempty"` + RequestID string `json:"requestId,omitempty"` + SessionID string `json:"sessionId,omitempty"` + WorkspaceID string `json:"workspaceId,omitempty"` + Module string `json:"module,omitempty"` + PhaseID string `json:"phaseId,omitempty"` + Path string `json:"path,omitempty"` + Method string `json:"method,omitempty"` + Source string `json:"source,omitempty"` + Upstream string `json:"upstream,omitempty"` + ActorUserID int64 `json:"actorUserId,omitempty"` + Retryable bool `json:"retryable,omitempty"` + UserMessage string `json:"userMessage,omitempty"` +} + +// Auditor 持久化错误事件。写入失败不得影响主请求。 +type Auditor interface { + Record(ctx context.Context, ev Event) error +} + +var ( + mu sync.RWMutex + byID = map[string]Def{} + legacyIndex = map[string]string{} + prefixes []prefixRule + sentinels []sentinel + auditor Auditor +) + +type prefixRule struct { + prefix string + id string +} + +type sentinel struct { + err error + id string +} + +func register(d Def) Def { + if d.ID == "" { + panic("errcode: empty id") + } + if d.Class == "" { + d.Class = ClassFatal + } + if d.Message == "" { + d.Message = "请求无法完成" + } + if d.Guide == "" { + mod := d.Module + if mod == "" { + mod = "server" + } + d.Guide = fmt.Sprintf("用 request_id 在服务日志或管理台「错误审计」中检索错误码 %s。负责模块:%s。", d.ID, mod) + } + if d.Class == ClassTransient || d.Class == ClassResumable { + d.Retryable = true + } + mu.Lock() + defer mu.Unlock() + if _, ok := byID[d.ID]; ok { + panic("errcode: duplicate id " + d.ID) + } + byID[d.ID] = d + return d +} + +func registerLegacy(message, id string) { + message = strings.TrimSpace(message) + if message == "" || id == "" { + panic("errcode: empty legacy mapping") + } + mu.Lock() + defer mu.Unlock() + if _, ok := byID[id]; !ok { + panic("errcode: legacy maps to unknown id " + id) + } + if prev, ok := legacyIndex[message]; ok && prev != id { + panic("errcode: legacy message remapped " + message) + } + legacyIndex[message] = id +} + +func registerPrefix(prefix, id string) { + prefix = strings.ToLower(strings.TrimSpace(prefix)) + mu.Lock() + defer mu.Unlock() + if _, ok := byID[id]; !ok { + panic("errcode: prefix maps to unknown id " + id) + } + prefixes = append(prefixes, prefixRule{prefix: prefix, id: id}) +} + +// Bind 把领域哨兵错误绑到已注册的码上。后绑定的不覆盖先绑定的 errors.Is 命中。 +func Bind(err error, id string) { + if err == nil { + panic("errcode: bind nil") + } + mu.Lock() + defer mu.Unlock() + if _, ok := byID[id]; !ok { + panic("errcode: bind unknown id " + id) + } + sentinels = append(sentinels, sentinel{err: err, id: id}) +} + +// Lookup 按码查询。 +func Lookup(id string) (Def, bool) { + mu.RLock() + defer mu.RUnlock() + d, ok := byID[id] + return d, ok +} + +// All 返回全部错误码,按 ID 排序。 +func All() []Def { + mu.RLock() + defer mu.RUnlock() + out := make([]Def, 0, len(byID)) + for _, d := range byID { + out = append(out, d) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +// FromStatus 把 HTTP 状态映射到粗粒度但稳定的码。 +func FromStatus(status int) Def { + id := "REQUEST_INVALID" + switch status { + case 401: + id = "AUTH_UNAUTHENTICATED" + case 403: + id = "AUTH_FORBIDDEN" + case 404: + id = "RESOURCE_NOT_FOUND" + case 405: + id = "REQUEST_METHOD_NOT_ALLOWED" + case 409: + id = "RESOURCE_CONFLICT" + case 429: + id = "RATE_LIMITED" + case 503: + id = "SERVICE_UNAVAILABLE" + default: + if status >= 500 { + id = "INTERNAL" + } + } + d, ok := Lookup(id) + if !ok { + return Def{ID: id, Status: status, Message: "请求无法完成", Class: ClassFatal, Module: "server"} + } + return d +} + +// FromLegacy 用公开文案(或其中的 JSON message/error)选择详细码。 +// 未命中时退回 FromStatus,不改变调用方的 HTTP 状态。 +func FromLegacy(status int, legacy string) Def { + key := normalizeLegacy(legacy) + mu.RLock() + id, ok := legacyIndex[key] + var pref string + if !ok { + low := strings.ToLower(key) + for _, p := range prefixes { + if strings.HasPrefix(low, p.prefix) { + pref = p.id + break + } + } + } + mu.RUnlock() + if ok { + if d, found := Lookup(id); found { + return d + } + } + if pref != "" { + if d, found := Lookup(pref); found { + return d + } + } + return FromStatus(status) +} + +func normalizeLegacy(legacy string) string { + legacy = strings.TrimSpace(legacy) + if legacy == "" || !strings.HasPrefix(legacy, "{") { + return legacy + } + var m map[string]any + if err := json.Unmarshal([]byte(legacy), &m); err != nil { + return legacy + } + if msg, _ := m["message"].(string); strings.TrimSpace(msg) != "" { + return strings.TrimSpace(msg) + } + if msg, _ := m["error"].(string); strings.TrimSpace(msg) != "" { + return strings.TrimSpace(msg) + } + return legacy +} + +// Error 是带稳定码的错误,可被 FromError / 流式分类直接读取。 +type Error struct { + Def Def + Cause error +} + +func (e *Error) Error() string { + if e == nil { + return "" + } + if e.Cause != nil { + return e.Cause.Error() + } + if e.Def.Message != "" { + return e.Def.Message + } + return e.Def.ID +} + +func (e *Error) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +func (e *Error) ErrorCode() string { + if e == nil { + return "" + } + return e.Def.ID +} + +// New 包装一个已注册错误码。 +func New(id string, cause error) error { + d, ok := Lookup(id) + if !ok { + d = Def{ID: id, Message: "请求无法完成", Class: ClassFatal, Module: "server"} + } + return &Error{Def: d, Cause: cause} +} + +type errorCoder interface { + ErrorCode() string +} + +// FromError 读取 typed 错误码或已绑定的哨兵错误。 +func FromError(err error) (Def, bool) { + if err == nil { + return Def{}, false + } + var coded *Error + if errors.As(err, &coded) && coded != nil && coded.Def.ID != "" { + if d, ok := Lookup(coded.Def.ID); ok { + return d, true + } + return coded.Def, true + } + var coder errorCoder + if errors.As(err, &coder) && coder != nil { + if d, ok := Lookup(coder.ErrorCode()); ok { + return d, true + } + } + mu.RLock() + defer mu.RUnlock() + for _, s := range sentinels { + if errors.Is(err, s.err) { + d, ok := byID[s.id] + return d, ok + } + } + return Def{}, false +} + +// SetAuditor 安装进程级审计写入器。未安装时 Report 只跳过落库。 +func SetAuditor(a Auditor) { + mu.Lock() + auditor = a + mu.Unlock() +} + +// Report 记录一条错误审计。落库失败只打日志,不向调用方返回错误。 +func Report(ctx context.Context, ev Event) { + _ = ctx + if ev.Code == "" { + ev.Code = "INTERNAL" + } + if ev.Class == "" { + if d, ok := Lookup(ev.Code); ok { + ev.Class = d.Class + ev.Retryable = d.Retryable + if ev.UserMessage == "" { + ev.UserMessage = d.Message + } + if ev.Module == "" { + ev.Module = d.Module + } + } else { + ev.Class = ClassFatal + } + } + ev.Upstream = Sanitize(ev.Upstream) + ev.UserMessage = Sanitize(ev.UserMessage) + ev.Path = stripQuery(ev.Path) + if ev.Source == "" { + ev.Source = "http" + } + mu.RLock() + a := auditor + mu.RUnlock() + if a == nil { + return + } + cctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + if err := a.Record(cctx, ev); err != nil { + slog.Warn("error_event_audit_degraded", "code", ev.Code, "request_id", ev.RequestID, "err", err.Error()) + } +} + +// Sanitize 去掉疑似内部信息,并截断到 160 个字符。 +func Sanitize(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + if looksInternal(s) { + return "" + } + return truncate(s, 160) +} + +func looksInternal(s string) bool { + low := strings.ToLower(s) + needles := []string{ + "sql", "pq:", "pgx", "postgres", "stack", "panic", "token", "password", + "secret", "api_key", "bearer ", "../", "prompt", "commit", "rollback", + "transaction", "constraint", "violates", + } + for _, n := range needles { + if strings.Contains(low, n) { + return true + } + } + if strings.Contains(s, "/home/") || strings.Contains(s, "/var/") || strings.Contains(s, "\\") { + return true + } + return false +} + +func truncate(s string, maxRunes int) string { + if utf8.RuneCountInString(s) <= maxRunes { + return s + } + r := []rune(s) + return string(r[:maxRunes]) +} + +func stripQuery(path string) string { + path = strings.TrimSpace(path) + if i := strings.IndexAny(path, "?#"); i >= 0 { + path = path[:i] + } + return path +} diff --git a/internal/errcode/code_test.go b/internal/errcode/code_test.go new file mode 100644 index 00000000..b1c56fa2 --- /dev/null +++ b/internal/errcode/code_test.go @@ -0,0 +1,64 @@ +package errcode + +import ( + "errors" + "strings" + "testing" +) + +func TestRegistryUniqueAndPublic(t *testing.T) { + all := All() + if len(all) < 50 { + t.Fatalf("catalog too small: %d", len(all)) + } + seen := map[string]struct{}{} + for _, d := range all { + if _, ok := seen[d.ID]; ok { + t.Fatalf("duplicate %s", d.ID) + } + seen[d.ID] = struct{}{} + if d.Message == "" || d.Guide == "" || d.Class == "" { + t.Fatalf("incomplete %+v", d) + } + if Sanitize(d.Message) != d.Message || Sanitize(d.Guide) != d.Guide { + t.Fatalf("public text redacted: %+v", d) + } + if strings.Contains(d.Guide, "/home/") || strings.Contains(strings.ToLower(d.Message), "password") { + t.Fatalf("leak %+v", d) + } + } + if _, ok := Lookup("INTERNAL"); !ok { + t.Fatal("missing INTERNAL") + } + if got := FromStatus(404).ID; got != "RESOURCE_NOT_FOUND" { + t.Fatalf("404 -> %s", got) + } + if got := FromLegacy(400, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`).ID; got != "REQUEST_JSON_INVALID" { + t.Fatalf("json legacy -> %s", got) + } + if got := FromLegacy(400, "invalid request body: boom").ID; got != "REQUEST_BODY_INVALID" { + t.Fatalf("prefix -> %s", got) + } +} + +func TestFromErrorSentinelAndTyped(t *testing.T) { + sentinel := errors.New("errcode test sentinel") + Bind(sentinel, "REQUEST_INVALID") + def, ok := FromError(sentinel) + if !ok || def.ID != "REQUEST_INVALID" { + t.Fatalf("sentinel %+v ok=%v", def, ok) + } + wrapped := New("DOC_BUSY", errors.New("编辑中")) + def, ok = FromError(wrapped) + if !ok || def.ID != "DOC_BUSY" { + t.Fatalf("typed %+v", def) + } + if Sanitize("pq: relation users password /home/x") != "" { + t.Fatal("expected redact") + } +} + +func TestReportWithoutAuditor(t *testing.T) { + SetAuditor(nil) + Report(t.Context(), Event{Code: "INTERNAL", RequestID: "req_test", Upstream: "pq: secret"}) +} diff --git a/internal/errcode/codes_domain.go b/internal/errcode/codes_domain.go new file mode 100644 index 00000000..a867d755 --- /dev/null +++ b/internal/errcode/codes_domain.go @@ -0,0 +1,233 @@ +package errcode + +func init() { + rows := []Def{ + {ID: "REQUEST_INVALID", Status: 400, Message: "请求参数无效", Module: "server"}, + {ID: "REQUEST_JSON_INVALID", Status: 400, Message: "无效的 JSON 请求体", Module: "server"}, + {ID: "REQUEST_BODY_INVALID", Status: 400, Message: "无效的请求体", Module: "server"}, + {ID: "REQUEST_METHOD_NOT_ALLOWED", Status: 405, Message: "不支持的请求方法", Module: "server"}, + {ID: "AUTH_UNAUTHENTICATED", Status: 401, Message: "未登录或登录已过期", Module: "auth"}, + {ID: "AUTH_FORBIDDEN", Status: 403, Message: "无权访问", Module: "auth"}, + {ID: "AUTH_SESSION_EXPIRED", Status: 401, Message: "未登录或登录已过期", Module: "auth"}, + {ID: "AUTH_LOGIN_REQUIRED", Status: 401, Message: "请先登录", Module: "auth"}, + {ID: "AUTH_USERNAME_EXISTS", Status: 409, Message: "用户名已存在", Module: "auth"}, + {ID: "AUTH_BAD_CREDENTIALS", Status: 401, Message: "用户名或密码错误", Module: "auth"}, + {ID: "RESOURCE_NOT_FOUND", Status: 404, Message: "资源不存在", Module: "server"}, + {ID: "RESOURCE_CONFLICT", Status: 409, Message: "资源版本冲突", Module: "server", Class: ClassResumable}, + {ID: "VERSION_CONFLICT", Status: 409, Message: "资源版本冲突", Module: "server", Class: ClassResumable}, + {ID: "IDEMPOTENCY_CONFLICT", Status: 409, Message: "重复提交", Module: "server", Class: ClassResumable}, + {ID: "SERVICE_UNAVAILABLE", Status: 503, Message: "服务暂不可用", Module: "server", Class: ClassTransient}, + {ID: "INTERNAL", Status: 500, Message: "请求无法完成", Module: "server"}, + {ID: "INTERNAL_ERROR", Status: 500, Message: "系统内部错误,请重新开始会话", Module: "server", Actions: []string{"restart"}}, + {ID: "RATE_LIMITED", Status: 429, Message: "请求过于频繁,请稍后重试", Module: "server", Class: ClassTransient, Actions: []string{"retry"}}, + + {ID: "DOCUMENT_NOT_FOUND", Status: 404, Message: "文档不存在", Module: "docgov"}, + {ID: "DOCUMENT_LOCKED", Status: 409, Message: "文档已锁定", Module: "docgov", Class: ClassResumable}, + {ID: "DOC_BUSY", Status: 409, Message: "该文档正在编辑中,请等待现有任务完成", Module: "docgov", Class: ClassFatal, Actions: []string{}}, + {ID: "DOC_LEDGER_ERROR", Status: 500, Message: "文档职责账本写入失败,请重新开始会话", Module: "docgov", Actions: []string{"restart"}}, + {ID: "DOC_TASK_BUSY", Status: 409, Message: "文档任务正在执行,暂不能改派", Module: "projectinstance", Class: ClassResumable}, + {ID: "LEASE_NOT_HELD", Status: 409, Message: "当前没有文档编辑租约", Module: "docgov", Class: ClassResumable}, + {ID: "LEASE_EXPIRED", Status: 409, Message: "文档编辑租约已过期", Module: "docgov", Class: ClassResumable}, + {ID: "LEASE_STALE", Status: 409, Message: "文档编辑租约已失效", Module: "docgov", Class: ClassResumable}, + {ID: "FORCE_EDIT_NOT_ALLOWED", Status: 409, Message: "当前不允许强制直接编辑", Module: "docgov"}, + {ID: "PROPOSAL_NOT_FOUND", Status: 404, Message: "变更提案不存在", Module: "docgov"}, + {ID: "UNAUTHORIZED_ACTOR", Status: 403, Message: "当前身份不能执行该文档动作", Module: "docgov"}, + {ID: "AUTHOR_ONLY_EDIT", Status: 403, Message: "只有编制人可以修改文档内容", Module: "docgov"}, + {ID: "REVIEWER_CANNOT_EDIT", Status: 403, Message: "审核人不能修改文档内容", Module: "docgov"}, + {ID: "APPROVER_CANNOT_EDIT", Status: 403, Message: "批准人不能修改文档内容", Module: "docgov"}, + {ID: "INVALID_STATE_TRANSITION", Status: 409, Message: "文档状态不能这样流转", Module: "docgov"}, + {ID: "REJECT_REASON_REQUIRED", Status: 400, Message: "驳回必须填写原因", Module: "docgov"}, + {ID: "PROPOSAL_ALREADY_DECIDED", Status: 409, Message: "变更提案已裁决", Module: "docgov"}, + {ID: "CHANGE_PROPOSAL_PENDING", Status: 409, Message: "文档仍有待编制人确认的变更提案", Module: "docgov", Class: ClassResumable}, + {ID: "DOCUMENT_CONTENT_PROJECTION_MISSING", Status: 409, Message: "文档内容投影缺失", Module: "docgov"}, + {ID: "DOCUMENT_CONTENT_PROJECTION_EMPTY", Status: 409, Message: "文档内容投影为空", Module: "docgov"}, + {ID: "DOCUMENT_CONTENT_INTEGRITY_MISMATCH", Status: 409, Message: "文档内容校验不一致", Module: "docgov"}, + {ID: "DOCUMENT_CONTENT_PROJECTION_DRIFT", Status: 409, Message: "文档内容投影与账本不一致", Module: "docgov"}, + {ID: "DOCUMENT_CONTENT_ERROR", Status: 409, Message: "文档内容读取失败", Module: "docgov"}, + + {ID: "CHAT_TIMEOUT", Status: 504, Message: "生成回复超时,请稍后重试", Module: "stream", Class: ClassTransient, Actions: []string{"retry"}}, + {ID: "NETWORK_ERROR", Status: 502, Message: "网络连接异常,请稍后重试", Module: "stream", Class: ClassTransient, Actions: []string{"retry"}}, + {ID: "RUNNER_RESULT_REJECTED", Status: 502, Message: "助手执行结果校验失败,请重新开始任务", Module: "stream", Actions: []string{"restart"}}, + {ID: "TASK_INTERRUPTED", Status: 409, Message: "任务执行中断,可继续或重新开始", Module: "taskrun", Class: ClassResumable, Actions: []string{"continue", "restart"}}, + {ID: "TASK_SCOPE_UNRESOLVED", Status: 400, Message: "无法确定任务所属阶段", Module: "projectinstance"}, + + {ID: "FREEDOC_NOT_FOUND", Status: 404, Message: "自由文档不存在", Module: "freedoc"}, + {ID: "FREEDOC_FORBIDDEN", Status: 403, Message: "无权访问该自由文档", Module: "freedoc"}, + {ID: "FREEDOC_CONFLICT", Status: 409, Message: "自由文档版本冲突", Module: "freedoc", Class: ClassResumable}, + {ID: "FREEDOC_INVALID_PARAM", Status: 400, Message: "自由文档参数无效", Module: "freedoc"}, + {ID: "FREEDOC_NOT_EXPORTABLE", Status: 400, Message: "该消息不能导出为文档", Module: "freedoc"}, + + {ID: "SETTING_UNKNOWN", Status: 400, Message: "未知的设置键", Module: "sysadmin"}, + {ID: "SETTING_READ_ONLY", Status: 403, Message: "该设置为只读", Module: "sysadmin"}, + {ID: "SETTING_INVALID_VALUE", Status: 400, Message: "设置值类型不匹配", Module: "sysadmin"}, + {ID: "SETTING_STORE_UNAVAILABLE", Status: 503, Message: "设置存储不可用", Module: "sysadmin", Class: ClassTransient}, + {ID: "SETTING_REVISION_REQUIRED", Status: 400, Message: "该设置需要 expected_revision", Module: "sysadmin"}, + {ID: "SETTING_REVISION_CONFLICT", Status: 409, Message: "设置版本冲突,请刷新后重试", Module: "sysadmin", Class: ClassResumable}, + {ID: "SETTING_NOT_FOUND", Status: 404, Message: "设置不存在", Module: "sysadmin"}, + + {ID: "WORKFLOW_UNAUTHENTICATED", Status: 401, Message: "需要登录后操作工作流", Module: "projectworkflow"}, + {ID: "WORKFLOW_FORBIDDEN_NOT_CREATOR", Status: 403, Message: "仅项目创建人有权切换激活工作流", Module: "projectworkflow"}, + {ID: "PROJECT_NOT_FOUND", Status: 404, Message: "项目不存在", Module: "projectworkflow"}, + {ID: "WORKFLOW_NOT_FOUND", Status: 404, Message: "工作流定义不存在或未在项目启用", Module: "projectworkflow"}, + {ID: "WORKFLOW_CONCURRENT_CONFLICT", Status: 409, Message: "工作流选择版本落后或并发冲突,请刷新后重试", Module: "projectworkflow", Class: ClassResumable}, + {ID: "WORKFLOW_PREREQUISITE", Status: 400, Message: "上游文档尚未满足,不能继续", Module: "projectworkflow"}, + {ID: "WORKFLOW_INVALID_INPUT", Status: 400, Message: "工作流参数无效", Module: "projectworkflow"}, + {ID: "PROJECT_NAME_DUPLICATE", Status: 409, Message: "项目名称已存在", Module: "projectworkflow"}, + {ID: "WORKFLOW_LEGACY_SWITCH_BLOCKED", Status: 409, Message: "请通过项目设置完成工作流规划与激活", Module: "projectworkflow"}, + + {ID: "TASKRUN_NOT_RESUMABLE", Status: 409, Message: "该任务不能继续", Module: "taskrun"}, + {ID: "TASKRUN_ALREADY_ACTIVE", Status: 409, Message: "任务已在执行", Module: "taskrun", Class: ClassResumable}, + {ID: "TASKRUN_TERMINAL_CONFLICT", Status: 409, Message: "任务已结束,不能再次收口", Module: "taskrun"}, + {ID: "TASKRUN_CLEANUP_BLOCKED", Status: 409, Message: "任务清理尚未满足核对条件", Module: "taskrun"}, + {ID: "TASKRUN_UNAUTHORIZED_BACKEND", Status: 403, Message: "任务执行器未授权", Module: "taskrun"}, + {ID: "TASKRUN_SENSITIVE_PAYLOAD", Status: 400, Message: "任务输入包含不允许的内容", Module: "taskrun"}, + {ID: "TASKRUN_STORE_UNAVAILABLE", Status: 503, Message: "任务存储不可用", Module: "taskrun", Class: ClassTransient}, + {ID: "TASKRUN_IDEMPOTENCY_CONFLICT", Status: 409, Message: "重复的任务提交", Module: "taskrun", Class: ClassResumable}, + + {ID: "SESSION_OWNER_MISMATCH", Status: 403, Message: "无权访问该会话", Module: "ctxmgr"}, + {ID: "CHAT_TURN_NOT_FOUND", Status: 404, Message: "对话回合不存在", Module: "ctxmgr"}, + {ID: "IDEMPOTENCY_KEY_REUSED", Status: 409, Message: "重复提交的内容不一致", Module: "ctxmgr"}, + {ID: "CTXMGR_BACKGROUND", Status: 500, Message: "上下文后台任务失败", Module: "ctxmgr", Class: ClassTransient}, + + {ID: "MAINTENANCE_NOT_FOUND", Status: 404, Message: "维护窗口不存在", Module: "maintenance"}, + {ID: "MAINTENANCE_CONFLICT_OPEN", Status: 409, Message: "已有未结束的维护窗口", Module: "maintenance", Class: ClassResumable}, + {ID: "MAINTENANCE_INVALID_WINDOW", Status: 400, Message: "维护窗口参数无效", Module: "maintenance"}, + {ID: "MAINTENANCE_BAD_STATUS", Status: 409, Message: "当前状态不可取消", Module: "maintenance"}, + + {ID: "SWBOOT_MERGE_FORBIDDEN", Status: 403, Message: "受控执行不会合并、推送或部署", Module: "swboot"}, + {ID: "SWBOOT_REPO_NOT_ALLOWLISTED", Status: 403, Message: "仓库路径不在允许列表", Module: "swboot"}, + {ID: "SWBOOT_PATCH_DISABLED", Status: 403, Message: "受控补丁执行未开启", Module: "swboot"}, + {ID: "SWBOOT_PATCH_UNIMPLEMENTED", Status: 501, Message: "受控补丁执行尚未放量", Module: "swboot"}, + + {ID: "EVENTS_QUARANTINE", Status: 500, Message: "事件已隔离,不会自动重试", Module: "events"}, + {ID: "EVENTS_DEAD", Status: 500, Message: "事件投递已进入死信", Module: "events"}, + {ID: "EVENTS_AUTH_FAILURE", Status: 403, Message: "事件未通过授权", Module: "events"}, + {ID: "EVENTS_INTEGRITY", Status: 500, Message: "事件完整性校验失败", Module: "events"}, + {ID: "EVENTS_STALE_FENCING", Status: 409, Message: "事件租约令牌已过期", Module: "events", Class: ClassResumable}, + + {ID: "REMINDER_SCAN_FAILED", Status: 500, Message: "阶段提醒扫描失败", Module: "notification", Class: ClassTransient}, + {ID: "INSTANCE_NOT_AVAILABLE", Status: 503, Message: "项目实例服务不可用", Module: "projectinstance", Class: ClassTransient}, + {ID: "INSTANCE_FORBIDDEN_NOT_CREATOR", Status: 403, Message: "仅项目创建人可配置工作流", Module: "projectinstance"}, + {ID: "INSTANCE_PLAN_FROZEN", Status: 409, Message: "工作流已激活,规划已冻结", Module: "projectinstance"}, + {ID: "INSTANCE_SWITCH_FROZEN", Status: 409, Message: "运行中不能切换工作流,只能调整职责、审批和文档启用", Module: "projectinstance"}, + {ID: "INSTANCE_ALREADY_ACTIVE", Status: 409, Message: "项目已有运行中的工作流实例", Module: "projectinstance", Class: ClassResumable}, + {ID: "INSTANCE_PLAN_RECONFIRM", Status: 409, Message: "规划已变更,请重新预览并确认", Module: "projectinstance", Class: ClassResumable}, + {ID: "INSTANCE_VALIDATION_FAILED", Status: 400, Message: "工作流配置校验未通过", Module: "projectinstance"}, + {ID: "INSTANCE_ACTIVATION_INVALID", Status: 400, Message: "激活令牌无效或已过期", Module: "projectinstance"}, + {ID: "INSTANCE_CONFLICT", Status: 409, Message: "项目版本冲突,请刷新后重试", Module: "projectinstance", Class: ClassResumable}, + {ID: "INSTANCE_PLAN_CONFLICT", Status: 409, Message: "规划版本冲突,请刷新后重试", Module: "projectinstance", Class: ClassResumable}, + {ID: "INSTANCE_NOT_FOUND", Status: 404, Message: "工作流实例不存在", Module: "projectinstance"}, + {ID: "INSTANCE_STAGE_GATE", Status: 400, Message: "阶段门禁未满足,必需文档尚未全部批准", Module: "projectinstance"}, + {ID: "INSTANCE_STAGE_NOT_ACTIVE", Status: 409, Message: "目标阶段不是当前活动阶段", Module: "projectinstance"}, + {ID: "INSTANCE_STAGE_NOT_COMPLETED", Status: 400, Message: "只能再打开已完成的阶段", Module: "projectinstance"}, + {ID: "INSTANCE_RECHECK_REQUIRED", Status: 409, Message: "该阶段有待复核标记,推进前须确认已知悉", Module: "projectinstance"}, + {ID: "INSTANCE_INVALID_STAGE", Status: 400, Message: "无效的阶段", Module: "projectinstance"}, + {ID: "INSTANCE_REVISION_CONFLICT", Status: 409, Message: "实例版本冲突,请刷新后重试", Module: "projectinstance", Class: ClassResumable}, + {ID: "INSTANCE_TERMINATION_DENIED", Status: 409, Message: "当前状态不允许终止", Module: "projectinstance"}, + {ID: "INSTANCE_AUTHOR_ONLY_DISABLED", Status: 403, Message: "系统未授权编制人兼任审核批准", Module: "projectinstance"}, + {ID: "INSTANCE_AUTHOR_ONLY_UNAVAILABLE", Status: 503, Message: "兼任总授权存储不可用", Module: "projectinstance", Class: ClassTransient}, + {ID: "INSTANCE_AUTHOR_ONLY_CHANGED", Status: 409, Message: "兼任总授权已变更,请重新预览并确认激活", Module: "projectinstance", Class: ClassResumable}, + {ID: "INSTANCE_DUTY_FORBIDDEN", Status: 403, Message: "无权改派文档职责", Module: "projectinstance"}, + {ID: "INSTANCE_DOC_TASK_NOT_FOUND", Status: 404, Message: "文档任务不存在或已禁用", Module: "projectinstance"}, + {ID: "INSTANCE_LEGACY_SWITCH_BLOCKED", Status: 409, Message: "新项目请通过项目设置完成工作流激活", Module: "projectinstance"}, + {ID: "TASKRUN_BACKGROUND", Status: 500, Message: "任务后台处理失败", Module: "taskrun", Class: ClassTransient}, + } + for _, d := range rows { + if d.Actions != nil && len(d.Actions) == 0 { + d.Actions = []string{} + } + register(d) + } + + aliases := map[string]string{ + "无效的 JSON 请求体": "REQUEST_JSON_INVALID", + "无效的 JSON": "REQUEST_JSON_INVALID", + "invalid json": "REQUEST_JSON_INVALID", + "无效的请求体": "REQUEST_BODY_INVALID", + "invalid request body": "REQUEST_BODY_INVALID", + "invalid body": "REQUEST_BODY_INVALID", + "不支持的请求方法": "REQUEST_METHOD_NOT_ALLOWED", + "不支持的方法": "REQUEST_METHOD_NOT_ALLOWED", + "method not allowed": "REQUEST_METHOD_NOT_ALLOWED", + "Method Not Allowed": "REQUEST_METHOD_NOT_ALLOWED", + "未登录或登录已过期": "AUTH_SESSION_EXPIRED", + "请先登录": "AUTH_LOGIN_REQUIRED", + "用户名或密码错误": "AUTH_BAD_CREDENTIALS", + "用户名已存在": "AUTH_USERNAME_EXISTS", + "未知的设置键": "SETTING_UNKNOWN", + "该设置为只读": "SETTING_READ_ONLY", + "设置值类型不匹配": "SETTING_INVALID_VALUE", + "设置存储不可用": "SETTING_STORE_UNAVAILABLE", + "该设置需要 expected_revision": "SETTING_REVISION_REQUIRED", + "设置版本冲突,请刷新后重试": "SETTING_REVISION_CONFLICT", + "已有未结束的维护窗口": "MAINTENANCE_CONFLICT_OPEN", + "当前状态不可取消": "MAINTENANCE_BAD_STATUS", + "无权改派文档职责": "INSTANCE_DUTY_FORBIDDEN", + "项目实例服务不可用": "INSTANCE_NOT_AVAILABLE", + "项目实例服务不可用,请先执行数据库迁移": "INSTANCE_NOT_AVAILABLE", + "仅项目创建人可配置工作流": "INSTANCE_FORBIDDEN_NOT_CREATOR", + "仅项目创建人有权切换激活工作流": "WORKFLOW_FORBIDDEN_NOT_CREATOR", + "工作流已激活,规划已冻结": "INSTANCE_PLAN_FROZEN", + "运行中不能切换工作流,只能调整职责、审批和文档启用": "INSTANCE_SWITCH_FROZEN", + "项目已有运行中的工作流实例": "INSTANCE_ALREADY_ACTIVE", + "项目已有运行中的工作流实例,请使用项目设置查看流程": "INSTANCE_ALREADY_ACTIVE", + "规划已变更,请重新预览并确认": "INSTANCE_PLAN_RECONFIRM", + "工作流配置校验未通过": "INSTANCE_VALIDATION_FAILED", + "激活令牌无效或已过期": "INSTANCE_ACTIVATION_INVALID", + "项目版本冲突,请刷新后重试": "INSTANCE_CONFLICT", + "工作流选择版本落后或并发冲突,请刷新后重试": "WORKFLOW_CONCURRENT_CONFLICT", + "规划版本冲突,请刷新后重试": "INSTANCE_PLAN_CONFLICT", + "工作流实例不存在": "INSTANCE_NOT_FOUND", + "阶段门禁未满足,必需文档尚未全部批准": "INSTANCE_STAGE_GATE", + "目标阶段不是当前活动阶段": "INSTANCE_STAGE_NOT_ACTIVE", + "只能再打开已完成的阶段": "INSTANCE_STAGE_NOT_COMPLETED", + "该阶段有待复核标记,推进前须确认已知悉": "INSTANCE_RECHECK_REQUIRED", + "无效的阶段": "INSTANCE_INVALID_STAGE", + "实例版本冲突,请刷新后重试": "INSTANCE_REVISION_CONFLICT", + "系统未授权编制人兼任审核批准": "INSTANCE_AUTHOR_ONLY_DISABLED", + "兼任总授权已变更,请重新预览并确认激活": "INSTANCE_AUTHOR_ONLY_CHANGED", + "兼任总授权存储不可用": "INSTANCE_AUTHOR_ONLY_UNAVAILABLE", + "文档任务不存在或已禁用": "INSTANCE_DOC_TASK_NOT_FOUND", + "新项目请通过项目设置完成工作流激活": "INSTANCE_LEGACY_SWITCH_BLOCKED", + "请通过项目设置完成工作流规划与激活": "WORKFLOW_LEGACY_SWITCH_BLOCKED", + "工作流定义不存在或未在项目启用": "WORKFLOW_NOT_FOUND", + "项目不存在": "PROJECT_NOT_FOUND", + "项目不存在或无权限访问": "PROJECT_NOT_FOUND", + "document not found": "DOCUMENT_NOT_FOUND", + "proposal not found": "PROPOSAL_NOT_FOUND", + "ERR_INVALID_PARAM": "REQUEST_INVALID", + "ERR_UNAUTHENTICATED": "AUTH_LOGIN_REQUIRED", + "ERR_NOT_FOUND": "RESOURCE_NOT_FOUND", + "ERR_FORBIDDEN": "AUTH_FORBIDDEN", + "ERR_NOT_AVAILABLE": "SERVICE_UNAVAILABLE", + "ERR_TERMINATION_NOT_ALLOWED": "INSTANCE_TERMINATION_DENIED", + "会话不存在或已失效": "CHAT_TURN_NOT_FOUND", + "无效的会话 ID": "REQUEST_INVALID", + "context manager not initialized": "SERVICE_UNAVAILABLE", + "context manager unavailable": "SERVICE_UNAVAILABLE", + "docgov engine not initialized": "SERVICE_UNAVAILABLE", + "freeform doc service not initialized": "SERVICE_UNAVAILABLE", + "sysadmin service unavailable": "SETTING_STORE_UNAVAILABLE", + "maintenance service unavailable": "SERVICE_UNAVAILABLE", + "storage unavailable": "SERVICE_UNAVAILABLE", + "notification service unavailable": "SERVICE_UNAVAILABLE", + "workspace registry unavailable": "SERVICE_UNAVAILABLE", + "workspace resolver unavailable": "SERVICE_UNAVAILABLE", + "parse engine unavailable": "SERVICE_UNAVAILABLE", + "supplement impact service unavailable": "SERVICE_UNAVAILABLE", + "成员已保存,但受控文档职责同步失败": "INTERNAL", + "无权管理项目成员": "AUTH_FORBIDDEN", + "not a project member": "AUTH_FORBIDDEN", + } + for msg, id := range aliases { + registerLegacy(msg, id) + } + for _, p := range []prefixRule{ + {"invalid request body:", "REQUEST_BODY_INVALID"}, + {"invalid request json body:", "REQUEST_JSON_INVALID"}, + {"invalid multipart form:", "REQUEST_BODY_INVALID"}, + {"invalid request body: ", "REQUEST_BODY_INVALID"}, + } { + registerPrefix(p.prefix, p.id) + } +} diff --git a/internal/errcode/legacy_extra.go b/internal/errcode/legacy_extra.go new file mode 100644 index 00000000..70375fd4 --- /dev/null +++ b/internal/errcode/legacy_extra.go @@ -0,0 +1,165 @@ +package errcode + +func init() { + register(Def{ID: "ASSET_NOT_FOUND", Status: 404, Message: "asset not found", Class: ClassFatal, Module: "server"}) + register(Def{ID: "ASSET_KEY_AND_FILE_REQUIRED", Status: 400, Message: "asset_key and file required", Class: ClassFatal, Module: "server"}) + register(Def{ID: "BATCH_NOT_FOUND", Status: 404, Message: "batch not found", Class: ClassFatal, Module: "server"}) + register(Def{ID: "CANNOT_STORE_EMPTY_FILE", Status: 400, Message: "cannot store empty file", Class: ClassFatal, Module: "server"}) + register(Def{ID: "CHAT_MESSAGES_IMPORT_ENDPOINT_IS_ONLY_AVAILABLE_IN_DEV_D", Status: 400, Message: "chat messages import endpoint is only available in dev/demo environment", Class: ClassFatal, Module: "server"}) + register(Def{ID: "CLIENT_TURN_ID_AND_SESSION_ID_REQUIRED", Status: 400, Message: "client_turn_id and session_id required", Class: ClassFatal, Module: "server"}) + register(Def{ID: "DECISION_NONCE_AND_DECISION_DIGEST_REQUIRED", Status: 400, Message: "decision_nonce and decision_digest required", Class: ClassFatal, Module: "server"}) + register(Def{ID: "DOC_ID_CANNOT_BE_EMPTY", Status: 400, Message: "doc_id cannot be empty", Class: ClassFatal, Module: "server"}) + register(Def{ID: "DOC_ID_REQUIRED", Status: 400, Message: "doc_id required", Class: ClassFatal, Module: "server"}) + register(Def{ID: "FILE_REQUIRED", Status: 400, Message: "file required", Class: ClassFatal, Module: "server"}) + register(Def{ID: "FILE_TYPE_NOT_SUPPORTED_BY_PARSER", Status: 400, Message: "file type not supported by parser", Class: ClassFatal, Module: "server"}) + register(Def{ID: "INVALID_ACTION_MUST_BE_APPROVE_OR_REJECT", Status: 400, Message: "invalid action: must be approve or reject", Class: ClassFatal, Module: "server"}) + register(Def{ID: "INVALID_ASSET_ID", Status: 400, Message: "invalid asset id", Class: ClassFatal, Module: "server"}) + register(Def{ID: "INVALID_ASSET_KEY", Status: 400, Message: "invalid asset key", Class: ClassFatal, Module: "server"}) + register(Def{ID: "INVALID_BATCH_ID", Status: 400, Message: "invalid batch id", Class: ClassFatal, Module: "server"}) + register(Def{ID: "INVALID_COMMENT_ID", Status: 400, Message: "invalid comment id", Class: ClassFatal, Module: "server"}) + register(Def{ID: "INVALID_EDGE_ID", Status: 400, Message: "invalid edge id", Class: ClassFatal, Module: "server"}) + register(Def{ID: "INVALID_INSTANCE_ID", Status: 400, Message: "invalid instance id", Class: ClassFatal, Module: "server"}) + register(Def{ID: "INVALID_MARK_ID", Status: 400, Message: "invalid mark id", Class: ClassFatal, Module: "server"}) + register(Def{ID: "INVALID_MULTIPART", Status: 400, Message: "invalid multipart", Class: ClassFatal, Module: "server"}) + register(Def{ID: "INVALID_PROJECT_ID", Status: 400, Message: "invalid project id", Class: ClassFatal, Module: "server"}) + register(Def{ID: "INVALID_REPO_PATH", Status: 400, Message: "invalid repo_path", Class: ClassFatal, Module: "server"}) + register(Def{ID: "INVALID_REQUEST_ID", Status: 400, Message: "invalid request id", Class: ClassFatal, Module: "server"}) + register(Def{ID: "INVALID_TASK_KEY", Status: 400, Message: "invalid task key", Class: ClassFatal, Module: "server"}) + register(Def{ID: "INVALID_VERSION", Status: 409, Message: "invalid version", Class: ClassResumable, Module: "server"}) + register(Def{ID: "INVALID_WORKFLOW_ID", Status: 400, Message: "invalid workflow id", Class: ClassFatal, Module: "server"}) + register(Def{ID: "INVALID_WORKSPACE_PATH", Status: 400, Message: "invalid workspace path", Class: ClassFatal, Module: "server"}) + register(Def{ID: "KIND_MUST_BE_CONFIRM_OR_CANCEL", Status: 400, Message: "kind must be confirm or cancel", Class: ClassFatal, Module: "server"}) + register(Def{ID: "LIST_EXECUTIONS_FAILED", Status: 400, Message: "list executions failed", Class: ClassFatal, Module: "server"}) + register(Def{ID: "MESSAGE_CANNOT_BE_EMPTY", Status: 400, Message: "message cannot be empty", Class: ClassFatal, Module: "server"}) + register(Def{ID: "MESSAGE_MUST_BE_SENT_VIA_POST_JSON_BODY_NOT_GET_QUERY_T2", Status: 400, Message: "message must be sent via POST JSON body, not GET query (T20-B11)", Class: ClassFatal, Module: "server"}) + register(Def{ID: "MISSING_DATE_DAY_KEY_OR_ANCHOR_ID_QUERY_PARAM", Status: 400, Message: "missing date/day_key or anchor_id query param", Class: ClassFatal, Module: "server"}) + register(Def{ID: "MISSING_DOC_ID_QUERY_PARAM", Status: 400, Message: "missing doc_id query param", Class: ClassFatal, Module: "server"}) + register(Def{ID: "MISSING_OR_EMPTY_QUERY_PARAM_Q", Status: 400, Message: "missing or empty query param 'q'", Class: ClassFatal, Module: "server"}) + register(Def{ID: "MISSING_OR_INVALID_PROJECT_ID_QUERY_PARAM", Status: 400, Message: "missing or invalid project_id query param", Class: ClassFatal, Module: "server"}) + register(Def{ID: "MISSING_QUERY_PARAM", Status: 400, Message: "missing query param", Class: ClassFatal, Module: "server"}) + register(Def{ID: "MISSING_REQUIRED_FIELDS_SESSION_ID_OR_DAY_KEY", Status: 400, Message: "missing required fields 'session_id' or 'day_key'", Class: ClassFatal, Module: "server"}) + register(Def{ID: "MISSING_REQUIRED_QUERY_PARAM_SESSION_ID", Status: 400, Message: "missing required query param 'session_id'", Class: ClassFatal, Module: "server"}) + register(Def{ID: "MISSING_SESSION_ID", Status: 400, Message: "missing session_id", Class: ClassFatal, Module: "server"}) + register(Def{ID: "MISSING_SESSION_ID_QUERY_PARAM", Status: 400, Message: "missing session_id query param", Class: ClassFatal, Module: "server"}) + register(Def{ID: "PARSE_IS_IN_PROGRESS_OR_ALREADY_COMPLETE", Status: 409, Message: "parse is in progress or already complete", Class: ClassResumable, Module: "server"}) + register(Def{ID: "PROJECT_ROLE_REQUIRED_WHEN_MEMBER_DOES_NOT_EXIST", Status: 400, Message: "project_role required when member does not exist", Class: ClassFatal, Module: "server"}) + register(Def{ID: "PROJECT_ROLE_REQUIRED_WITH_DOCUMENT_DUTY", Status: 400, Message: "project_role required with document_duty", Class: ClassFatal, Module: "server"}) + register(Def{ID: "PROPOSAL_ID_CANNOT_BE_EMPTY", Status: 400, Message: "proposal_id cannot be empty", Class: ClassFatal, Module: "server"}) + register(Def{ID: "PROPOSED_CHANGES_CANNOT_BE_EMPTY", Status: 400, Message: "proposed_changes cannot be empty", Class: ClassFatal, Module: "server"}) + register(Def{ID: "READ_FILE_FAILED", Status: 400, Message: "read file failed", Class: ClassFatal, Module: "server"}) + register(Def{ID: "REPO_PATH_REQUIRED", Status: 400, Message: "repo_path required", Class: ClassFatal, Module: "server"}) + register(Def{ID: "RUN_NOT_FOUND", Status: 404, Message: "run not found", Class: ClassFatal, Module: "server"}) + register(Def{ID: "RUN_ID_REQUIRED", Status: 400, Message: "run_id required", Class: ClassFatal, Module: "server"}) + register(Def{ID: "SAVE_BINDING_FAILED", Status: 400, Message: "save binding failed", Class: ClassFatal, Module: "server"}) + register(Def{ID: "SESSION_OR_PROJECT_CONTEXT_NOT_FOUND", Status: 404, Message: "session or project context not found", Class: ClassFatal, Module: "server"}) + register(Def{ID: "STREAMING_UNSUPPORTED", Status: 400, Message: "streaming unsupported", Class: ClassFatal, Module: "server"}) + register(Def{ID: "STREAMING_UNSUPPORTED_BY_UNDERLYING_CONNECTION", Status: 400, Message: "streaming unsupported by underlying connection", Class: ClassFatal, Module: "server"}) + register(Def{ID: "TURN_NOT_FOUND", Status: 404, Message: "turn not found", Class: ClassFatal, Module: "server"}) + register(Def{ID: "UNKNOWN_WORKFLOW", Status: 400, Message: "unknown workflow", Class: ClassFatal, Module: "server"}) + register(Def{ID: "UNSUPPORTED_CATEGORY", Status: 400, Message: "unsupported category", Class: ClassFatal, Module: "server"}) + register(Def{ID: "UPLOAD_TOO_LARGE", Status: 400, Message: "upload too large", Class: ClassFatal, Module: "server"}) + register(Def{ID: "USER_ID_AND_PROJECT_ROLE_REQUIRED", Status: 400, Message: "user_id and project_role required", Class: ClassFatal, Module: "server"}) + register(Def{ID: "USER_ID_QUERY_REQUIRED", Status: 400, Message: "user_id query required", Class: ClassFatal, Module: "server"}) + register(Def{ID: "USER_ID_REQUIRED", Status: 400, Message: "user_id required", Class: ClassFatal, Module: "server"}) + register(Def{ID: "WORKFLOW_CATALOG_UNAVAILABLE", Status: 503, Message: "workflow catalog unavailable", Class: ClassTransient, Module: "server"}) + register(Def{ID: "WORKFLOW_KEY_IS_REQUIRED", Status: 400, Message: "workflow_key is required", Class: ClassFatal, Module: "server"}) + register(Def{ID: "WORKSPACE_NOT_AVAILABLE_FOR_RUN", Status: 503, Message: "workspace not available for run", Class: ClassTransient, Module: "server"}) + register(Def{ID: "MSG_", Status: 400, Message: "仅原批准人或(批准人缺位时)项目创建人可批准再打开", Class: ClassFatal, Module: "server"}) + register(Def{ID: "MSG__2", Status: 400, Message: "只有该阶段文档职责人或创建人可以申请再打开", Class: ClassFatal, Module: "server"}) + register(Def{ID: "MSG__3", Status: 400, Message: "姓名不能为空", Class: ClassFatal, Module: "server"}) + register(Def{ID: "TASK_KEY", Status: 400, Message: "客户端不可覆盖 task_key", Class: ClassFatal, Module: "server"}) + register(Def{ID: "IAM", Status: 400, Message: "工作流定义服务需要数据库 IAM", Class: ClassFatal, Module: "server"}) + register(Def{ID: "MSG__4", Status: 400, Message: "指定的角色不存在或已停用", Class: ClassFatal, Module: "server"}) + register(Def{ID: "ID", Status: 400, Message: "无效的 id", Class: ClassFatal, Module: "server"}) + register(Def{ID: "LIMIT", Status: 400, Message: "无效的 limit", Class: ClassFatal, Module: "server"}) + register(Def{ID: "ID_2", Status: 400, Message: "无效的用户ID", Class: ClassFatal, Module: "server"}) + register(Def{ID: "ID_3", Status: 400, Message: "无效的角色 ID", Class: ClassFatal, Module: "server"}) + register(Def{ID: "MSG__5", Status: 404, Message: "未找到该项目的打开标记", Class: ClassFatal, Module: "server"}) + register(Def{ID: "MSG__6", Status: 400, Message: "申请不属于该项目", Class: ClassFatal, Module: "server"}) + register(Def{ID: "MSG__7", Status: 400, Message: "看不见下游文档,不能裁决", Class: ClassFatal, Module: "server"}) + register(Def{ID: "MSG__8", Status: 400, Message: "至少提供一个可更新字段", Class: ClassFatal, Module: "server"}) + register(Def{ID: "MSG__9", Status: 400, Message: "追踪索引不可用", Class: ClassFatal, Module: "server"}) + extras := map[string]string{ + "asset not found": "ASSET_NOT_FOUND", + "asset_key and file required": "ASSET_KEY_AND_FILE_REQUIRED", + "batch not found": "BATCH_NOT_FOUND", + "cannot store empty file": "CANNOT_STORE_EMPTY_FILE", + "chat messages import endpoint is only available in dev/demo environment": "CHAT_MESSAGES_IMPORT_ENDPOINT_IS_ONLY_AVAILABLE_IN_DEV_D", + "client_turn_id and session_id required": "CLIENT_TURN_ID_AND_SESSION_ID_REQUIRED", + "decision_nonce and decision_digest required": "DECISION_NONCE_AND_DECISION_DIGEST_REQUIRED", + "doc_id cannot be empty": "DOC_ID_CANNOT_BE_EMPTY", + "doc_id required": "DOC_ID_REQUIRED", + "file required": "FILE_REQUIRED", + "file type not supported by parser": "FILE_TYPE_NOT_SUPPORTED_BY_PARSER", + "invalid action: must be approve or reject": "INVALID_ACTION_MUST_BE_APPROVE_OR_REJECT", + "invalid asset id": "INVALID_ASSET_ID", + "invalid asset key": "INVALID_ASSET_KEY", + "invalid batch id": "INVALID_BATCH_ID", + "invalid comment id": "INVALID_COMMENT_ID", + "invalid edge id": "INVALID_EDGE_ID", + "invalid instance id": "INVALID_INSTANCE_ID", + "invalid mark id": "INVALID_MARK_ID", + "invalid multipart": "INVALID_MULTIPART", + "invalid project id": "INVALID_PROJECT_ID", + "invalid repo_path": "INVALID_REPO_PATH", + "invalid request id": "INVALID_REQUEST_ID", + "invalid task key": "INVALID_TASK_KEY", + "invalid version": "INVALID_VERSION", + "invalid workflow id": "INVALID_WORKFLOW_ID", + "invalid workspace path": "INVALID_WORKSPACE_PATH", + "kind must be confirm or cancel": "KIND_MUST_BE_CONFIRM_OR_CANCEL", + "list executions failed": "LIST_EXECUTIONS_FAILED", + "message cannot be empty": "MESSAGE_CANNOT_BE_EMPTY", + "message must be sent via POST JSON body, not GET query (T20-B11)": "MESSAGE_MUST_BE_SENT_VIA_POST_JSON_BODY_NOT_GET_QUERY_T2", + "missing date/day_key or anchor_id query param": "MISSING_DATE_DAY_KEY_OR_ANCHOR_ID_QUERY_PARAM", + "missing doc_id query param": "MISSING_DOC_ID_QUERY_PARAM", + "missing or empty query param 'q'": "MISSING_OR_EMPTY_QUERY_PARAM_Q", + "missing or invalid project_id query param": "MISSING_OR_INVALID_PROJECT_ID_QUERY_PARAM", + "missing query param": "MISSING_QUERY_PARAM", + "missing required fields 'session_id' or 'day_key'": "MISSING_REQUIRED_FIELDS_SESSION_ID_OR_DAY_KEY", + "missing required query param 'session_id'": "MISSING_REQUIRED_QUERY_PARAM_SESSION_ID", + "missing session_id": "MISSING_SESSION_ID", + "missing session_id query param": "MISSING_SESSION_ID_QUERY_PARAM", + "parse is in progress or already complete": "PARSE_IS_IN_PROGRESS_OR_ALREADY_COMPLETE", + "project_role required when member does not exist": "PROJECT_ROLE_REQUIRED_WHEN_MEMBER_DOES_NOT_EXIST", + "project_role required with document_duty": "PROJECT_ROLE_REQUIRED_WITH_DOCUMENT_DUTY", + "proposal_id cannot be empty": "PROPOSAL_ID_CANNOT_BE_EMPTY", + "proposed_changes cannot be empty": "PROPOSED_CHANGES_CANNOT_BE_EMPTY", + "read file failed": "READ_FILE_FAILED", + "repo_path required": "REPO_PATH_REQUIRED", + "run not found": "RUN_NOT_FOUND", + "run_id required": "RUN_ID_REQUIRED", + "save binding failed": "SAVE_BINDING_FAILED", + "session or project context not found": "SESSION_OR_PROJECT_CONTEXT_NOT_FOUND", + "streaming unsupported": "STREAMING_UNSUPPORTED", + "streaming unsupported by underlying connection": "STREAMING_UNSUPPORTED_BY_UNDERLYING_CONNECTION", + "turn not found": "TURN_NOT_FOUND", + "unknown workflow": "UNKNOWN_WORKFLOW", + "unsupported category": "UNSUPPORTED_CATEGORY", + "upload too large": "UPLOAD_TOO_LARGE", + "user_id and project_role required": "USER_ID_AND_PROJECT_ROLE_REQUIRED", + "user_id query required": "USER_ID_QUERY_REQUIRED", + "user_id required": "USER_ID_REQUIRED", + "workflow catalog unavailable": "WORKFLOW_CATALOG_UNAVAILABLE", + "workflow_key is required": "WORKFLOW_KEY_IS_REQUIRED", + "workspace not available for run": "WORKSPACE_NOT_AVAILABLE_FOR_RUN", + "仅原批准人或(批准人缺位时)项目创建人可批准再打开": "MSG_", + "只有该阶段文档职责人或创建人可以申请再打开": "MSG__2", + "姓名不能为空": "MSG__3", + "客户端不可覆盖 task_key": "TASK_KEY", + "工作流定义服务需要数据库 IAM": "IAM", + "指定的角色不存在或已停用": "MSG__4", + "无效的 id": "ID", + "无效的 limit": "LIMIT", + "无效的用户ID": "ID_2", + "无效的角色 ID": "ID_3", + "未找到该项目的打开标记": "MSG__5", + "申请不属于该项目": "MSG__6", + "看不见下游文档,不能裁决": "MSG__7", + "至少提供一个可更新字段": "MSG__8", + "追踪索引不可用": "MSG__9", + } + for msg, id := range extras { + registerLegacy(msg, id) + } +} diff --git a/internal/errorevents/store.go b/internal/errorevents/store.go new file mode 100644 index 00000000..e9a4bc95 --- /dev/null +++ b/internal/errorevents/store.go @@ -0,0 +1,151 @@ +package errorevents + +import ( + "context" + "fmt" + "strings" + "time" + + "chat-agent-system/internal/errcode" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Store 是 error_events 的唯一写者。 +type Store struct { + pool *pgxpool.Pool +} + +func NewStore(pool *pgxpool.Pool) *Store { + if pool == nil { + return nil + } + return &Store{pool: pool} +} + +var active *Store + +// Install 把存储注册为进程级错误审计写入器。 +func Install(store *Store) { + active = store + if store == nil { + errcode.SetAuditor(nil) + return + } + errcode.SetAuditor(store) +} + +// Active 返回当前已安装的存储,供管理台查询。 +func Active() *Store { return active } + +func (s *Store) Record(ctx context.Context, ev errcode.Event) error { + if s == nil || s.pool == nil { + return fmt.Errorf("errorevents: store unavailable") + } + var actor any + if ev.ActorUserID > 0 { + actor = ev.ActorUserID + } + _, err := s.pool.Exec(ctx, ` + INSERT INTO error_events ( + code, class, http_status, request_id, session_id, workspace_id, + module, phase_id, path, method, source, upstream, actor_user_id, + retryable, user_message + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15 + )`, + ev.Code, emptyClass(ev.Class), zeroNil(ev.HTTPStatus), ev.RequestID, ev.SessionID, ev.WorkspaceID, + ev.Module, ev.PhaseID, ev.Path, ev.Method, emptySource(ev.Source), ev.Upstream, actor, + ev.Retryable, ev.UserMessage, + ) + return err +} + +// Filter 是管理台查询条件。 +type Filter struct { + Code string + RequestID string + Class string + Since time.Time + Until time.Time + Limit int +} + +func (s *Store) List(ctx context.Context, f Filter) ([]errcode.Event, error) { + if s == nil || s.pool == nil { + return nil, fmt.Errorf("errorevents: store unavailable") + } + limit := f.Limit + if limit <= 0 || limit > 200 { + limit = 50 + } + q := ` + SELECT id, code, class, COALESCE(http_status,0), COALESCE(request_id,''), + COALESCE(session_id,''), COALESCE(workspace_id,''), COALESCE(module,''), + COALESCE(phase_id,''), COALESCE(path,''), COALESCE(method,''), + COALESCE(source,'http'), COALESCE(upstream,''), COALESCE(actor_user_id,0), + retryable, COALESCE(user_message,''), created_at + FROM error_events WHERE 1=1` + args := make([]any, 0, 6) + add := func(clause string, val any) { + args = append(args, val) + q += fmt.Sprintf(clause, len(args)) + } + if v := strings.TrimSpace(f.Code); v != "" { + add(" AND code=$%d", v) + } + if v := strings.TrimSpace(f.RequestID); v != "" { + add(" AND request_id=$%d", v) + } + if v := strings.TrimSpace(f.Class); v != "" { + add(" AND class=$%d", v) + } + if !f.Since.IsZero() { + add(" AND created_at>=$%d", f.Since) + } + if !f.Until.IsZero() { + add(" AND created_at<=$%d", f.Until) + } + args = append(args, limit) + q += fmt.Sprintf(" ORDER BY created_at DESC, id DESC LIMIT $%d", len(args)) + rows, err := s.pool.Query(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []errcode.Event{} + for rows.Next() { + var ev errcode.Event + if err := rows.Scan( + &ev.ID, &ev.Code, &ev.Class, &ev.HTTPStatus, &ev.RequestID, + &ev.SessionID, &ev.WorkspaceID, &ev.Module, &ev.PhaseID, &ev.Path, + &ev.Method, &ev.Source, &ev.Upstream, &ev.ActorUserID, &ev.Retryable, + &ev.UserMessage, &ev.CreatedAt, + ); err != nil { + return nil, err + } + out = append(out, ev) + } + return out, rows.Err() +} + +func emptyClass(v string) string { + if v == "" { + return errcode.ClassFatal + } + return v +} + +func emptySource(v string) string { + if v == "" { + return "http" + } + return v +} + +func zeroNil(n int) any { + if n == 0 { + return nil + } + return n +} diff --git a/internal/errorevents/store_pg_test.go b/internal/errorevents/store_pg_test.go new file mode 100644 index 00000000..0e7e109d --- /dev/null +++ b/internal/errorevents/store_pg_test.go @@ -0,0 +1,52 @@ +package errorevents + +import ( + "context" + "testing" + "time" + + "chat-agent-system/internal/db" + "chat-agent-system/internal/errcode" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestStoreRoundTrip(t *testing.T) { + dsn := db.SkipIfNoTestDatabaseURL(t) + db.RefuseSharedEnvDSN(t, dsn) + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer pool.Close() + if err := db.RunMigrationsOnceForTest(ctx, pool, dsn); err != nil { + t.Fatalf("migrate: %v", err) + } + store := NewStore(pool) + rid := "req_error_events_" + time.Now().UTC().Format("150405.000") + ev := errcode.Event{ + Code: "REQUEST_JSON_INVALID", + Class: errcode.ClassFatal, + HTTPStatus: 400, + RequestID: rid, + Module: "server", + Path: "/api/v1/auth/login", + Method: "POST", + Source: "http", + UserMessage: "无效的 JSON 请求体", + } + if err := store.Record(ctx, ev); err != nil { + t.Fatalf("record: %v", err) + } + rows, err := store.List(ctx, Filter{RequestID: rid, Limit: 10}) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(rows) != 1 || rows[0].Code != ev.Code || rows[0].RequestID != rid { + t.Fatalf("rows=%+v", rows) + } + if rows[0].Upstream != "" { + t.Fatalf("upstream=%q", rows[0].Upstream) + } +} diff --git a/internal/events/dispatcher.go b/internal/events/dispatcher.go index a8b7c379..c92bf065 100644 --- a/internal/events/dispatcher.go +++ b/internal/events/dispatcher.go @@ -6,6 +6,8 @@ import ( "strings" "time" + "chat-agent-system/internal/errcode" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) @@ -289,7 +291,25 @@ func FailLease(ctx context.Context, tx pgx.Tx, l Lease, code string, retryable b SET finished_at=now(), outcome=$4, error_code=$5 WHERE event_id=$1 AND consumer=$2 AND fencing_token=$3 `, l.EventID, l.Consumer, l.Token, outcome, code) - return err + if err != nil { + return err + } + if outcome == "dead" || outcome == "quarantine" { + id := "EVENTS_DEAD" + if outcome == "quarantine" { + id = "EVENTS_QUARANTINE" + } + errcode.Report(ctx, errcode.Event{ + Code: id, + Module: "events", + Source: "async", + RequestID: l.EventID, + PhaseID: l.Consumer, + Upstream: code, + HTTPStatus: 500, + }) + } + return nil } func HeartbeatLease(ctx context.Context, tx pgx.Tx, l Lease, lease time.Duration) error { diff --git a/internal/httpx/envelope.go b/internal/httpx/envelope.go index 976c67b8..256412b7 100644 --- a/internal/httpx/envelope.go +++ b/internal/httpx/envelope.go @@ -1,9 +1,11 @@ package httpx import ( + "context" "crypto/rand" "encoding/hex" "encoding/json" + "log/slog" "net/http" "strings" ) @@ -15,6 +17,7 @@ const ( CodeIDEMPOTENCY_CONFLICT = "IDEMPOTENCY_CONFLICT" CodeINTERNAL = "INTERNAL" HeaderRequestID = "X-Request-ID" + HeaderErrorCode = "X-Error-Code" ) type Envelope struct { @@ -101,15 +104,44 @@ func PublicLegacy(code, legacy string) string { } func Write(w http.ResponseWriter, status int, code, legacy, requestID string, details map[string]any) { + write(w, nil, status, code, legacy, requestID, "", details) +} + +// WriteHTTP 写入错误信封,并记录 method/path。safeMessage 为空时使用按码的固定文案。 +func WriteHTTP(w http.ResponseWriter, r *http.Request, status int, code, legacy, requestID, safeMessage string, details map[string]any) { + if requestID == "" { + requestID = requestIDFrom(w, r) + } + write(w, r, status, code, legacy, requestID, safeMessage, details) +} + +func requestIDFrom(w http.ResponseWriter, r *http.Request) string { + if r != nil { + if v := strings.TrimSpace(r.Header.Get(HeaderRequestID)); v != "" { + return v + } + } + if w != nil { + if v := strings.TrimSpace(w.Header().Get(HeaderRequestID)); v != "" { + return v + } + } + return RequestID(r) +} + +func write(w http.ResponseWriter, r *http.Request, status int, code, legacy, requestID, safeMessage string, details map[string]any) { if code == "" { code = CodeFromStatus(status) } if requestID == "" { - requestID = newRequestID() + requestID = requestIDFrom(w, r) + } + if safeMessage == "" { + safeMessage = SafeMessage(code) } env := Envelope{ Code: code, - Message: SafeMessage(code), + Message: safeMessage, RequestID: requestID, Error: PublicLegacy(code, legacy), Details: details, @@ -118,6 +150,31 @@ func Write(w http.ResponseWriter, status int, code, legacy, requestID string, de w.Header().Set("Content-Type", "application/json; charset=utf-8") } w.Header().Set(HeaderRequestID, requestID) + w.Header().Set(HeaderErrorCode, code) + logError(r, status, code, requestID) w.WriteHeader(status) _ = json.NewEncoder(w).Encode(env) } + +func logError(r *http.Request, status int, code, requestID string) { + level := slog.LevelWarn + if status >= 500 { + level = slog.LevelError + } + args := []any{"code", code, "status", status, "request_id", requestID} + if r != nil { + args = append(args, "method", r.Method, "path", stripQuery(r.URL.Path)) + } + slog.Log(context.Background(), level, "http_error", args...) +} + +func stripQuery(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "/" + } + if i := strings.IndexAny(path, "?#"); i >= 0 { + path = path[:i] + } + return path +} diff --git a/internal/httpx/envelope_test.go b/internal/httpx/envelope_test.go index b49cde85..2aba052f 100644 --- a/internal/httpx/envelope_test.go +++ b/internal/httpx/envelope_test.go @@ -1,7 +1,9 @@ package httpx import ( + "bytes" "encoding/json" + "log/slog" "net/http" "net/http/httptest" "strings" @@ -47,6 +49,37 @@ func TestLooksInternalXSS(t *testing.T) { } } +func TestWriteLogsCodeWithoutInternalText(t *testing.T) { + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}))) + t.Cleanup(func() { slog.SetDefault(prev) }) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/login?access_token=leak", nil) + req.Header.Set(HeaderRequestID, "req_log") + rec := httptest.NewRecorder() + WriteHTTP(rec, req, http.StatusInternalServerError, CodeINTERNAL, "pq: password /home/x", "req_log", "请求无法完成", nil) + line := buf.String() + if !strings.Contains(line, `"msg":"http_error"`) && !strings.Contains(line, "http_error") { + t.Fatalf("missing log: %s", line) + } + if !strings.Contains(line, "req_log") || !strings.Contains(line, CodeINTERNAL) { + t.Fatalf("log=%s", line) + } + if !strings.Contains(line, `"/api/v1/login"`) { + t.Fatalf("path=%s", line) + } + low := strings.ToLower(line) + for _, leak := range []string{"password", "pq:", "/home/", "access_token"} { + if strings.Contains(low, leak) { + t.Fatalf("leaked %s in %s", leak, line) + } + } + if rec.Header().Get(HeaderErrorCode) != CodeINTERNAL { + t.Fatalf("header=%s", rec.Header().Get(HeaderErrorCode)) + } +} + func TestCodeFromStatus(t *testing.T) { if CodeFromStatus(http.StatusUnauthorized) != CodeAUTHZ { t.Fatal("401") diff --git a/internal/projectinstance/errors.go b/internal/projectinstance/errors.go index 0a1768df..6b5d90bf 100644 --- a/internal/projectinstance/errors.go +++ b/internal/projectinstance/errors.go @@ -53,6 +53,8 @@ func (e *DocTaskBusyError) Error() string { return "document task busy: squad run in progress" } +func (e *DocTaskBusyError) ErrorCode() string { return "DOC_TASK_BUSY" } + func (e *DocTaskBusyError) Is(target error) bool { _, ok := target.(*DocTaskBusyError) return ok diff --git a/internal/server/access_log_middleware.go b/internal/server/access_log_middleware.go index 8c128fc9..d190d30c 100644 --- a/internal/server/access_log_middleware.go +++ b/internal/server/access_log_middleware.go @@ -10,16 +10,17 @@ import ( ) // accessLogMiddleware emits one structured line per HTTP request when enabled. +// It always stamps X-Request-ID so later error writers can reuse it. // It never logs bodies, Authorization, Cookie, or raw query strings. func (s *Server) accessLogMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestID := httpx.RequestID(r) + w.Header().Set(httpx.HeaderRequestID, requestID) if s == nil || s.cfg == nil || !s.cfg.HTTPAccessLogEnabled { next.ServeHTTP(w, r) return } started := time.Now() - requestID := httpx.RequestID(r) - w.Header().Set(httpx.HeaderRequestID, requestID) rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(rec, r) slog.Info("http_access", @@ -28,6 +29,7 @@ func (s *Server) accessLogMiddleware(next http.Handler) http.Handler { "status", rec.status, "duration_ms", time.Since(started).Milliseconds(), "request_id", requestID, + "err_code", rec.Header().Get(httpx.HeaderErrorCode), ) }) } @@ -71,12 +73,16 @@ func accessLogPath(path string) string { } // accessLogSafeMessage is used by tests to assert the middleware contract. -func accessLogSafeMessage(method, path string, status int, durationMs int64, requestID string) map[string]any { - return map[string]any{ +func accessLogSafeMessage(method, path string, status int, durationMs int64, requestID, errCode string) map[string]any { + msg := map[string]any{ "method": method, "path": accessLogPath(path), "status": status, "duration_ms": durationMs, "request_id": requestID, } + if errCode != "" { + msg["err_code"] = errCode + } + return msg } diff --git a/internal/server/access_log_middleware_test.go b/internal/server/access_log_middleware_test.go index 81f93fc5..547e46fa 100644 --- a/internal/server/access_log_middleware_test.go +++ b/internal/server/access_log_middleware_test.go @@ -16,7 +16,7 @@ func TestAccessLogPath_DropsQueryAndFragment(t *testing.T) { if got != "/api/v1/chat" { t.Fatalf("path=%q", got) } - msg := accessLogSafeMessage("GET", "/x?password=p", 200, 3, "req_1") + msg := accessLogSafeMessage("GET", "/x?password=p", 200, 3, "req_1", "") blob := strings.ToLower(strings.Join([]string{ msg["method"].(string), msg["path"].(string), diff --git a/internal/server/admin_error_events_test.go b/internal/server/admin_error_events_test.go new file mode 100644 index 00000000..7061ef60 --- /dev/null +++ b/internal/server/admin_error_events_test.go @@ -0,0 +1,32 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + + "chat-agent-system/internal/app" + "chat-agent-system/internal/auth" + "chat-agent-system/internal/authz" +) + +func TestAdminErrorEvents_ForbiddenForNonAdmin(t *testing.T) { + s := &Server{rt: &app.Runtime{Authz: authz.NewService(nil)}} + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/system/error-events", nil) + req = req.WithContext(withPrincipal(req.Context(), &auth.UserAccount{ID: 2, RoleID: "role-member"})) + rec := httptest.NewRecorder() + s.handleAdminErrorEvents(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestAdminErrorEvents_Unauthenticated(t *testing.T) { + s := &Server{rt: &app.Runtime{Authz: authz.NewService(nil)}} + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/system/error-events", nil) + rec := httptest.NewRecorder() + s.handleAdminErrorEvents(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/server/admin_roles_handlers.go b/internal/server/admin_roles_handlers.go index d951d373..5dbf3e67 100644 --- a/internal/server/admin_roles_handlers.go +++ b/internal/server/admin_roles_handlers.go @@ -18,7 +18,7 @@ func (s *Server) handleAdminRoles(w http.ResponseWriter, r *http.Request) { case http.MethodGet: roles, err := s.authzSvc().ListRoles(r.Context()) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } writeJSONResponse(w, http.StatusOK, map[string]any{"roles": roles}) @@ -34,7 +34,7 @@ func (s *Server) handleAdminRoles(w http.ResponseWriter, r *http.Request) { if errors.Is(err, authz.ErrRolesNotAvailable) { status = http.StatusServiceUnavailable } - writeSafeError(w, r, status, err.Error()) + writeDomainError(w, r, status, err) return } writeJSONResponse(w, http.StatusCreated, created) @@ -72,7 +72,7 @@ func (s *Server) handleAdminRoleByID(w http.ResponseWriter, r *http.Request) { case errors.Is(err, authz.ErrRolesNotAvailable): status = http.StatusServiceUnavailable } - writeSafeError(w, r, status, err.Error()) + writeDomainError(w, r, status, err) return } writeJSONResponse(w, http.StatusOK, updated) @@ -89,7 +89,7 @@ func (s *Server) handleAdminRoleByID(w http.ResponseWriter, r *http.Request) { case errors.Is(err, authz.ErrRolesNotAvailable): status = http.StatusServiceUnavailable } - writeSafeError(w, r, status, err.Error()) + writeDomainError(w, r, status, err) return } writeJSONResponse(w, http.StatusOK, map[string]bool{"success": true}) diff --git a/internal/server/admin_system_handlers.go b/internal/server/admin_system_handlers.go index 5dec8eaf..f8c2a460 100644 --- a/internal/server/admin_system_handlers.go +++ b/internal/server/admin_system_handlers.go @@ -5,8 +5,11 @@ import ( "errors" "net/http" "strconv" + "strings" + "time" "chat-agent-system/internal/authz" + "chat-agent-system/internal/errorevents" "chat-agent-system/internal/sysadmin" ) @@ -55,7 +58,7 @@ func (s *Server) handleAdminSystemSettings(w http.ResponseWriter, r *http.Reques } settings, err := s.sysadmin().ListSettings(r.Context()) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } writeJSONResponse(w, http.StatusOK, map[string]any{"settings": settings}) @@ -100,7 +103,7 @@ func (s *Server) handleAdminSystemSettingByKey(w http.ResponseWriter, r *http.Re case errors.Is(err, sysadmin.ErrStoreUnavailable): writeSafeError(w, r, http.StatusServiceUnavailable, "设置存储不可用") case err != nil: - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) default: writeJSONResponse(w, http.StatusOK, setting) } @@ -131,8 +134,57 @@ func (s *Server) handleAdminSystemSettingAudits(w http.ResponseWriter, r *http.R case errors.Is(err, sysadmin.ErrStoreUnavailable): writeSafeError(w, r, http.StatusServiceUnavailable, "设置存储不可用") case err != nil: - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) default: writeJSONResponse(w, http.StatusOK, map[string]any{"audits": audits}) } } + +func (s *Server) handleAdminErrorEvents(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireSystemPermission(w, r, authz.PermSystemAuditRead); !ok { + return + } + store := errorevents.Active() + if store == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "错误审计存储不可用") + return + } + q := r.URL.Query() + limit := 50 + if raw := q.Get("limit"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n < 0 { + writeSafeError(w, r, http.StatusBadRequest, "无效的 limit") + return + } + limit = n + } + filter := errorevents.Filter{ + Code: q.Get("code"), + RequestID: q.Get("request_id"), + Class: q.Get("class"), + Limit: limit, + } + if raw := strings.TrimSpace(q.Get("since")); raw != "" { + ts, err := time.Parse(time.RFC3339, raw) + if err != nil { + writeSafeError(w, r, http.StatusBadRequest, "无效的 since") + return + } + filter.Since = ts + } + if raw := strings.TrimSpace(q.Get("until")); raw != "" { + ts, err := time.Parse(time.RFC3339, raw) + if err != nil { + writeSafeError(w, r, http.StatusBadRequest, "无效的 until") + return + } + filter.Until = ts + } + events, err := store.List(r.Context(), filter) + if err != nil { + writeDomainError(w, r, http.StatusInternalServerError, err) + return + } + writeJSONResponse(w, http.StatusOK, map[string]any{"events": events}) +} diff --git a/internal/server/admin_template_handlers.go b/internal/server/admin_template_handlers.go index 6a8e1b44..a4e22371 100644 --- a/internal/server/admin_template_handlers.go +++ b/internal/server/admin_template_handlers.go @@ -19,7 +19,7 @@ func (s *Server) handleAdminTemplates(w http.ResponseWriter, r *http.Request) { case http.MethodGet: assets, err := s.wfDefSvc().ListTemplates(r.Context()) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } writeJSONResponse(w, http.StatusOK, map[string]any{"templates": assets}) @@ -60,7 +60,7 @@ func (s *Server) handleAdminTemplates(w http.ResponseWriter, r *http.Request) { case errors.Is(err, workflowdef.ErrNotAvailable): status = http.StatusServiceUnavailable } - writeSafeError(w, r, status, err.Error()) + writeDomainError(w, r, status, err) return } writeJSONResponse(w, http.StatusCreated, tv) @@ -84,7 +84,7 @@ func (s *Server) handleAdminTemplateVersions(w http.ResponseWriter, r *http.Requ } versions, err := s.wfDefSvc().ListTemplateVersions(r.Context(), assetKey) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } writeJSONResponse(w, http.StatusOK, map[string]any{"versions": versions}) diff --git a/internal/server/admin_workflow_handlers.go b/internal/server/admin_workflow_handlers.go index 557846ef..a37f46c8 100644 --- a/internal/server/admin_workflow_handlers.go +++ b/internal/server/admin_workflow_handlers.go @@ -32,7 +32,7 @@ func (s *Server) handleAdminWorkflows(w http.ResponseWriter, r *http.Request) { case http.MethodGet: defs, err := s.wfDefSvc().ListDefinitions(r.Context()) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } writeJSONResponse(w, http.StatusOK, map[string]any{"workflows": defs}) @@ -48,7 +48,7 @@ func (s *Server) handleAdminWorkflows(w http.ResponseWriter, r *http.Request) { if errors.Is(err, workflowdef.ErrValidationFailed) { status = http.StatusUnprocessableEntity } - writeSafeError(w, r, status, err.Error()) + writeDomainError(w, r, status, err) return } writeJSONResponse(w, http.StatusCreated, map[string]any{"definition": def, "version": ver}) @@ -72,12 +72,12 @@ func (s *Server) handleAdminWorkflowByID(w http.ResponseWriter, r *http.Request) } def, err := s.wfDefSvc().GetDefinition(r.Context(), id) if err != nil { - writeSafeError(w, r, http.StatusNotFound, err.Error()) + writeDomainError(w, r, http.StatusNotFound, err) return } versions, err := s.wfDefSvc().ListVersions(r.Context(), id) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } writeJSONResponse(w, http.StatusOK, map[string]any{"definition": def, "versions": versions}) @@ -114,7 +114,7 @@ func (s *Server) handleAdminWorkflowVersion(w http.ResponseWriter, r *http.Reque if errors.Is(err, workflowdef.ErrNotDraft) { status = http.StatusConflict } - writeSafeError(w, r, status, err.Error()) + writeDomainError(w, r, status, err) return } writeJSONResponse(w, http.StatusOK, updated) @@ -136,7 +136,7 @@ func (s *Server) handleAdminWorkflowValidate(w http.ResponseWriter, r *http.Requ version, _ := strconv.Atoi(r.PathValue("version")) res, err := s.wfDefSvc().ValidateVersion(r.Context(), workflowID, version) if err != nil { - writeSafeError(w, r, http.StatusNotFound, err.Error()) + writeDomainError(w, r, http.StatusNotFound, err) return } writeJSONResponse(w, http.StatusOK, res) @@ -159,7 +159,7 @@ func (s *Server) handleAdminWorkflowPublish(w http.ResponseWriter, r *http.Reque if errors.Is(err, workflowdef.ErrValidationFailed) { status = http.StatusUnprocessableEntity } - writeSafeError(w, r, status, err.Error()) + writeDomainError(w, r, status, err) return } if refresher, ok := s.pwSvc().Catalog().(interface { @@ -182,7 +182,7 @@ func (s *Server) handleAdminWorkflowRetire(w http.ResponseWriter, r *http.Reques version, _ := strconv.Atoi(r.PathValue("version")) ver, err := s.wfDefSvc().RetireVersion(r.Context(), workflowID, version) if err != nil { - writeSafeError(w, r, http.StatusBadRequest, err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } if refresher, ok := s.pwSvc().Catalog().(interface { diff --git a/internal/server/asset_handlers.go b/internal/server/asset_handlers.go index f7a8d597..d2021396 100644 --- a/internal/server/asset_handlers.go +++ b/internal/server/asset_handlers.go @@ -41,7 +41,7 @@ func (s *Server) handleUploadProjectAsset(w http.ResponseWriter, r *http.Request writeSafeError(w, r, http.StatusRequestEntityTooLarge, "upload too large") return } - writeSafeError(w, r, http.StatusBadRequest, "invalid multipart form: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } file, header, err := r.FormFile("file") @@ -68,7 +68,7 @@ func (s *Server) handleUploadProjectAsset(w http.ResponseWriter, r *http.Request declaredMIME := header.Header.Get("Content-Type") mimeType, verr := upload.ValidateProjectMaterial(filename, declaredMIME, data) if verr != nil { - writeSafeError(w, r, http.StatusUnsupportedMediaType, "upload rejected: "+verr.Error()) + writeDomainError(w, r, http.StatusUnsupportedMediaType, verr) return } @@ -79,7 +79,7 @@ func (s *Server) handleUploadProjectAsset(w http.ResponseWriter, r *http.Request } ref, err := cas.StoreFile(r.Context(), projectID, userID, filepath.Base(filename), "material", data, mimeType) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, "store asset failed: "+err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } @@ -176,7 +176,7 @@ func (s *Server) handleListProjectAssets(w http.ResponseWriter, r *http.Request) } refs, err := cas.ListProjectRefs(r.Context(), projectID) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } diff --git a/internal/server/auth_handlers.go b/internal/server/auth_handlers.go index 71317e23..e7af1773 100644 --- a/internal/server/auth_handlers.go +++ b/internal/server/auth_handlers.go @@ -130,7 +130,7 @@ func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) { status = http.StatusInternalServerError slog.Error("auth_register_failed", "request_id", httpx.RequestID(r), "err", err) } - writeSafeError(w, r, status, err.Error()) + writeDomainError(w, r, status, err) return } @@ -166,7 +166,7 @@ func (s *Server) handleAuthChangePassword(w http.ResponseWriter, r *http.Request } if err := s.authSvc().ChangePassword(r.Context(), user.ID, req.OldPassword, req.NewPassword); err != nil { - writeSafeError(w, r, http.StatusBadRequest, err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } @@ -229,7 +229,7 @@ func (s *Server) handleAuthSessions(w http.ResponseWriter, r *http.Request) { token := extractBearerToken(r) sessions, listErr := s.authSvc().ListUserSessions(r.Context(), user.ID, token) if listErr != nil { - writeSafeError(w, r, http.StatusInternalServerError, listErr.Error()) + writeDomainError(w, r, http.StatusInternalServerError, listErr) return } if sessions == nil { @@ -272,7 +272,7 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { users, err := s.authSvc().ListUsers(r.Context()) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") @@ -289,7 +289,7 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) { if roleID := strings.TrimSpace(req.RoleID); roleID != "" && s.authzSvc() != nil && s.authzSvc().Enabled() { roles, listErr := s.authzSvc().ListRoles(r.Context()) if listErr != nil { - writeSafeError(w, r, http.StatusInternalServerError, listErr.Error()) + writeDomainError(w, r, http.StatusInternalServerError, listErr) return } found := false @@ -310,7 +310,7 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) { if authIsInfrastructureError(err) { status = http.StatusInternalServerError } - writeSafeError(w, r, status, err.Error()) + writeDomainError(w, r, status, err) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") @@ -344,7 +344,7 @@ func (s *Server) handleAdminUpdateRole(w http.ResponseWriter, r *http.Request) { updated, err := s.authSvc().UpdateUserRole(r.Context(), targetID, req.RoleID, req.Position) if err != nil { - writeSafeError(w, r, http.StatusBadRequest, err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } @@ -376,14 +376,14 @@ func (s *Server) handleAdminUpdateStatus(w http.ResponseWriter, r *http.Request) if req.RoleID != "" { if _, roleErr := s.authSvc().UpdateUserRole(r.Context(), targetID, req.RoleID, req.Position); roleErr != nil { - writeSafeError(w, r, http.StatusBadRequest, roleErr.Error()) + writeDomainError(w, r, http.StatusBadRequest, roleErr) return } } updated, err := s.authSvc().UpdateUserStatus(r.Context(), targetID, req.Status) if err != nil { - writeSafeError(w, r, http.StatusBadRequest, err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } @@ -434,7 +434,7 @@ func (s *Server) handleAuthPublicConfig(w http.ResponseWriter, r *http.Request) } cfg, err := s.authSvc().GetConfig(r.Context()) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") @@ -450,7 +450,7 @@ func (s *Server) handleAdminConfig(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { cfg, err := s.authSvc().GetConfig(r.Context()) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") @@ -465,7 +465,7 @@ func (s *Server) handleAdminConfig(w http.ResponseWriter, r *http.Request) { return } if err := s.authSvc().UpdateConfig(r.Context(), req); err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") diff --git a/internal/server/auth_handlers_test.go b/internal/server/auth_handlers_test.go index 408671c6..d7df89db 100644 --- a/internal/server/auth_handlers_test.go +++ b/internal/server/auth_handlers_test.go @@ -253,8 +253,8 @@ func TestAuthHandlers_RegisterDuplicateUsername(t *testing.T) { if !bytes.Contains([]byte(errText), []byte("已存在")) { t.Fatalf("expected error to mention 已存在, got %#v", env) } - if env["code"] != "VALIDATION" { - t.Fatalf("expected VALIDATION, got %#v", env["code"]) + if env["code"] != "AUTH_USERNAME_EXISTS" { + t.Fatalf("expected AUTH_USERNAME_EXISTS, got %#v", env["code"]) } } diff --git a/internal/server/auth_helpers.go b/internal/server/auth_helpers.go index d6a2134d..1602954f 100644 --- a/internal/server/auth_helpers.go +++ b/internal/server/auth_helpers.go @@ -58,7 +58,7 @@ func writeAuthzError(w http.ResponseWriter, err error) { "message": "请先登录", }) default: - writeSafeError(w, nil, http.StatusInternalServerError, err.Error()) + writeDomainError(w, nil, http.StatusInternalServerError, err) } } diff --git a/internal/server/chat_digest_api.go b/internal/server/chat_digest_api.go index 64dd8e67..2560322f 100644 --- a/internal/server/chat_digest_api.go +++ b/internal/server/chat_digest_api.go @@ -76,7 +76,7 @@ func (s *Server) handleGenerateDailyDigest(w http.ResponseWriter, r *http.Reques var req GenerateDigestRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request json body: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } diff --git a/internal/server/chat_turn_api.go b/internal/server/chat_turn_api.go index c4feabe2..d2876a05 100644 --- a/internal/server/chat_turn_api.go +++ b/internal/server/chat_turn_api.go @@ -38,7 +38,7 @@ func (s *Server) handleGetChatTurn(w http.ResponseWriter, r *http.Request) { return } if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } diff --git a/internal/server/chat_unread_api.go b/internal/server/chat_unread_api.go index ca57c422..12a8f293 100644 --- a/internal/server/chat_unread_api.go +++ b/internal/server/chat_unread_api.go @@ -76,7 +76,7 @@ func (s *Server) handleChatRead(w http.ResponseWriter, r *http.Request) { var req ctxmgr.MarkChatReadRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } if req.SessionID == "" { diff --git a/internal/server/chat_window_api.go b/internal/server/chat_window_api.go index 82c4b564..ba83b3cf 100644 --- a/internal/server/chat_window_api.go +++ b/internal/server/chat_window_api.go @@ -188,7 +188,7 @@ func (s *Server) handleImportChatMessages(w http.ResponseWriter, r *http.Request var items []importMessageItem if err := json.NewDecoder(r.Body).Decode(&items); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } diff --git a/internal/server/chatflow_http.go b/internal/server/chatflow_http.go index 9687d050..984c6d51 100644 --- a/internal/server/chatflow_http.go +++ b/internal/server/chatflow_http.go @@ -32,6 +32,6 @@ func (s *Server) writeChatLogError(w http.ResponseWriter, err error) { case errors.Is(err, chatflow.ErrClosed): writeSafeError(w, nil, http.StatusServiceUnavailable, "context manager not initialized") default: - writeSafeError(w, nil, http.StatusInternalServerError, err.Error()) + writeDomainError(w, nil, http.StatusInternalServerError, err) } } diff --git a/internal/server/document_revision_handlers.go b/internal/server/document_revision_handlers.go index ddabe648..f1d00d55 100644 --- a/internal/server/document_revision_handlers.go +++ b/internal/server/document_revision_handlers.go @@ -216,7 +216,7 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re if kind == "controlled" && s.gov() != nil { // ActorIsMember=true:上游 enforceDocGovWorkflowAction(Edit) 已按成员级放行。 if err := s.gov().AssertHumanCanEdit(r.Context(), docID, user.ID, vocabulary.RoleAuthor, true); err != nil { - writeDocGovError(w, err) + writeDocGovError(w, r, err) return } holderID := fmt.Sprintf("%d", user.ID) diff --git a/internal/server/domain_http.go b/internal/server/domain_http.go index a5e38327..094d8203 100644 --- a/internal/server/domain_http.go +++ b/internal/server/domain_http.go @@ -4,8 +4,18 @@ import ( "errors" "net/http" + "chat-agent-system/internal/auth" + "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/docgov" + "chat-agent-system/internal/errcode" + "chat-agent-system/internal/freedoc" + "chat-agent-system/internal/httpx" + "chat-agent-system/internal/maintenance" + "chat-agent-system/internal/projectinstance" "chat-agent-system/internal/projectworkflow" + "chat-agent-system/internal/swboot" + "chat-agent-system/internal/sysadmin" + "chat-agent-system/internal/taskrun" ) // domainHTTPStatus is the single adapter that maps known domain sentinel / @@ -53,21 +63,31 @@ func domainHTTPStatus(err error) (status int, ok bool) { } } -func writeTaskError(w http.ResponseWriter, err error, payload any) { +func writeTaskError(w http.ResponseWriter, r *http.Request, err error, payload any) { status := http.StatusInternalServerError if mapped, ok := domainHTTPStatus(err); ok { status = mapped } if payload != nil { + def := errcode.FromStatus(status) + if d, found := errcode.FromError(err); found { + def = d + } + noteCoded(w, r, status, def, err) writeJSONResponse(w, status, payload) return } - writeSafeError(w, nil, status, err.Error()) + writeDomainError(w, r, status, err) } -func writeDocGovError(w http.ResponseWriter, err error) { +func writeDocGovError(w http.ResponseWriter, r *http.Request, err error) { var busy *docgov.DocumentBusyError if errors.As(err, &busy) { + def, ok := errcode.Lookup("DOC_BUSY") + if !ok { + def = errcode.FromStatus(http.StatusConflict) + } + noteCoded(w, r, http.StatusConflict, def, err) writeJSONResponse(w, http.StatusConflict, map[string]any{ "error": "document_busy", "detail": busy, @@ -78,5 +98,110 @@ func writeDocGovError(w http.ResponseWriter, err error) { if mapped, ok := domainHTTPStatus(err); ok { status = mapped } - writeSafeError(w, nil, status, err.Error()) + writeDomainError(w, r, status, err) +} + +func noteCoded(w http.ResponseWriter, r *http.Request, status int, def errcode.Def, err error) { + rid := incomingRequestID(w, r) + if rid == "" { + rid = httpx.RequestID(r) + } + if w != nil { + w.Header().Set(httpx.HeaderRequestID, rid) + w.Header().Set(httpx.HeaderErrorCode, def.ID) + } + legacy := "" + if err != nil { + legacy = err.Error() + } + reportHTTPError(r, status, def, rid, legacy) +} + +func init() { + bind := func(err error, id string) { errcode.Bind(err, id) } + bind(docgov.ErrDocumentNotFound, "DOCUMENT_NOT_FOUND") + bind(docgov.ErrDocumentLocked, "DOCUMENT_LOCKED") + bind(docgov.ErrDocumentBusy, "DOC_BUSY") + bind(docgov.ErrLeaseNotHeld, "LEASE_NOT_HELD") + bind(docgov.ErrLeaseExpired, "LEASE_EXPIRED") + bind(docgov.ErrStaleFencing, "LEASE_STALE") + bind(docgov.ErrForceEditNotAllowed, "FORCE_EDIT_NOT_ALLOWED") + bind(docgov.ErrProposalNotFound, "PROPOSAL_NOT_FOUND") + bind(docgov.ErrUnauthorizedActor, "UNAUTHORIZED_ACTOR") + bind(docgov.ErrOnlyAuthorCanEdit, "AUTHOR_ONLY_EDIT") + bind(docgov.ErrReviewerCannotEdit, "REVIEWER_CANNOT_EDIT") + bind(docgov.ErrApproverCannotEdit, "APPROVER_CANNOT_EDIT") + bind(docgov.ErrInvalidStateTransition, "INVALID_STATE_TRANSITION") + bind(docgov.ErrRejectReasonRequired, "REJECT_REASON_REQUIRED") + bind(docgov.ErrProposalAlreadyDecided, "PROPOSAL_ALREADY_DECIDED") + bind(docgov.ErrChangeProposalPending, "CHANGE_PROPOSAL_PENDING") + bind(docgov.ErrContentProjectionMissing, "DOCUMENT_CONTENT_PROJECTION_MISSING") + bind(docgov.ErrContentProjectionEmpty, "DOCUMENT_CONTENT_PROJECTION_EMPTY") + bind(docgov.ErrContentIntegrityMismatch, "DOCUMENT_CONTENT_INTEGRITY_MISMATCH") + bind(freedoc.ErrNotFound, "FREEDOC_NOT_FOUND") + bind(freedoc.ErrForbidden, "FREEDOC_FORBIDDEN") + bind(freedoc.ErrConflict, "FREEDOC_CONFLICT") + bind(freedoc.ErrInvalidParam, "FREEDOC_INVALID_PARAM") + bind(freedoc.ErrMessageNotExportable, "FREEDOC_NOT_EXPORTABLE") + bind(auth.ErrUsernameExists, "AUTH_USERNAME_EXISTS") + bind(sysadmin.ErrUnknownSetting, "SETTING_UNKNOWN") + bind(sysadmin.ErrSettingReadOnly, "SETTING_READ_ONLY") + bind(sysadmin.ErrInvalidSettingValue, "SETTING_INVALID_VALUE") + bind(sysadmin.ErrStoreUnavailable, "SETTING_STORE_UNAVAILABLE") + bind(sysadmin.ErrExpectedRevisionRequired, "SETTING_REVISION_REQUIRED") + bind(sysadmin.ErrSettingRevisionConflict, "SETTING_REVISION_CONFLICT") + bind(sysadmin.ErrSettingNotFound, "SETTING_NOT_FOUND") + bind(projectworkflow.ErrUnauthenticated, "WORKFLOW_UNAUTHENTICATED") + bind(projectworkflow.ErrForbiddenNotCreator, "WORKFLOW_FORBIDDEN_NOT_CREATOR") + bind(projectworkflow.ErrProjectNotFound, "PROJECT_NOT_FOUND") + bind(projectworkflow.ErrWorkflowNotFound, "WORKFLOW_NOT_FOUND") + bind(projectworkflow.ErrConcurrentConflict, "WORKFLOW_CONCURRENT_CONFLICT") + bind(projectworkflow.ErrPrerequisiteNotMet, "WORKFLOW_PREREQUISITE") + bind(projectworkflow.ErrInvalidInput, "WORKFLOW_INVALID_INPUT") + bind(projectworkflow.ErrDuplicateProjectName, "PROJECT_NAME_DUPLICATE") + bind(projectworkflow.ErrLegacySwitchBlocked, "WORKFLOW_LEGACY_SWITCH_BLOCKED") + bind(taskrun.ErrRunNotResumable, "TASKRUN_NOT_RESUMABLE") + bind(taskrun.ErrRunAlreadyActive, "TASKRUN_ALREADY_ACTIVE") + bind(taskrun.ErrTerminalConflict, "TASKRUN_TERMINAL_CONFLICT") + bind(taskrun.ErrCleanupBlocked, "TASKRUN_CLEANUP_BLOCKED") + bind(taskrun.ErrUnauthorizedBackend, "TASKRUN_UNAUTHORIZED_BACKEND") + bind(taskrun.ErrSensitivePayload, "TASKRUN_SENSITIVE_PAYLOAD") + bind(taskrun.ErrStoreNotAvailable, "TASKRUN_STORE_UNAVAILABLE") + bind(taskrun.ErrIdempotencyConflict, "TASKRUN_IDEMPOTENCY_CONFLICT") + bind(ctxmgr.ErrSessionOwnerMismatch, "SESSION_OWNER_MISMATCH") + bind(ctxmgr.ErrChatTurnNotFound, "CHAT_TURN_NOT_FOUND") + bind(ctxmgr.ErrIdempotencyKeyReused, "IDEMPOTENCY_KEY_REUSED") + bind(maintenance.ErrNotFound, "MAINTENANCE_NOT_FOUND") + bind(maintenance.ErrConflictOpen, "MAINTENANCE_CONFLICT_OPEN") + bind(maintenance.ErrInvalidWindow, "MAINTENANCE_INVALID_WINDOW") + bind(maintenance.ErrBadStatus, "MAINTENANCE_BAD_STATUS") + bind(swboot.ErrMergeRequiresSeparateAuthorization, "SWBOOT_MERGE_FORBIDDEN") + bind(swboot.ErrRepoPathNotAllowlisted, "SWBOOT_REPO_NOT_ALLOWLISTED") + bind(swboot.ErrControlledPatchDisabled, "SWBOOT_PATCH_DISABLED") + bind(swboot.ErrPatchExecutionUnimplemented, "SWBOOT_PATCH_UNIMPLEMENTED") + bind(projectinstance.ErrNotAvailable, "INSTANCE_NOT_AVAILABLE") + bind(projectinstance.ErrForbiddenNotCreator, "INSTANCE_FORBIDDEN_NOT_CREATOR") + bind(projectinstance.ErrPlanFrozen, "INSTANCE_PLAN_FROZEN") + bind(projectinstance.ErrWorkflowSwitchFrozen, "INSTANCE_SWITCH_FROZEN") + bind(projectinstance.ErrWorkflowAlreadyActive, "INSTANCE_ALREADY_ACTIVE") + bind(projectinstance.ErrPlanChangedReconfirm, "INSTANCE_PLAN_RECONFIRM") + bind(projectinstance.ErrValidationFailed, "INSTANCE_VALIDATION_FAILED") + bind(projectinstance.ErrTokenInvalid, "INSTANCE_ACTIVATION_INVALID") + bind(projectinstance.ErrConcurrentConflict, "INSTANCE_CONFLICT") + bind(projectinstance.ErrPlanRevisionConflict, "INSTANCE_PLAN_CONFLICT") + bind(projectinstance.ErrInstanceNotFound, "INSTANCE_NOT_FOUND") + bind(projectinstance.ErrStageGateNotReady, "INSTANCE_STAGE_GATE") + bind(projectinstance.ErrStageNotActive, "INSTANCE_STAGE_NOT_ACTIVE") + bind(projectinstance.ErrStageNotCompleted, "INSTANCE_STAGE_NOT_COMPLETED") + bind(projectinstance.ErrRecheckAckRequired, "INSTANCE_RECHECK_REQUIRED") + bind(projectinstance.ErrInvalidStage, "INSTANCE_INVALID_STAGE") + bind(projectinstance.ErrInstanceRevisionConflict, "INSTANCE_REVISION_CONFLICT") + bind(projectinstance.ErrTerminationNotAllowed, "INSTANCE_TERMINATION_DENIED") + bind(projectinstance.ErrAuthorOnlyPolicyDisabled, "INSTANCE_AUTHOR_ONLY_DISABLED") + bind(projectinstance.ErrAuthorOnlyPolicyUnavailable, "INSTANCE_AUTHOR_ONLY_UNAVAILABLE") + bind(projectinstance.ErrAuthorOnlyPolicyChanged, "INSTANCE_AUTHOR_ONLY_CHANGED") + bind(projectinstance.ErrForbiddenDutyReassign, "INSTANCE_DUTY_FORBIDDEN") + bind(projectinstance.ErrDocTaskNotFound, "INSTANCE_DOC_TASK_NOT_FOUND") + bind(projectinstance.ErrLegacySwitchBlocked, "INSTANCE_LEGACY_SWITCH_BLOCKED") + bind(projectinstance.ErrTaskScopeNotResolved, "TASK_SCOPE_UNRESOLVED") } diff --git a/internal/server/domain_http_test.go b/internal/server/domain_http_test.go index 9ecd3993..fc37ff7f 100644 --- a/internal/server/domain_http_test.go +++ b/internal/server/domain_http_test.go @@ -45,25 +45,25 @@ func TestDomainHTTPStatus_MapsKnownSentinels(t *testing.T) { func TestWriteDocGovError_UsesAdapterStatuses(t *testing.T) { rec := httptest.NewRecorder() - writeDocGovError(rec, docgov.ErrProposalNotFound) + writeDocGovError(rec, nil, docgov.ErrProposalNotFound) if rec.Code != http.StatusNotFound { t.Fatalf("status %d", rec.Code) } rec = httptest.NewRecorder() - writeDocGovError(rec, docgov.ErrUnauthorizedActor) + writeDocGovError(rec, nil, docgov.ErrUnauthorizedActor) if rec.Code != http.StatusForbidden { t.Fatalf("status %d", rec.Code) } rec = httptest.NewRecorder() - writeDocGovError(rec, docgov.ErrOnlyAuthorCanEdit) + writeDocGovError(rec, nil, docgov.ErrOnlyAuthorCanEdit) if rec.Code != http.StatusForbidden { t.Fatalf("OnlyAuthorCanEdit status %d", rec.Code) } rec = httptest.NewRecorder() - writeDocGovError(rec, &docgov.DocumentBusyError{DocID: "d", Reason: "held"}) + writeDocGovError(rec, nil, &docgov.DocumentBusyError{DocID: "d", Reason: "held"}) if rec.Code != http.StatusConflict { t.Fatalf("status %d", rec.Code) } @@ -78,13 +78,13 @@ func TestWriteDocGovError_UsesAdapterStatuses(t *testing.T) { func TestWriteTaskError_DefaultsTo500(t *testing.T) { rec := httptest.NewRecorder() - writeTaskError(rec, errors.New("boom"), nil) + writeTaskError(rec, nil, errors.New("boom"), nil) if rec.Code != http.StatusInternalServerError { t.Fatalf("status %d", rec.Code) } rec = httptest.NewRecorder() - writeTaskError(rec, docgov.ErrDocumentBusy, map[string]string{"ok": "no"}) + writeTaskError(rec, nil, docgov.ErrDocumentBusy, map[string]string{"ok": "no"}) if rec.Code != http.StatusConflict { t.Fatalf("status %d", rec.Code) } diff --git a/internal/server/handlers_chat_history.go b/internal/server/handlers_chat_history.go index 1c0c1d02..0b6ef86b 100644 --- a/internal/server/handlers_chat_history.go +++ b/internal/server/handlers_chat_history.go @@ -61,7 +61,7 @@ func (s *Server) handleListChatSessions(w http.ResponseWriter, r *http.Request) Offset: offset, }) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, "list chat sessions failed: "+err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } @@ -101,7 +101,7 @@ func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request) } can, err := s.authzSvc().CanManageProjectMembers(r.Context(), req.ProjectID, user.ID, user.RoleID) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } if !can { @@ -123,7 +123,7 @@ func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request) }) return } - writeSafeError(w, r, http.StatusInternalServerError, "upsert chat session failed: "+err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } @@ -217,7 +217,7 @@ func (s *Server) handleRecommendCases(w http.ResponseWriter, r *http.Request) { MinScore: minScore, }) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, "search similar cases failed: "+err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } diff --git a/internal/server/handlers_chat_task.go b/internal/server/handlers_chat_task.go index 9438f2db..2bd0e3c5 100644 --- a/internal/server/handlers_chat_task.go +++ b/internal/server/handlers_chat_task.go @@ -52,7 +52,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { var req ChatRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } if req.Message == "" { @@ -82,7 +82,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { }) return } - writeSafeError(w, r, http.StatusInternalServerError, "resolve session failed: "+err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } s.hydrateVisibleHistory(r.Context(), sess, user.ID) @@ -111,7 +111,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { return sendErr }) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, "process chat message failed: "+err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } @@ -126,7 +126,7 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { var req TaskRunRequestDTO if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } req.UserID = user.ID @@ -209,7 +209,7 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { if runRecord != nil { _ = s.pwSvc().UpdateWorkflowRun(r.Context(), runRecord.RunID, "failed", "", "", err.Error()) } - writeTaskError(w, err, stageRes) + writeTaskError(w, r, err, stageRes) return } if runRecord != nil && stageRes != nil && stageRes.PrimaryArtifact != nil { @@ -259,7 +259,7 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { if runRecord != nil { _ = s.pwSvc().UpdateWorkflowRun(r.Context(), runRecord.RunID, "failed", "", "", err.Error()) } - writeTaskError(w, err, taskRes) + writeTaskError(w, r, err, taskRes) return } diff --git a/internal/server/handlers_context.go b/internal/server/handlers_context.go index d2f1a114..d99f6dc2 100644 --- a/internal/server/handlers_context.go +++ b/internal/server/handlers_context.go @@ -56,7 +56,7 @@ func (s *Server) handleGetUserContext(w http.ResponseWriter, r *http.Request) { uCtx, err := s.ctxMgr().GetUserContext(r.Context(), projectID, userID, sessionID) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, "get user context failed: "+err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } @@ -71,7 +71,7 @@ func (s *Server) handleUpdateUserPreference(w http.ResponseWriter, r *http.Reque } var req UpdateUserPreferenceRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } if req.UserID <= 0 { @@ -99,7 +99,7 @@ func (s *Server) handleUpdateUserPreference(w http.ResponseWriter, r *http.Reque } if err := s.ctxMgr().UpdateUserPreference(r.Context(), pref); err != nil { - writeSafeError(w, r, http.StatusInternalServerError, "save user preference failed: "+err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } @@ -138,7 +138,7 @@ func (s *Server) handleGetProjectContext(w http.ResponseWriter, r *http.Request) pCtx, err := s.ctxMgr().GetProjectContext(r.Context(), projectID) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, "get project context failed: "+err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } diff --git a/internal/server/handlers_docgov.go b/internal/server/handlers_docgov.go index 47e04006..844eb7e7 100644 --- a/internal/server/handlers_docgov.go +++ b/internal/server/handlers_docgov.go @@ -42,7 +42,7 @@ func (s *Server) handleDocGovGetDocument(w http.ResponseWriter, r *http.Request) doc, err := s.gov().GetDocument(r.Context(), docID) if err != nil { - writeSafeError(w, r, http.StatusNotFound, "get document failed: "+err.Error()) + writeDomainError(w, r, http.StatusNotFound, err) return } @@ -53,7 +53,7 @@ func (s *Server) handleDocGovGetDocument(w http.ResponseWriter, r *http.Request) func (s *Server) handleDocGovClaim(w http.ResponseWriter, r *http.Request) { var req DocGovClaimRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } if req.DocID == "" { @@ -73,7 +73,7 @@ func (s *Server) handleDocGovClaim(w http.ResponseWriter, r *http.Request) { doc, err := s.gov().RecordDocumentViewed(r.Context(), req.DocID, user.ID, s.resolveDocGovRole(r.Context(), req.DocID, user), user.Name) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, "claim document failed: "+err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } @@ -89,7 +89,7 @@ func (s *Server) handleDocGovSubmitReview(w http.ResponseWriter, r *http.Request var req DocGovSubmitReviewRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } @@ -103,7 +103,7 @@ func (s *Server) handleDocGovSubmitReview(w http.ResponseWriter, r *http.Request doc, err := s.gov().SubmitForReview(r.Context(), req.DocID, user.ID, vocabulary.RoleAuthor, user.Name) if err != nil { - writeSafeError(w, r, http.StatusBadRequest, "submit review failed: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } @@ -119,7 +119,7 @@ func (s *Server) handleDocGovReviewDecision(w http.ResponseWriter, r *http.Reque var req DocGovDecisionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } @@ -149,7 +149,7 @@ func (s *Server) handleDocGovReviewDecision(w http.ResponseWriter, r *http.Reque } if err != nil { - writeSafeError(w, r, http.StatusBadRequest, "review decision failed: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } @@ -165,7 +165,7 @@ func (s *Server) handleDocGovApproverDecision(w http.ResponseWriter, r *http.Req var req DocGovDecisionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } @@ -207,14 +207,14 @@ func (s *Server) handleDocGovApproverDecision(w http.ResponseWriter, r *http.Req "request_id", httpx.RequestID(r), "doc_id", req.DocID, "action", action, "user_id", user.ID, "error", err.Error()) if errors.Is(err, uow.ErrPermissionDenied) { - writeSafeError(w, r, http.StatusForbidden, "approver decision failed: "+err.Error()) + writeDomainError(w, r, http.StatusForbidden, err) return } if errors.Is(err, uow.ErrBusy) { - writeSafeError(w, r, http.StatusConflict, "approver decision failed: "+err.Error()) + writeDomainError(w, r, http.StatusConflict, err) return } - writeSafeError(w, r, http.StatusBadRequest, "approver decision failed: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } @@ -230,7 +230,7 @@ func (s *Server) handleDocGovProposeModification(w http.ResponseWriter, r *http. var req ProposeModificationRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } if strings.TrimSpace(req.DocID) == "" { @@ -268,7 +268,7 @@ func (s *Server) handleDocGovProposeModification(w http.ResponseWriter, r *http. writeSafeError(w, r, status, "document not found") return } - writeSafeError(w, r, http.StatusBadRequest, "propose modification failed: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } @@ -284,7 +284,7 @@ func (s *Server) handleDocGovDecideProposal(w http.ResponseWriter, r *http.Reque var req DecideProposalRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } if strings.TrimSpace(req.ProposalID) == "" { @@ -304,13 +304,13 @@ func (s *Server) handleDocGovDecideProposal(w http.ResponseWriter, r *http.Reque case http.StatusNotFound: writeSafeError(w, r, status, "proposal not found") case http.StatusForbidden: - writeSafeError(w, r, status, "decide proposal failed: "+err.Error()) + writeDomainError(w, r, status, err) default: - writeSafeError(w, r, status, err.Error()) + writeDomainError(w, r, status, err) } return } - writeSafeError(w, r, http.StatusBadRequest, "decide proposal failed: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } @@ -346,7 +346,7 @@ func (s *Server) handleDocGovListComments(w http.ResponseWriter, r *http.Request comments, err := s.gov().ListComments(r.Context(), docID) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, "list comments failed: "+err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } @@ -358,7 +358,7 @@ func (s *Server) handleDocGovListComments(w http.ResponseWriter, r *http.Request func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request) { var req AddCommentRequestDTO if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } @@ -382,7 +382,7 @@ func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request) s.resolveDocGovRole(r.Context(), req.DocID, user), req.TargetSection, cType, req.Content, ) if err != nil { - writeDocGovError(w, err) + writeDocGovError(w, r, err) return } @@ -424,7 +424,7 @@ func (s *Server) handleDocGovResolveComment(w http.ResponseWriter, r *http.Reque role := s.resolveDocGovRole(r.Context(), docID, user) comment, err := s.gov().ResolveComment(r.Context(), commentID, user.ID, role, resolve) if err != nil { - writeDocGovError(w, err) + writeDocGovError(w, r, err) return } writeJSONResponse(w, http.StatusOK, comment) @@ -449,7 +449,7 @@ func (s *Server) handleDocGovListAuditLogs(w http.ResponseWriter, r *http.Reques logs, err := s.gov().ListAuditLogs(r.Context(), docID) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, "list audit logs failed: "+err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } diff --git a/internal/server/handlers_docgov_lease.go b/internal/server/handlers_docgov_lease.go index b68240db..77dffc01 100644 --- a/internal/server/handlers_docgov_lease.go +++ b/internal/server/handlers_docgov_lease.go @@ -21,7 +21,7 @@ func (s *Server) handleDocGovSaveContent(w http.ResponseWriter, r *http.Request) } var req SaveContentRequestDTO if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } if s.gov() == nil { @@ -44,7 +44,7 @@ func (s *Server) handleDocGovSaveContent(w http.ResponseWriter, r *http.Request) FencingToken: req.FencingToken, }) if err != nil { - writeDocGovError(w, err) + writeDocGovError(w, r, err) return } writeJSONResponse(w, http.StatusOK, res) @@ -56,7 +56,7 @@ func (s *Server) handleDocGovAcquireLease(w http.ResponseWriter, r *http.Request } var req LeaseRequestDTO if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } if s.gov() == nil { @@ -68,7 +68,7 @@ func (s *Server) handleDocGovAcquireLease(w http.ResponseWriter, r *http.Request return } if err := s.gov().CheckHumanCanEdit(r.Context(), req.DocID, user.ID, vocabulary.RoleAuthor, true); err != nil { - writeDocGovError(w, err) + writeDocGovError(w, r, err) return } req.HolderID = fmt.Sprintf("%d", user.ID) @@ -86,7 +86,7 @@ func (s *Server) handleDocGovAcquireLease(w http.ResponseWriter, r *http.Request TTL: ttl, }) if err != nil { - writeDocGovError(w, err) + writeDocGovError(w, r, err) return } writeJSONResponse(w, http.StatusOK, doc) @@ -99,7 +99,7 @@ func (s *Server) handleDocGovHeartbeatLease(w http.ResponseWriter, r *http.Reque } var req LeaseRequestDTO if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } if s.gov() == nil { @@ -119,7 +119,7 @@ func (s *Server) handleDocGovHeartbeatLease(w http.ResponseWriter, r *http.Reque ttl := time.Duration(req.TTLSeconds) * time.Second doc, err := s.gov().HeartbeatLease(r.Context(), req.DocID, req.HolderType, req.HolderID, ttl) if err != nil { - writeDocGovError(w, err) + writeDocGovError(w, r, err) return } writeJSONResponse(w, http.StatusOK, doc) @@ -132,7 +132,7 @@ func (s *Server) handleDocGovReleaseLease(w http.ResponseWriter, r *http.Request } var req LeaseRequestDTO if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } if s.gov() == nil { @@ -151,7 +151,7 @@ func (s *Server) handleDocGovReleaseLease(w http.ResponseWriter, r *http.Request } doc, err := s.gov().ReleaseLease(r.Context(), req.DocID, req.HolderType, req.HolderID) if err != nil { - writeDocGovError(w, err) + writeDocGovError(w, r, err) return } writeJSONResponse(w, http.StatusOK, doc) diff --git a/internal/server/handlers_http.go b/internal/server/handlers_http.go index bfd755ae..9cfd3d3b 100644 --- a/internal/server/handlers_http.go +++ b/internal/server/handlers_http.go @@ -1,9 +1,12 @@ package server import ( + "context" "encoding/json" "net/http" + "strings" + "chat-agent-system/internal/errcode" "chat-agent-system/internal/httpx" ) @@ -14,5 +17,93 @@ func writeJSONResponse(w http.ResponseWriter, status int, data any) { } func writeSafeError(w http.ResponseWriter, r *http.Request, status int, legacy string) { - httpx.Write(w, status, httpx.CodeFromStatus(status), legacy, httpx.RequestID(r), nil) + emitError(w, r, status, errcode.FromLegacy(status, legacy), legacy) +} + +func writeDomainError(w http.ResponseWriter, r *http.Request, status int, err error) { + if err == nil { + writeSafeError(w, r, status, "") + return + } + if st, ok := domainHTTPStatus(err); ok { + def := errcode.FromStatus(st) + if d, found := errcode.FromError(err); found { + def = d + } + emitError(w, r, st, def, err.Error()) + return + } + if def, found := errcode.FromError(err); found { + // 处理器传入的状态是既有 HTTP 契约。注册表状态只在调用方未给出时使用。 + st := status + if st == 0 { + st = def.Status + } + if st == 0 { + st = http.StatusInternalServerError + } + emitError(w, r, st, def, err.Error()) + return + } + emitError(w, r, status, errcode.FromLegacy(status, err.Error()), err.Error()) +} + +func emitError(w http.ResponseWriter, r *http.Request, status int, def errcode.Def, legacy string) { + if def.ID == "" { + def = errcode.FromStatus(status) + } + details := map[string]any{} + if def.Guide != "" { + details["guide"] = def.Guide + } + if def.Module != "" { + details["module"] = def.Module + } + rid := incomingRequestID(w, r) + httpx.WriteHTTP(w, r, status, def.ID, legacy, rid, def.Message, details) + if rid == "" && w != nil { + rid = w.Header().Get(httpx.HeaderRequestID) + } + reportHTTPError(r, status, def, rid, legacy) +} + +func incomingRequestID(w http.ResponseWriter, r *http.Request) string { + if r != nil { + if v := strings.TrimSpace(r.Header.Get(httpx.HeaderRequestID)); v != "" { + return v + } + } + if w != nil { + if v := strings.TrimSpace(w.Header().Get(httpx.HeaderRequestID)); v != "" { + return v + } + } + return "" +} + +func reportHTTPError(r *http.Request, status int, def errcode.Def, requestID, legacy string) { + ev := errcode.Event{ + Code: def.ID, + Class: def.Class, + HTTPStatus: status, + RequestID: requestID, + Module: def.Module, + Source: "http", + Retryable: def.Retryable, + UserMessage: def.Message, + Upstream: legacy, + } + ctx := context.Background() + if r != nil { + ev.Method = r.Method + ev.Path = r.URL.Path + if sid := strings.TrimSpace(r.URL.Query().Get("session_id")); sid != "" { + ev.SessionID = sid + } + if user := principalFromContext(r.Context()); user != nil { + ev.ActorUserID = user.ID + } + ctx = r.Context() + } + errcode.Report(ctx, ev) } diff --git a/internal/server/maintenance_handlers.go b/internal/server/maintenance_handlers.go index 8d44286b..b1406d2d 100644 --- a/internal/server/maintenance_handlers.go +++ b/internal/server/maintenance_handlers.go @@ -26,7 +26,7 @@ func (s *Server) handleMaintenanceStatus(w http.ResponseWriter, r *http.Request) } view, err := s.maintenance().CurrentStatus(r.Context()) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } writeJSONResponse(w, http.StatusOK, view) @@ -52,7 +52,7 @@ func (s *Server) handleAdminMaintenanceList(w http.ResponseWriter, r *http.Reque } list, err := s.maintenance().ListRecent(r.Context(), 20) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } writeJSONResponse(w, http.StatusOK, map[string]any{"windows": list}) @@ -86,11 +86,11 @@ func (s *Server) handleAdminMaintenanceSchedule(w http.ResponseWriter, r *http.R }) switch { case errors.Is(err, maintenance.ErrInvalidWindow): - writeSafeError(w, r, http.StatusBadRequest, err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) case errors.Is(err, maintenance.ErrConflictOpen): writeSafeError(w, r, http.StatusConflict, "已有未结束的维护窗口") case err != nil: - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) default: writeJSONResponse(w, http.StatusOK, wnd) } @@ -114,7 +114,7 @@ func (s *Server) handleAdminMaintenanceCancel(w http.ResponseWriter, r *http.Req case errors.Is(err, maintenance.ErrBadStatus): writeSafeError(w, r, http.StatusConflict, "当前状态不可取消") case err != nil: - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) default: writeJSONResponse(w, http.StatusOK, wnd) } diff --git a/internal/server/me_settings_handlers.go b/internal/server/me_settings_handlers.go index 8a43a5f1..090e5bab 100644 --- a/internal/server/me_settings_handlers.go +++ b/internal/server/me_settings_handlers.go @@ -55,7 +55,7 @@ func (s *Server) handleMeProfile(w http.ResponseWriter, r *http.Request) { Timezone: patch.Timezone, }) if err != nil { - writeSafeError(w, r, http.StatusBadRequest, err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } writeJSONResponse(w, http.StatusOK, saved) @@ -74,7 +74,7 @@ func (s *Server) handleMePreferences(w http.ResponseWriter, r *http.Request) { case http.MethodGet: prefs, err := s.authSvc().GetUserPreferences(r.Context(), user.ID) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } writeJSONResponse(w, http.StatusOK, prefs) @@ -86,7 +86,7 @@ func (s *Server) handleMePreferences(w http.ResponseWriter, r *http.Request) { } prefs, err := s.authSvc().SaveUserPreferences(r.Context(), user.ID, body) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } writeJSONResponse(w, http.StatusOK, prefs) diff --git a/internal/server/notification_handlers.go b/internal/server/notification_handlers.go index 09abe4da..c4494f41 100644 --- a/internal/server/notification_handlers.go +++ b/internal/server/notification_handlers.go @@ -33,7 +33,7 @@ func (s *Server) handleListProjectNotifications(w http.ResponseWriter, r *http.R items, err := s.notifySvc().ListProjectNotifications(r.Context(), user.ID, projectID, sinceID, limit) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, "list notifications failed: "+err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } writeJSONResponse(w, http.StatusOK, map[string]any{ @@ -76,7 +76,7 @@ func (s *Server) handleListUserNotifications(w http.ResponseWriter, r *http.Requ items, err := s.listAllScopeNotifications(r.Context(), user.ID, sinceID, limit) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, "list notifications failed: "+err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } writeJSONResponse(w, http.StatusOK, map[string]any{ diff --git a/internal/server/notification_reminder_loop.go b/internal/server/notification_reminder_loop.go index e2dee0fb..42da6735 100644 --- a/internal/server/notification_reminder_loop.go +++ b/internal/server/notification_reminder_loop.go @@ -4,6 +4,8 @@ import ( "context" "log/slog" "time" + + "chat-agent-system/internal/errcode" ) const defaultReminderPollInterval = 15 * time.Minute @@ -38,6 +40,12 @@ func (s *Server) runStageAdvanceReminders(ctx context.Context) { n, err := s.notifySvc().ProcessStageAdvanceReminders(ctx) if err != nil { slog.Warn("stage advance reminder scan failed", "error", err) + errcode.Report(ctx, errcode.Event{ + Code: "REMINDER_SCAN_FAILED", + Module: "notification", + Source: "async", + Upstream: err.Error(), + }) return } if n > 0 { diff --git a/internal/server/project_doc_handlers.go b/internal/server/project_doc_handlers.go index 8825c40b..1cf85b13 100644 --- a/internal/server/project_doc_handlers.go +++ b/internal/server/project_doc_handlers.go @@ -94,7 +94,7 @@ func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *ht } docs, err := s.gov().ListWorkflowDocumentsByProject(r.Context(), projectID) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, "list project documents failed: "+err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } docs = s.filterWorkflowDocsByStageAccess(r.Context(), projectID, user.ID, docs) diff --git a/internal/server/project_handlers.go b/internal/server/project_handlers.go index 167c4573..0e828844 100644 --- a/internal/server/project_handlers.go +++ b/internal/server/project_handlers.go @@ -21,7 +21,7 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) { return } if ok, permErr := s.userHasSystemPermission(r.Context(), user, "project:create"); permErr != nil { - writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+permErr.Error()+`"}`) + writeDomainError(w, r, http.StatusInternalServerError, permErr) return } else if !ok { writeJSONResponse(w, http.StatusForbidden, map[string]string{ @@ -40,7 +40,7 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) { proj, err := s.pwSvc().CreateProject(r.Context(), user, req) if err != nil { if errors.Is(err, projectworkflow.ErrInvalidInput) { - writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"`+err.Error()+`"}`) + writeDomainError(w, r, http.StatusBadRequest, err) return } if errors.Is(err, projectworkflow.ErrDuplicateProjectName) { @@ -51,10 +51,10 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) { return } if errors.Is(err, projectworkflow.ErrWorkflowNotFound) { - writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_WORKFLOW_NOT_FOUND","message":"`+err.Error()+`"}`) + writeDomainError(w, r, http.StatusBadRequest, err) return } - writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) + writeDomainError(w, r, http.StatusInternalServerError, err) return } @@ -93,7 +93,7 @@ func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) { list, err = s.pwSvc().ListProjects(r.Context(), user.ID) } if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) + writeDomainError(w, r, http.StatusInternalServerError, err) return } if list == nil { @@ -122,7 +122,7 @@ func (s *Server) handleGetProjectByID(w http.ResponseWriter, r *http.Request) { writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_PROJECT_NOT_FOUND","message":"项目不存在或无权限访问"}`) return } - writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) + writeDomainError(w, r, http.StatusInternalServerError, err) return } @@ -143,7 +143,7 @@ func (s *Server) handleListProjectWorkflows(w http.ResponseWriter, r *http.Reque wfs, err := s.pwSvc().ListProjectWorkflows(r.Context(), id) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) + writeDomainError(w, r, http.StatusInternalServerError, err) return } if wfs == nil { @@ -201,7 +201,7 @@ func (s *Server) handleActivateWorkflow(w http.ResponseWriter, r *http.Request) writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_PROJECT_NOT_FOUND","message":"项目不存在"}`) return } - writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) + writeDomainError(w, r, http.StatusInternalServerError, err) return } @@ -274,7 +274,7 @@ func (s *Server) handleListProjectWorkflowRuns(w http.ResponseWriter, r *http.Re wfKey := r.PathValue("workflow_key") runs, err := s.pwSvc().ListWorkflowRuns(r.Context(), id, wfKey, 20) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) + writeDomainError(w, r, http.StatusInternalServerError, err) return } if runs == nil { @@ -307,7 +307,7 @@ func (s *Server) handleGetProjectLineage(w http.ResponseWriter, r *http.Request) } lineages, err := s.pwSvc().GetLineageForProjectDownstream(r.Context(), projectID, docID, 0) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) + writeDomainError(w, r, http.StatusInternalServerError, err) return } if len(lineages) == 0 { diff --git a/internal/server/project_members_handlers.go b/internal/server/project_members_handlers.go index d100ca3c..47a570c6 100644 --- a/internal/server/project_members_handlers.go +++ b/internal/server/project_members_handlers.go @@ -75,7 +75,7 @@ func (s *Server) handleListProjectMembers(w http.ResponseWriter, r *http.Request } members, err := s.authorizer().ListProjectMembers(r.Context(), projectID) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } if members == nil { @@ -87,7 +87,7 @@ func (s *Server) handleListProjectMembers(w http.ResponseWriter, r *http.Request func (s *Server) requireProjectMemberManage(w http.ResponseWriter, r *http.Request, user *auth.UserAccount, projectID int64) bool { can, err := s.authzSvc().CanManageProjectMembers(r.Context(), projectID, user.ID, user.RoleID) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return false } if !can { @@ -113,7 +113,7 @@ func (s *Server) handleAddProjectMember(w http.ResponseWriter, r *http.Request, ProjectRole: req.ProjectRole, DocumentDuty: req.DocumentDuty, }); err != nil { - writeSafeError(w, r, http.StatusBadRequest, err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } if err := s.healProjectDuties(r.Context(), projectID); err != nil { @@ -154,7 +154,7 @@ func (s *Server) handleUpdateProjectMember(w http.ResponseWriter, r *http.Reques if s.authorizer() != nil { rec, err := s.authorizer().GetProjectMember(r.Context(), projectID, req.UserID) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } existing = rec @@ -173,14 +173,14 @@ func (s *Server) handleUpdateProjectMember(w http.ResponseWriter, r *http.Reques ProjectRole: role, DocumentDuty: req.DocumentDuty, }); err != nil { - writeSafeError(w, r, http.StatusBadRequest, err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } wrote = true } if req.Status != "" { if err := s.authorizer().SetProjectMemberStatus(r.Context(), projectID, req.UserID, req.Status); err != nil { - writeSafeError(w, r, http.StatusBadRequest, err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } wrote = true @@ -205,7 +205,7 @@ func (s *Server) handleRemoveProjectMember(w http.ResponseWriter, r *http.Reques return } if err := s.authorizer().SetProjectMemberStatus(r.Context(), projectID, targetID, authz.StatusExited); err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } if err := s.healProjectDuties(r.Context(), projectID); err != nil { diff --git a/internal/server/project_settings_handlers.go b/internal/server/project_settings_handlers.go index c25fda73..2d6d8344 100644 --- a/internal/server/project_settings_handlers.go +++ b/internal/server/project_settings_handlers.go @@ -194,7 +194,7 @@ func writeProjectInstanceError(w http.ResponseWriter, err error) { case errors.Is(err, projectinstance.ErrProjectNotFound): writeSafeError(w, nil, http.StatusNotFound, `{"error":"ERR_PROJECT_NOT_FOUND","message":"项目不存在"}`) case errors.Is(err, projectinstance.ErrInvalidPlan): - writeSafeError(w, nil, http.StatusBadRequest, `{"error":"ERR_INVALID_PLAN","message":"`+err.Error()+`"}`) + writeDomainError(w, nil, http.StatusBadRequest, err) case errors.Is(err, projectinstance.ErrInstanceNotFound): writeSafeError(w, nil, http.StatusNotFound, `{"error":"ERR_INSTANCE_NOT_FOUND","message":"工作流实例不存在"}`) case errors.Is(err, projectinstance.ErrStageGateNotReady): @@ -232,7 +232,7 @@ func writeProjectInstanceError(w http.ResponseWriter, err error) { }) return } - writeSafeError(w, nil, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) + writeDomainError(w, nil, http.StatusInternalServerError, err) } } @@ -247,7 +247,7 @@ func (s *Server) blockLegacyWorkflowSwitch(w http.ResponseWriter, r *http.Reques } live, err := s.instSvc().HasLiveInstance(r.Context(), projectID) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) + writeDomainError(w, r, http.StatusInternalServerError, err) return true } if live { diff --git a/internal/server/reconcile_handlers.go b/internal/server/reconcile_handlers.go index 431f86c0..b4fecdb1 100644 --- a/internal/server/reconcile_handlers.go +++ b/internal/server/reconcile_handlers.go @@ -42,7 +42,7 @@ func (s *Server) handleReconcileWorkspace(w http.ResponseWriter, r *http.Request report, err := squad.ReconcileWorkspaceStatus(workDir) if err != nil { - writeSafeError(w, r, http.StatusBadRequest, err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } writeJSONResponse(w, http.StatusOK, report) diff --git a/internal/server/server.go b/internal/server/server.go index 8667fd20..a6059857 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -346,6 +346,7 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /api/v1/admin/system/features", s.handleAdminSystemFeatures) mux.HandleFunc("GET /api/v1/admin/system/settings", s.handleAdminSystemSettings) mux.HandleFunc("GET /api/v1/admin/system/settings/{key}/audits", s.handleAdminSystemSettingAudits) + mux.HandleFunc("GET /api/v1/admin/system/error-events", s.handleAdminErrorEvents) mux.HandleFunc("PUT /api/v1/admin/system/settings/{key}", s.handleAdminSystemSettingByKey) mux.HandleFunc("GET /api/v1/maintenance/status", s.handleMaintenanceStatus) diff --git a/internal/server/software_project_handlers.go b/internal/server/software_project_handlers.go index 4a47bd84..5e718e8d 100644 --- a/internal/server/software_project_handlers.go +++ b/internal/server/software_project_handlers.go @@ -38,7 +38,7 @@ func (s *Server) handleProjectCreationPreview(w http.ResponseWriter, r *http.Req return } if ok, permErr := s.userHasSystemPermission(r.Context(), user, "project:create"); permErr != nil { - writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+permErr.Error()+`"}`) + writeDomainError(w, r, http.StatusInternalServerError, permErr) return } else if !ok { writeJSONResponse(w, http.StatusForbidden, map[string]string{ diff --git a/internal/server/sse.go b/internal/server/sse.go index 053f0a74..3cc7fd70 100644 --- a/internal/server/sse.go +++ b/internal/server/sse.go @@ -145,7 +145,7 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) { var req StreamChatRequest if r.Method == http.MethodPost { if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } } else { @@ -209,7 +209,7 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) { }) return } - writeSafeError(w, r, http.StatusInternalServerError, "resolve session failed: "+err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } s.hydrateVisibleHistory(r.Context(), sess, user.ID) @@ -375,7 +375,7 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) { includeDetails := false if r.Method == http.MethodPost { if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } } else { @@ -442,7 +442,7 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) { var reused *taskrun.RunRecord if idemKey := taskrun.BuildTaskRunIdempotencyKey(req.SessionID, req.ClientTurnID); idemKey != "" && s.taskRuns() != nil { if existing, findErr := s.taskRuns().FindByIdempotencyKey(r.Context(), idemKey); findErr != nil { - writeSafeError(w, r, http.StatusInternalServerError, findErr.Error()) + writeDomainError(w, r, http.StatusInternalServerError, findErr) return } else if existing != nil { if existing.UserID != req.UserID || existing.SessionID != req.SessionID || @@ -464,7 +464,7 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) { var err error created, err = s.taskRuns().CreateAndStart(r.Context(), runID, record, taskReq, stageReq, req.WorkDir) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, "start task run failed: "+err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } } @@ -473,7 +473,7 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) { ch, replay, err := s.taskRuns().Subscribe(created.RunID, 0) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } defer s.taskRuns().Unsubscribe(created.RunID, ch) diff --git a/internal/server/swboot_handlers.go b/internal/server/swboot_handlers.go index d25f6b8a..0b5435b3 100644 --- a/internal/server/swboot_handlers.go +++ b/internal/server/swboot_handlers.go @@ -85,7 +85,7 @@ func (s *Server) handleSwbootExecutions(w http.ResponseWriter, r *http.Request) case http.MethodPost: exec, err := s.swbootExec() if err != nil { - writeSafeError(w, r, http.StatusServiceUnavailable, err.Error()) + writeDomainError(w, r, http.StatusServiceUnavailable, err) return } var req swbootExecutionRequest @@ -160,7 +160,7 @@ func (s *Server) handleSwbootBinding(w http.ResponseWriter, r *http.Request) { } exec, err := s.swbootExec() if err != nil { - writeSafeError(w, r, http.StatusServiceUnavailable, err.Error()) + writeDomainError(w, r, http.StatusServiceUnavailable, err) return } var req swbootBindingRequest diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index 601248f1..e2dc98bc 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -53,7 +53,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { } var req createTaskRunRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } req.UserID = user.ID @@ -67,7 +67,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { } } if err := bizdev.ValidateTaskRunIdentity(req.SessionID, req.ProjectID); err != nil { - writeSafeError(w, r, http.StatusBadRequest, err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } if req.TaskKey == "" && req.Stage == "" { @@ -87,7 +87,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { idemKey := taskrun.BuildTaskRunIdempotencyKey(req.SessionID, req.ClientTurnID) if idemKey != "" && s.taskRuns() != nil { if existing, findErr := s.taskRuns().FindByIdempotencyKey(r.Context(), idemKey); findErr != nil { - writeSafeError(w, r, http.StatusInternalServerError, findErr.Error()) + writeDomainError(w, r, http.StatusInternalServerError, findErr) return } else if existing != nil { if !taskRunMatchesIdempotentOwner(existing, req) { @@ -135,7 +135,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { return } if busy, preflightErr := s.preflightTaskDocumentBusy(r.Context(), req); preflightErr != nil { - writeSafeError(w, r, http.StatusBadRequest, preflightErr.Error()) + writeDomainError(w, r, http.StatusBadRequest, preflightErr) return } else if busy != nil { s.recordBusyPreflightMessages(r.Context(), ctxMgr, req, taskScope, busy) @@ -166,7 +166,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { } claimed, created, claimErr := s.taskRuns().ClaimRun(r.Context(), record) if claimErr != nil { - writeSafeError(w, r, http.StatusInternalServerError, claimErr.Error()) + writeDomainError(w, r, http.StatusInternalServerError, claimErr) return } if !created { @@ -223,7 +223,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { stageReq := s.buildBizdevStageRequest(req.TaskRunRequestDTO, req.SessionID, req.ClientTurnID, claimed.RunID) createdRun, err := s.taskRuns().StartClaimedRun(r.Context(), claimed, taskReq, stageReq, resumeDir) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } @@ -258,7 +258,7 @@ func (s *Server) createBlockedGovernanceRun(w http.ResponseWriter, r *http.Reque } claimed, created, claimErr := s.taskRuns().ClaimRun(r.Context(), record) if claimErr != nil { - writeSafeError(w, r, http.StatusInternalServerError, claimErr.Error()) + writeDomainError(w, r, http.StatusInternalServerError, claimErr) return } if !created { @@ -498,7 +498,7 @@ func (s *Server) handleTaskRunEvents(w http.ResponseWriter, r *http.Request) { // (same order as handleChatStream / CreateAndStart in sse.go). ch, replay, err := s.taskRuns().Subscribe(runID, afterSeq) if err != nil { - writeSafeError(w, r, http.StatusNotFound, err.Error()) + writeDomainError(w, r, http.StatusNotFound, err) return } defer s.taskRuns().Unsubscribe(runID, ch) @@ -584,7 +584,7 @@ func (s *Server) handleCancelTaskRun(w http.ResponseWriter, r *http.Request) { return } if err := s.taskRuns().Cancel(runID); err != nil { - writeSafeError(w, r, http.StatusNotFound, err.Error()) + writeDomainError(w, r, http.StatusNotFound, err) return } if rec, getErr := s.taskRuns().GetRun(runID); getErr == nil && rec.WorkspaceID != "" { @@ -624,7 +624,7 @@ func (s *Server) handleGetTaskRun(w http.ResponseWriter, r *http.Request) { } rec, err := s.taskRuns().GetRun(runID) if err != nil { - writeSafeError(w, r, http.StatusNotFound, err.Error()) + writeDomainError(w, r, http.StatusNotFound, err) return } writeJSONResponse(w, http.StatusOK, s.buildTaskRunResponse(rec)) @@ -646,7 +646,7 @@ func (s *Server) handleResumeTaskRun(w http.ResponseWriter, r *http.Request) { var req resumeTaskRunRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } if !req.Confirm { @@ -659,7 +659,7 @@ func (s *Server) handleResumeTaskRun(w http.ResponseWriter, r *http.Request) { rec, err := s.taskRuns().GetRun(runID) if err != nil { - writeSafeError(w, r, http.StatusNotFound, err.Error()) + writeDomainError(w, r, http.StatusNotFound, err) return } if rec.Status != "interrupted" { @@ -711,7 +711,7 @@ func (s *Server) handleResumeTaskRun(w http.ResponseWriter, r *http.Request) { }) return } - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } diff --git a/internal/server/trace_handlers.go b/internal/server/trace_handlers.go index 30a0b77f..78d96fe6 100644 --- a/internal/server/trace_handlers.go +++ b/internal/server/trace_handlers.go @@ -20,7 +20,7 @@ func (s *Server) handleProjectTrace(w http.ResponseWriter, r *http.Request) { } items, err := s.rt.Trace.ListItems(r.Context(), projectID) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) + writeDomainError(w, r, http.StatusInternalServerError, err) return } visible, hidden := traceimpact.RedactItems(items, func(docID string) bool { @@ -28,17 +28,17 @@ func (s *Server) handleProjectTrace(w http.ResponseWriter, r *http.Request) { }) index, err := s.rt.Trace.ListIndex(r.Context(), projectID) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) + writeDomainError(w, r, http.StatusInternalServerError, err) return } marks, err := s.rt.Trace.ListOpenMarks(r.Context(), projectID, "") if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) + writeDomainError(w, r, http.StatusInternalServerError, err) return } candidates, err := s.rt.Trace.ListCandidateEdges(r.Context(), projectID) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) + writeDomainError(w, r, http.StatusInternalServerError, err) return } writeJSONResponse(w, http.StatusOK, map[string]any{ @@ -83,7 +83,7 @@ func (s *Server) handleProjectTraceAnalyze(w http.ResponseWriter, r *http.Reques } rep, err := s.rt.Trace.Analyze(traceimpact.WithActor(r.Context(), user.ID), projectID, body.DocRef, body.Others) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) + writeDomainError(w, r, http.StatusInternalServerError, err) return } writeJSONResponse(w, http.StatusOK, rep) @@ -194,7 +194,7 @@ func (s *Server) handleProjectTraceRecheckClear(w http.ResponseWriter, r *http.R } marks, err := s.rt.Trace.ListOpenMarks(r.Context(), projectID, "") if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) + writeDomainError(w, r, http.StatusInternalServerError, err) return } found := false diff --git a/internal/server/user_directory_handlers.go b/internal/server/user_directory_handlers.go index 2b68f16f..019af229 100644 --- a/internal/server/user_directory_handlers.go +++ b/internal/server/user_directory_handlers.go @@ -21,7 +21,7 @@ func (s *Server) handleUserDirectory(w http.ResponseWriter, r *http.Request) { } users, err := s.authSvc().ListUserDirectory(r.Context()) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } writeJSONResponse(w, http.StatusOK, map[string]any{"users": users}) @@ -67,7 +67,7 @@ func (s *Server) handleAdminUpdateProfile(w http.ResponseWriter, r *http.Request EmployeeID: body.EmployeeID, }, actor.ID) if err != nil { - writeSafeError(w, r, http.StatusBadRequest, err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } writeJSONResponse(w, http.StatusOK, updated) diff --git a/internal/server/workflow_catalog_handlers.go b/internal/server/workflow_catalog_handlers.go index b51b778f..31d427c2 100644 --- a/internal/server/workflow_catalog_handlers.go +++ b/internal/server/workflow_catalog_handlers.go @@ -22,7 +22,7 @@ func (s *Server) handleWorkflowCatalog(w http.ResponseWriter, r *http.Request) { } defs, err := s.wfDefSvc().Store().ListProjectSelectableDefinitions(r.Context()) if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) + writeDomainError(w, r, http.StatusInternalServerError, err) return } productDomain := strings.TrimSpace(r.URL.Query().Get("product_domain")) diff --git a/internal/server/workflow_terminate_handlers.go b/internal/server/workflow_terminate_handlers.go index b46764f7..e816bb11 100644 --- a/internal/server/workflow_terminate_handlers.go +++ b/internal/server/workflow_terminate_handlers.go @@ -72,7 +72,7 @@ func (s *Server) handleTerminateWorkflowInstance(w http.ResponseWriter, r *http. case errors.Is(err, projectinstance.ErrTerminationNotAllowed): writeSafeError(w, r, http.StatusConflict, `{"error":"ERR_TERMINATION_NOT_ALLOWED"}`) default: - writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) + writeDomainError(w, r, http.StatusInternalServerError, err) } return } diff --git a/internal/server/workspace_admin_handlers.go b/internal/server/workspace_admin_handlers.go index b845bbed..ff79c6d6 100644 --- a/internal/server/workspace_admin_handlers.go +++ b/internal/server/workspace_admin_handlers.go @@ -42,12 +42,12 @@ func (s *Server) serveAdminWorkspaces(w http.ResponseWriter, r *http.Request) { return } if err := reg.Discover(); err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } list, err := reg.List() if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + writeDomainError(w, r, http.StatusInternalServerError, err) return } var bytes int64 @@ -79,7 +79,7 @@ func (s *Server) serveAdminWorkspaceCleanup(w http.ResponseWriter, r *http.Reque } if body.Frozen != nil { if err := reg.SetRetention(id, *body.Frozen, body.Receipts); err != nil { - writeSafeError(w, r, http.StatusBadRequest, err.Error()) + writeDomainError(w, r, http.StatusBadRequest, err) return } } @@ -92,7 +92,7 @@ func (s *Server) serveAdminWorkspaceCleanup(w http.ResponseWriter, r *http.Reque if err == workspace.ErrWorkDirNotFound { status = http.StatusNotFound } - writeSafeError(w, r, status, err.Error()) + writeDomainError(w, r, status, err) return } writeJSONResponse(w, http.StatusOK, map[string]any{"deleted": id}) diff --git a/internal/stream/classify.go b/internal/stream/classify.go index 7754671d..2d5324f7 100644 --- a/internal/stream/classify.go +++ b/internal/stream/classify.go @@ -1,6 +1,7 @@ package stream import ( + "chat-agent-system/internal/errcode" "errors" "io" "net" @@ -28,6 +29,23 @@ func ClassifyError(err error, phaseID, requestID, sessionID, workspaceID string) info.Upstream = truncateRunes(raw, 80) } + if def, ok := errcode.FromError(err); ok { + info.Code = def.ID + info.Class = ErrorClass(def.Class) + if info.Class == "" { + info.Class = ErrorClassFatal + } + info.UserMessage = def.Message + info.Guide = def.Guide + if len(def.Actions) == 0 { + info.Actions = []string{} + } else { + info.Actions = append([]string(nil), def.Actions...) + } + info.Message = info.UserMessage + return info + } + switch { case strings.Contains(msg, "编辑中") || strings.Contains(msg, "禁止并行修改") || strings.Contains(msg, "edit lease"): info.Code = "DOC_BUSY" diff --git a/internal/stream/classify_test.go b/internal/stream/classify_test.go index 029a9194..b2331d2f 100644 --- a/internal/stream/classify_test.go +++ b/internal/stream/classify_test.go @@ -160,3 +160,21 @@ func TestClassifyError_BodyTooLargeIsFatalNotResumable(t *testing.T) { t.Fatalf("class=%s", info.Class) } } + +type codedBusy struct{} + +func (codedBusy) Error() string { return "plain failure" } +func (codedBusy) ErrorCode() string { return "DOC_BUSY" } + +func TestClassifyError_PrefersRegisteredCode(t *testing.T) { + info := ClassifyError(codedBusy{}, "phase", "req_typed", "sess", "") + if info.Code != "DOC_BUSY" { + t.Fatalf("code=%s", info.Code) + } + if info.Guide == "" || info.UserMessage == "" { + t.Fatalf("%+v", info) + } + if info.RequestID != "req_typed" { + t.Fatalf("request=%s", info.RequestID) + } +} diff --git a/internal/stream/types.go b/internal/stream/types.go index 83aa6f0b..70f48483 100644 --- a/internal/stream/types.go +++ b/internal/stream/types.go @@ -120,6 +120,7 @@ type ErrorInfo struct { Message string `json:"message,omitempty"` Code string `json:"code"` Class ErrorClass `json:"class"` + Guide string `json:"guide,omitempty"` PhaseID string `json:"phase_id,omitempty"` Upstream string `json:"upstream,omitempty"` RequestID string `json:"request_id,omitempty"` diff --git a/internal/taskrun/manager.go b/internal/taskrun/manager.go index ccf9c183..0500d316 100644 --- a/internal/taskrun/manager.go +++ b/internal/taskrun/manager.go @@ -10,6 +10,7 @@ import ( "chat-agent-system/internal/bizdev" "chat-agent-system/internal/ctxmgr" + "chat-agent-system/internal/errcode" "chat-agent-system/internal/projectworkflow" "chat-agent-system/internal/stream" ) @@ -495,6 +496,37 @@ func (m *Manager) finishRun(ar *activeRun, status, workspaceID, errMsg string, r "workspace_id", workspaceID, "error", errMsg, ) + if status == "failed" || status == "cancelled" || status == "finalizing_failed" { + code := "TASKRUN_BACKGROUND" + class := errcode.ClassFatal + requestID := "" + phaseID := "" + userMessage := errMsg + if errorInfo != nil { + if errorInfo.Code != "" { + code = errorInfo.Code + } + if errorInfo.Class != "" { + class = string(errorInfo.Class) + } + requestID = errorInfo.RequestID + phaseID = errorInfo.PhaseID + if errorInfo.UserMessage != "" { + userMessage = errorInfo.UserMessage + } + } + errcode.Report(context.Background(), errcode.Event{ + Code: code, + Class: class, + RequestID: requestID, + PhaseID: phaseID, + Module: "taskrun", + Source: "async", + WorkspaceID: workspaceID, + Upstream: errMsg, + UserMessage: userMessage, + }) + } if m.store != nil && m.store.Available() { _ = m.store.ReleaseLease(context.Background(), runID, m.workerID) }