415 lines
9.2 KiB
Go
415 lines
9.2 KiB
Go
// 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
|
|
}
|