Files
pangolin/server/internal/apierr/apierr.go
T
wangjia b64c002a33 feat(tsk_GXDoc3Cs07Rn): apierr + idgen + CONVENTIONS.md
apierr:
- Add New() constructor, StatusFor() HTTP-status mapping
- Add ErrUnauthorized, ErrForbidden, ErrNotFound, ErrConflict predefined errors
- Add chi-compatible Middleware for panic(*Error) → JSON recovery
- Add apierr_test.go (8 tests; covers New, StatusFor, WriteJSON, Middleware)

idgen:
- Implement idgen.go: New()/NewString() (UUID v7 via google/uuid v1.6.0)
- Implement GenerateCode/CanonicalizeCode/HashCode (Crockford Base32 moved from codes)
- Add idgen_test.go (12 tests; UUID v7 ordering/uniqueness + Crockford format/normalization/check)

codes:
- Refactor generator.go to delegate GenerateCode/Canonicalize/Hash to idgen
- All existing codes generator tests continue to pass unchanged

server:
- Add CONVENTIONS.md covering package structure, error handling, ID generation,
  database conventions, handler templates, auth context, testing, and logging rules
- Move google/uuid from indirect to direct dependency in go.mod

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 11:58:38 +08:00

191 lines
6.9 KiB
Go

// 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 (
"encoding/json"
"net/http"
)
// 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 }
// 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",
MessageZH: "激活码格式无效,请检查后重试",
MessageEn: "Invalid code format, please verify and try again",
}
ErrCodeNotFound = &Error{
Code: "CODE_NOT_FOUND",
MessageZH: "激活码无效或已使用",
MessageEn: "Code not found or already used",
}
ErrCodeRedeemed = &Error{
Code: "CODE_REDEEMED",
MessageZH: "该激活码已被其他账户使用",
MessageEn: "This code has already been redeemed by another account",
}
ErrCodeVoid = &Error{
Code: "CODE_VOID",
MessageZH: "该激活码已失效",
MessageEn: "This code is no longer valid",
}
ErrLocked = &Error{
Code: "ACCOUNT_LOCKED",
MessageZH: "账户已临时锁定,请1小时后重试",
MessageEn: "Account temporarily locked, please retry in 1 hour",
}
)
// Webhook-specific errors.
var (
ErrWebhookSignature = &Error{
Code: "WEBHOOK_INVALID_SIGNATURE",
MessageZH: "签名校验失败",
MessageEn: "Invalid webhook signature",
}
ErrWebhookTimestamp = &Error{
Code: "WEBHOOK_TIMESTAMP_EXPIRED",
MessageZH: "请求时间戳超出允许窗口",
MessageEn: "Webhook timestamp outside allowed window",
}
ErrWebhookReplay = &Error{
Code: "WEBHOOK_REPLAY",
MessageZH: "重复请求已忽略",
MessageEn: "Duplicate webhook request ignored",
}
)
// ─────────────────────────────────────────────────────────────────────────────
// 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)
})
}