// 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", } ErrAccountBanned = &Error{ Code: "ACCOUNT_BANNED", MessageZH: "账户已被封禁,无法继续操作", MessageEn: "This account has been banned", } ) // 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", } ) // Usage / ad errors. var ( ErrAdNotUnlocked = &Error{ Code: "AD_NOT_UNLOCKED", MessageZH: "请先观看激励视频解锁当日时长", MessageEn: "Please watch the rewarded ad to unlock today's minutes", } ErrQuotaExhausted = &Error{ Code: "QUOTA_EXHAUSTED", MessageZH: "今日免费时长已用尽,请明天再来或升级套餐", MessageEn: "Today's free minutes are used up, try tomorrow or upgrade", } ErrAdVerifyFailed = &Error{ Code: "AD_VERIFY_FAILED", MessageZH: "广告回执校验失败", MessageEn: "Ad receipt verification failed", } ErrAdReplay = &Error{ Code: "AD_TOKEN_REPLAY", MessageZH: "该广告回执已被使用", MessageEn: "This ad receipt has already been used", } ) // 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 "ACCOUNT_BANNED": return http.StatusForbidden 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) }) }