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:
wangjia
2026-06-13 14:45:46 +08:00
parent 787151245e
commit 25e0204471
9 changed files with 1064 additions and 178 deletions
+125 -20
View File
@@ -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)
})
}
+210
View File
@@ -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
}
-4
View File
@@ -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