feat(errcode): 用稳定错误码记录 HTTP 与异步失败

失败响应此前不写日志,request_id 无法对照原因。注册表不覆盖处理器已经给出的 HTTP 状态。
This commit is contained in:
weichen
2026-09-28 02:27:16 +00:00
parent 3e534bbb39
commit f3f23a3018
66 changed files with 1846 additions and 176 deletions
+11
View File
@@ -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 {
+19
View File
@@ -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(),
})
}
+4
View File
@@ -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 {
+1
View File
@@ -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()
+2
View File
@@ -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
+9
View File
@@ -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
+414
View File
@@ -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
}
+64
View File
@@ -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"})
}
+233
View File
@@ -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)
}
}
+165
View File
@@ -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)
}
}
+151
View File
@@ -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
}
+52
View File
@@ -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)
}
}
+21 -1
View File
@@ -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 {
+59 -2
View File
@@ -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
}
+33
View File
@@ -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")
+2
View File
@@ -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 -4
View File
@@ -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())
}
}
+4 -4
View File
@@ -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})
+55 -3
View File
@@ -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})
}
+3 -3
View File
@@ -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})
+8 -8
View File
@@ -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 {
+4 -4
View File
@@ -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
}
+12 -12
View File
@@ -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")
+2 -2
View File
@@ -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"])
}
}
+1 -1
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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 == "" {
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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)
+129 -4
View File
@@ -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")
}
+6 -6
View File
@@ -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)
}
+4 -4
View File
@@ -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
}
+6 -6
View File
@@ -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
}
+4 -4
View File
@@ -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
}
+22 -22
View File
@@ -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
}
+9 -9
View File
@@ -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)
+92 -1
View File
@@ -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)
}
+5 -5
View File
@@ -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)
}
+3 -3
View File
@@ -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)
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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)
+10 -10
View File
@@ -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 {
+7 -7
View File
@@ -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 {
+3 -3
View File
@@ -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 {
+1 -1
View File
@@ -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)
+1
View File
@@ -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)
+1 -1
View File
@@ -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{
+6 -6
View File
@@ -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)
+2 -2
View File
@@ -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
+13 -13
View File
@@ -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
}
+6 -6
View File
@@ -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
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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
}
+4 -4
View File
@@ -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})
+18
View File
@@ -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"
+18
View File
@@ -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)
}
}
+1
View File
@@ -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"`
+32
View File
@@ -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)
}