feat(errcode): 用稳定错误码记录 HTTP 与异步失败
失败响应此前不写日志,request_id 无法对照原因。注册表不覆盖处理器已经给出的 HTTP 状态。
This commit is contained in:
@@ -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):以下为既有写者的真实表。
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user