merge: maestro/tsk_GXDoc3Cs07Rn [tsk_nI2T8qpcoier] apierr + idgen + CONVENTIONS.md
Apply changes from tsk_GXDoc3Cs07Rn (conflict rescue: original auto-merge
blocked by uncommitted changes on main; worktree was clean, no code conflicts).
Changes applied verbatim from b64c002:
- server/CONVENTIONS.md: new file — mandatory coding conventions
- server/internal/apierr: add New(), StatusFor(), ErrUnauthorized/Forbidden/
NotFound/Conflict predefined errors, chi Middleware; add apierr_test.go (8 tests)
- server/internal/idgen: implement idgen.go (UUID v7 + Crockford Base32 moved
from codes); add idgen_test.go (12 tests)
- server/internal/codes/generator.go: refactor to delegate to idgen.*
- server/go.mod: move google/uuid from indirect to direct dependency
All tests pass: go test ./internal/apierr/... ./internal/idgen/... ./internal/codes/...
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
// Package apierr defines the bilingual error response type used across all API modules.
|
||||
// Error messages follow the desensitisation rules: no "VPN" / "翻墙" / "科学上网" wording.
|
||||
// Package apierr defines the canonical error response shape used across all
|
||||
// v1 API handlers: {code, message_zh, message_en}. It provides constructor
|
||||
// helpers for common HTTP error categories (400/401/403/404/409/429/500)
|
||||
// and a middleware that serialises *Error values to JSON automatically.
|
||||
//
|
||||
// Error messages follow the desensitisation rules: no "VPN" / "翻墙" /
|
||||
// "科学上网" wording is permitted in any user-facing message.
|
||||
package apierr
|
||||
|
||||
import (
|
||||
@@ -8,15 +13,66 @@ import (
|
||||
)
|
||||
|
||||
// Error is the canonical API error body: {code, message_zh, message_en}.
|
||||
// All v1 handlers must return errors in this shape — never raw strings.
|
||||
type Error struct {
|
||||
Code string `json:"code"`
|
||||
MessageZH string `json:"message_zh"`
|
||||
MessageEn string `json:"message_en"`
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (e *Error) Error() string { return e.Code + ": " + e.MessageEn }
|
||||
|
||||
// Standard code-module errors.
|
||||
// New creates a new *Error with an application error code and bilingual messages.
|
||||
// Use this when none of the predefined errors fit the situation.
|
||||
func New(code, messageZH, messageEn string) *Error {
|
||||
return &Error{Code: code, MessageZH: messageZH, MessageEn: messageEn}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Predefined errors — common HTTP error categories
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// General HTTP-category errors (400 / 401 / 403 / 404 / 409 / 429 / 500).
|
||||
var (
|
||||
ErrBadRequest = &Error{
|
||||
Code: "BAD_REQUEST",
|
||||
MessageZH: "请求参数有误",
|
||||
MessageEn: "Invalid request parameters",
|
||||
}
|
||||
ErrUnauthorized = &Error{
|
||||
Code: "UNAUTHORIZED",
|
||||
MessageZH: "请先登录",
|
||||
MessageEn: "Authentication required",
|
||||
}
|
||||
ErrForbidden = &Error{
|
||||
Code: "FORBIDDEN",
|
||||
MessageZH: "权限不足",
|
||||
MessageEn: "Permission denied",
|
||||
}
|
||||
ErrNotFound = &Error{
|
||||
Code: "NOT_FOUND",
|
||||
MessageZH: "资源不存在",
|
||||
MessageEn: "Resource not found",
|
||||
}
|
||||
ErrConflict = &Error{
|
||||
Code: "CONFLICT",
|
||||
MessageZH: "资源状态冲突",
|
||||
MessageEn: "Resource state conflict",
|
||||
}
|
||||
ErrRateLimited = &Error{
|
||||
Code: "RATE_LIMITED",
|
||||
MessageZH: "操作过于频繁,请稍后再试",
|
||||
MessageEn: "Too many attempts, please try again later",
|
||||
}
|
||||
ErrInternal = &Error{
|
||||
Code: "INTERNAL_ERROR",
|
||||
MessageZH: "服务器内部错误,请稍后重试",
|
||||
MessageEn: "Internal server error, please try again later",
|
||||
}
|
||||
)
|
||||
|
||||
// Activation-code errors.
|
||||
var (
|
||||
ErrInvalidCode = &Error{
|
||||
Code: "INVALID_CODE",
|
||||
@@ -38,28 +94,15 @@ var (
|
||||
MessageZH: "该激活码已失效",
|
||||
MessageEn: "This code is no longer valid",
|
||||
}
|
||||
ErrRateLimited = &Error{
|
||||
Code: "RATE_LIMITED",
|
||||
MessageZH: "操作过于频繁,请稍后再试",
|
||||
MessageEn: "Too many attempts, please try again later",
|
||||
}
|
||||
ErrLocked = &Error{
|
||||
Code: "ACCOUNT_LOCKED",
|
||||
MessageZH: "账户已临时锁定,请1小时后重试",
|
||||
MessageEn: "Account temporarily locked, please retry in 1 hour",
|
||||
}
|
||||
ErrInternal = &Error{
|
||||
Code: "INTERNAL_ERROR",
|
||||
MessageZH: "服务器内部错误,请稍后重试",
|
||||
MessageEn: "Internal server error, please try again later",
|
||||
}
|
||||
ErrBadRequest = &Error{
|
||||
Code: "BAD_REQUEST",
|
||||
MessageZH: "请求参数有误",
|
||||
MessageEn: "Invalid request parameters",
|
||||
}
|
||||
)
|
||||
|
||||
// Webhook-specific errors.
|
||||
// Webhook-specific errors.
|
||||
var (
|
||||
ErrWebhookSignature = &Error{
|
||||
Code: "WEBHOOK_INVALID_SIGNATURE",
|
||||
MessageZH: "签名校验失败",
|
||||
@@ -77,9 +120,71 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
// WriteJSON writes the given status code and error body as JSON.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// HTTP helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// StatusFor returns a suitable HTTP status code for the given *Error, inferred
|
||||
// from the error Code string. It covers the standard mapping used across all
|
||||
// v1 handlers; callers may override with explicit WriteJSON calls when needed.
|
||||
func StatusFor(e *Error) int {
|
||||
switch e.Code {
|
||||
case "UNAUTHORIZED":
|
||||
return http.StatusUnauthorized
|
||||
case "FORBIDDEN":
|
||||
return http.StatusForbidden
|
||||
case "NOT_FOUND":
|
||||
return http.StatusNotFound
|
||||
case "CONFLICT":
|
||||
return http.StatusConflict
|
||||
case "RATE_LIMITED", "ACCOUNT_LOCKED":
|
||||
return http.StatusTooManyRequests
|
||||
case "INTERNAL_ERROR":
|
||||
return http.StatusInternalServerError
|
||||
default:
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
}
|
||||
|
||||
// WriteJSON writes the given HTTP status code and error body as JSON.
|
||||
func WriteJSON(w http.ResponseWriter, status int, e *Error) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(e)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Chi-compatible middleware
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Middleware is a chi-compatible middleware that recovers from panics of type
|
||||
// *Error and writes the appropriate JSON response via StatusFor + WriteJSON.
|
||||
// Any panic with a non-*Error value is re-raised so other recovery middleware
|
||||
// (e.g. chi's built-in Recoverer) can handle it.
|
||||
//
|
||||
// Usage in handlers — instead of:
|
||||
//
|
||||
// apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
|
||||
// return
|
||||
//
|
||||
// A handler may simply:
|
||||
//
|
||||
// panic(apierr.ErrBadRequest)
|
||||
//
|
||||
// This keeps handler code linear and avoids partial-write bugs when the caller
|
||||
// forgets to return after WriteJSON.
|
||||
func Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rv := recover(); rv != nil {
|
||||
if e, ok := rv.(*Error); ok {
|
||||
WriteJSON(w, StatusFor(e), e)
|
||||
return
|
||||
}
|
||||
// Unknown panic type — re-raise for upstream recovery middleware.
|
||||
panic(rv)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user