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>
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package apierr_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/apierr"
|
||||
)
|
||||
|
||||
// TestErrorInterface verifies that *Error implements the error interface.
|
||||
func TestErrorInterface(t *testing.T) {
|
||||
var err error = apierr.ErrBadRequest
|
||||
if err == nil {
|
||||
t.Fatal("ErrBadRequest should not be nil")
|
||||
}
|
||||
if err.Error() == "" {
|
||||
t.Error("Error() returned empty string")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNew verifies that New constructs an *Error with the given fields.
|
||||
func TestNew(t *testing.T) {
|
||||
e := apierr.New("TEST_CODE", "测试消息", "test message")
|
||||
if e.Code != "TEST_CODE" {
|
||||
t.Errorf("Code = %q, want %q", e.Code, "TEST_CODE")
|
||||
}
|
||||
if e.MessageZH != "测试消息" {
|
||||
t.Errorf("MessageZH = %q, want %q", e.MessageZH, "测试消息")
|
||||
}
|
||||
if e.MessageEn != "test message" {
|
||||
t.Errorf("MessageEn = %q, want %q", e.MessageEn, "test message")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatusFor verifies HTTP status code mapping.
|
||||
func TestStatusFor(t *testing.T) {
|
||||
cases := []struct {
|
||||
code string
|
||||
wantStatus int
|
||||
}{
|
||||
{"UNAUTHORIZED", http.StatusUnauthorized},
|
||||
{"FORBIDDEN", http.StatusForbidden},
|
||||
{"NOT_FOUND", http.StatusNotFound},
|
||||
{"CONFLICT", http.StatusConflict},
|
||||
{"RATE_LIMITED", http.StatusTooManyRequests},
|
||||
{"ACCOUNT_LOCKED", http.StatusTooManyRequests},
|
||||
{"INTERNAL_ERROR", http.StatusInternalServerError},
|
||||
{"BAD_REQUEST", http.StatusBadRequest},
|
||||
{"INVALID_CODE", http.StatusBadRequest},
|
||||
{"CODE_NOT_FOUND", http.StatusBadRequest},
|
||||
{"UNKNOWN_CODE", http.StatusBadRequest},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
e := &apierr.Error{Code: tc.code}
|
||||
got := apierr.StatusFor(e)
|
||||
if got != tc.wantStatus {
|
||||
t.Errorf("StatusFor({Code:%q}) = %d, want %d", tc.code, got, tc.wantStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteJSON verifies that WriteJSON sets the correct Content-Type,
|
||||
// status code, and JSON body.
|
||||
func TestWriteJSON(t *testing.T) {
|
||||
e := apierr.New("TEST", "中文", "english")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
apierr.WriteJSON(w, http.StatusBadRequest, e)
|
||||
|
||||
resp := w.Result()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest)
|
||||
}
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if ct != "application/json; charset=utf-8" {
|
||||
t.Errorf("Content-Type = %q, want %q", ct, "application/json; charset=utf-8")
|
||||
}
|
||||
|
||||
var got apierr.Error
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if got.Code != e.Code {
|
||||
t.Errorf("body.code = %q, want %q", got.Code, e.Code)
|
||||
}
|
||||
if got.MessageZH != e.MessageZH {
|
||||
t.Errorf("body.message_zh = %q, want %q", got.MessageZH, e.MessageZH)
|
||||
}
|
||||
if got.MessageEn != e.MessageEn {
|
||||
t.Errorf("body.message_en = %q, want %q", got.MessageEn, e.MessageEn)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMiddlewareCatchesAPIError verifies that the middleware intercepts
|
||||
// a panic(*Error) and writes the correct JSON response.
|
||||
func TestMiddlewareCatchesAPIError(t *testing.T) {
|
||||
panicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
panic(apierr.ErrUnauthorized)
|
||||
})
|
||||
|
||||
handler := apierr.Middleware(panicHandler)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
var got apierr.Error
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if got.Code != apierr.ErrUnauthorized.Code {
|
||||
t.Errorf("body.code = %q, want %q", got.Code, apierr.ErrUnauthorized.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMiddlewareReRaisesNonAPIError verifies that the middleware re-raises
|
||||
// panics that are not of type *Error.
|
||||
func TestMiddlewareReRaisesNonAPIError(t *testing.T) {
|
||||
panicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
panic("unexpected string panic")
|
||||
})
|
||||
|
||||
handler := apierr.Middleware(panicHandler)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
defer func() {
|
||||
if rv := recover(); rv == nil {
|
||||
t.Error("expected panic to be re-raised, but it was not")
|
||||
}
|
||||
}()
|
||||
handler.ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
// TestMiddlewarePassesthrough verifies that the middleware is a no-op when
|
||||
// the handler does not panic.
|
||||
func TestMiddlewarePassesthrough(t *testing.T) {
|
||||
okHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
})
|
||||
|
||||
handler := apierr.Middleware(okHandler)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPredefinedErrors checks that all predefined errors have non-empty fields
|
||||
// and comply with the desensitisation rule (no forbidden words in messages).
|
||||
func TestPredefinedErrors(t *testing.T) {
|
||||
forbidden := []string{"VPN", "翻墙", "科学上网"}
|
||||
errors := []*apierr.Error{
|
||||
apierr.ErrBadRequest,
|
||||
apierr.ErrUnauthorized,
|
||||
apierr.ErrForbidden,
|
||||
apierr.ErrNotFound,
|
||||
apierr.ErrConflict,
|
||||
apierr.ErrRateLimited,
|
||||
apierr.ErrInternal,
|
||||
apierr.ErrInvalidCode,
|
||||
apierr.ErrCodeNotFound,
|
||||
apierr.ErrCodeRedeemed,
|
||||
apierr.ErrCodeVoid,
|
||||
apierr.ErrLocked,
|
||||
apierr.ErrWebhookSignature,
|
||||
apierr.ErrWebhookTimestamp,
|
||||
apierr.ErrWebhookReplay,
|
||||
}
|
||||
|
||||
for _, e := range errors {
|
||||
if e.Code == "" {
|
||||
t.Errorf("error %+v has empty Code", e)
|
||||
}
|
||||
if e.MessageZH == "" {
|
||||
t.Errorf("error %q has empty MessageZH", e.Code)
|
||||
}
|
||||
if e.MessageEn == "" {
|
||||
t.Errorf("error %q has empty MessageEn", e.Code)
|
||||
}
|
||||
for _, f := range forbidden {
|
||||
if contains(e.MessageZH, f) || contains(e.MessageEn, f) {
|
||||
t.Errorf("error %q contains forbidden word %q", e.Code, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsStr(s, sub))
|
||||
}
|
||||
|
||||
func containsStr(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,5 +1 @@
|
||||
// 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 *APIError values to JSON automatically.
|
||||
package apierr
|
||||
|
||||
Reference in New Issue
Block a user