d88c1ae647
手动合并 tsk_x7wrlA87orsY(设备管理 + 订阅校验中间件)到 main:
冲突解决:
- server/internal/apierr/apierr.go:保留 tsk_GXDoc3Cs07Rn 版本(New/StatusFor/
Middleware/ErrConflict/改善文档),并入 tsk_x7wrlA87orsY 新增的 ErrAccountBanned
及对应 StatusFor case(→ 403)。
新增文件(来自 tsk_x7wrlA87orsY):
- server/internal/devices/doc.go package 文档(替换占位 stub)
- server/internal/devices/context.go CtxKeyUserID / Plan / WithPlan / PlanFromCtx
- server/internal/devices/handler.go GET /v1/me/devices · DELETE /v1/me/devices/{id}
- server/internal/devices/middleware.go SubscriptionMiddleware · CheckDeviceQuota · RequirePaidTier
- server/internal/devices/service.go RegisterIfAbsent / DeleteDevice / ResolvePlan + 纯函数 resolveEffectivePlan
- server/internal/devices/store.go MySQL 数据访问层
- server/internal/devices/service_test.go 15 个单测(全通过)
- server/internal/devices/devices_integration_test.go testcontainers 集成测试
OpenAPI 更新(来自 tsk_x7wrlA87orsY):
- server/api/openapi.yaml:SubscriptionInfo.source 枚举补 free
- design/server/openapi.yaml:SubscriptionInfo.source 枚举补 admin, free
测试:go build ./... ✓;go test ./internal/apierr/... ✓(8 tests);
go test ./internal/devices/... ✓(15 tests)。
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
198 lines
7.1 KiB
Go
198 lines
7.1 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",
|
|
}
|
|
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",
|
|
}
|
|
)
|
|
|
|
// 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)
|
|
})
|
|
}
|