feat: release v0.26 portable i18n and presentation adapters

This commit is contained in:
2026-09-29 13:31:42 +08:00
parent 6a95cad184
commit d1a747c36f
75 changed files with 5681 additions and 52 deletions

View File

@@ -1,5 +1,7 @@
# 统一错误目录
v0.26 新增的通用多语言适配见 [i18n](../i18n/README.md)。目录条目可显式引用 `translation: {namespace, key}`;`errori18n.Bind/Source` 支持从同一份 Go 目录生成源词典。翻译只影响公开展示,不改变业务码、HTTP 状态、失败语义、参数契约或 cause。没有引用或没有启用适配时沿用现有行为;幂等重放保留首次语言。该能力需要 v0.26.0-preview 或更新版本。
从 `v0.10.0-preview` 起,业务可以只返回逻辑错误标识,框架从项目目录取得 HTTP 状态、对外业务码与公开提示。目录不依赖具体业务、数据库、身份模型或网络层,也不定义固定返回体。
## 基本规则

View File

@@ -9,16 +9,19 @@ import (
"math"
"reflect"
"strings"
"unicode"
"unicode/utf8"
)
// Definition belongs to the project. Code is the public string/integer code;
// the Catalog map key is the distinct logical identifier used by ByCode.
// Nil Code retains legacy response code mapping (useful when migrating).
type Definition struct {
Status int `json:"status,omitempty"`
Code any `json:"code,omitempty"`
Message string `json:"message"`
Params []Parameter `json:"params,omitempty"`
Status int `json:"status,omitempty"`
Code any `json:"code,omitempty"`
Message string `json:"message"`
Params []Parameter `json:"params,omitempty"`
Translation *Translation `json:"translation,omitempty"`
}
type Catalog struct {
@@ -99,6 +102,10 @@ func (c Catalog) Clone() Catalog {
out := c
out.Definitions = make(map[string]Definition, len(c.Definitions))
for key, d := range c.Definitions {
if d.Translation != nil {
ref := *d.Translation
d.Translation = &ref
}
if d.Params != nil {
params := make([]Parameter, len(d.Params))
copy(params, d.Params)
@@ -126,6 +133,13 @@ func NewRegistry(c Catalog) (*Registry, error) {
}
messages := map[string][]messagePart{}
for key, d := range c.Definitions {
if d.Translation != nil {
for _, value := range []string{d.Translation.Namespace, d.Translation.Key} {
if value == "" || len(value) > 256 || !utf8.ValidString(value) || strings.TrimSpace(value) != value || strings.IndexFunc(value, unicode.IsControl) >= 0 {
return nil, fmt.Errorf("errors: %s has invalid translation reference", key)
}
}
}
if strings.TrimSpace(key) == "" || strings.TrimSpace(key) != key || strings.ContainsAny(key, "\r\n\t") {
return nil, fmt.Errorf("errors: invalid logical identifier %q", key)
}

46
errors/localization.go Normal file
View File

@@ -0,0 +1,46 @@
package apierrors
import "context"
// Translation is an explicit reference into an independently owned dictionary.
// It does not change the logical error key, status, public code or parameters.
type Translation struct {
Namespace string `json:"namespace"`
Key string `json:"key"`
}
// Localizer is an optional presentation adapter. false preserves the original
// public message. Implementations must support concurrent calls and must not
// mutate registry or failure, or expose causes/parameter values in diagnostics.
type Localizer interface {
Localize(context.Context, *Registry, *APIError) (string, bool)
}
// Definition returns detached metadata for the effective catalog scope.
func (r *Registry) Definition(key string) (Definition, bool) {
if r == nil {
return Definition{}, false
}
d, ok := r.catalog.Definitions[key]
if d.Params != nil {
d.Params = append([]Parameter{}, d.Params...)
}
if d.Translation != nil {
ref := *d.Translation
d.Translation = &ref
}
return d, ok
}
// Parameters returns detached public template arguments. This is for explicit
// presentation adapters, not automatic serialization into an API response.
func (e *APIError) Parameters() map[string]any {
if e == nil {
return nil
}
out := make(map[string]any, len(e.params))
for k, v := range e.params {
out[k] = v
}
return out
}