merge: maestro/tsk_tFMU7-hKzfOf [tsk_tFMU7-hKzfOf] codes 激活码模块
解决与 1A 骨架的冲突:module 统一为 github.com/wangjia/pangolin/server (codes 分支原用 pangolin/server,7 个源文件 import 已改写); go.mod require 并集(redis 取 9.20.1);Makefile 以骨架为基底并入 build-codegen/test-unit/test-integration target。 go build/vet 通过,codes 单测通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
package codes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/apierr"
|
||||
)
|
||||
|
||||
// RedeemHandler handles POST /v1/redeem requests.
|
||||
// It expects the request context to carry the authenticated userID under the
|
||||
// key ctxKeyUserID (set by the JWT auth middleware from the auth module).
|
||||
type RedeemHandler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
// NewRedeemHandler creates a RedeemHandler backed by svc.
|
||||
func NewRedeemHandler(svc *Service) *RedeemHandler {
|
||||
return &RedeemHandler{svc: svc}
|
||||
}
|
||||
|
||||
// ctxKeyUserID is the context key for the authenticated user ID.
|
||||
// Defined here to avoid an import cycle; the auth middleware uses the same key.
|
||||
type ctxKey string
|
||||
|
||||
const CtxKeyUserID ctxKey = "user_id"
|
||||
|
||||
// redeemRequest is the JSON body for POST /v1/redeem.
|
||||
type redeemRequest struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// redeemResponse is the JSON body on success.
|
||||
type redeemResponse struct {
|
||||
Idempotent bool `json:"idempotent"`
|
||||
Plan string `json:"plan"`
|
||||
DurationDays int `json:"duration_days"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"` // RFC 3339 UTC
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
func (h *RedeemHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract authenticated userID from context (set by JWT middleware).
|
||||
userID, ok := r.Context().Value(CtxKeyUserID).(int64)
|
||||
if !ok || userID == 0 {
|
||||
apierr.WriteJSON(w, http.StatusUnauthorized, &apierr.Error{
|
||||
Code: "UNAUTHORIZED",
|
||||
MessageZH: "请先登录",
|
||||
MessageEn: "Authentication required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req redeemRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Code == "" {
|
||||
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
result, apiErr := h.svc.Redeem(r.Context(), RedeemRequest{
|
||||
UserID: userID,
|
||||
Code: req.Code,
|
||||
})
|
||||
if apiErr != nil {
|
||||
status := http.StatusBadRequest
|
||||
switch apiErr.Code {
|
||||
case "ACCOUNT_LOCKED", "RATE_LIMITED":
|
||||
status = http.StatusTooManyRequests
|
||||
case "INTERNAL_ERROR":
|
||||
status = http.StatusInternalServerError
|
||||
case "CODE_REDEEMED", "CODE_NOT_FOUND", "CODE_VOID", "INVALID_CODE":
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
apierr.WriteJSON(w, status, apiErr)
|
||||
return
|
||||
}
|
||||
|
||||
resp := redeemResponse{
|
||||
Idempotent: result.Idempotent,
|
||||
Plan: string(result.PlanCode),
|
||||
DurationDays: result.DurationDays,
|
||||
}
|
||||
if !result.ExpiresAt.IsZero() {
|
||||
resp.ExpiresAt = result.ExpiresAt.UTC().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
Reference in New Issue
Block a user