// 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 }