Files
pangolin/server/internal/pay/handler.go
T

242 lines
8.2 KiB
Go

package pay
import (
"database/sql"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/wangjia/pangolin/server/internal/apierr"
"github.com/wangjia/pangolin/server/internal/auth"
)
// Handler 是面向 App 的下单代理(JWT 保护;user→biz_ref 映射在 server 侧,
// 客户端只传 sku+method+端型 metadata,永远不传金额)。
type Handler struct {
client *Client
store *Store
db *sql.DB
}
func NewHandler(client *Client, store *Store, db *sql.DB) *Handler {
return &Handler{client: client, store: store, db: db}
}
// allowedMetadataKeys 与 pay gateway.go 白名单一致(is_mobile/render)。
var allowedMetadataKeys = map[string]bool{"is_mobile": true, "render": true}
func filterMetadata(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
if allowedMetadataKeys[k] {
out[k] = v
}
}
if len(out) == 0 {
return nil
}
return out
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
// writePayErr 把 pay 网关错误映射为 apierr(脱敏双语;code 透传供客户端分辨)。
func writePayErr(w http.ResponseWriter, err error) {
var pe *Error
if !errors.As(err, &pe) {
apierr.WriteJSON(w, http.StatusBadGateway,
apierr.New("PAY_UPSTREAM", "支付服务暂不可用,请稍后重试", "Payment service unavailable, please retry later"))
return
}
switch pe.Code {
case "currency_mismatch":
apierr.WriteJSON(w, http.StatusConflict,
apierr.New("CURRENCY_MISMATCH", "该支付方式结算币种与订单不符,请重新下单", "Settlement currency mismatch, please create a new order"))
case "order_not_pending":
apierr.WriteJSON(w, http.StatusConflict,
apierr.New("ORDER_NOT_PENDING", "订单状态已变化,请刷新后重试", "Order is no longer pending"))
case "order_not_found", "product_not_found":
apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound)
case "rate_limited":
apierr.WriteJSON(w, http.StatusTooManyRequests, apierr.ErrRateLimited)
case "unknown_method", "bad_request", "method_not_recurring":
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
default: // no_account / no_settle_currency / create_failed / upstream_error…
apierr.WriteJSON(w, http.StatusBadGateway,
apierr.New("PAY_UPSTREAM", "支付服务暂不可用,请稍后重试", "Payment service unavailable, please retry later"))
}
}
// ─── GET /v1/pay/catalog ────────────────────────────────────────────────────
func (h *Handler) Catalog(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, map[string]any{"items": Catalog})
}
// ─── POST /v1/pay/orders ────────────────────────────────────────────────────
type createOrderRequest struct {
SKU string `json:"sku"`
Method string `json:"method"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type sessionResponse struct {
OrderNo string `json:"order_no"`
Session Session `json:"session"`
}
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
uid, ok := auth.UserIDFromContext(ctx)
if !ok {
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
return
}
var req createOrderRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&req); err != nil {
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
return
}
if _, ok := CatalogBySKU(req.SKU); !ok || req.Method == "" {
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
return
}
var bizRef string
err := h.db.QueryRowContext(ctx,
`SELECT uuid FROM users WHERE id = ? AND status = 'active'`, uid).Scan(&bizRef)
if err == sql.ErrNoRows {
apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound)
return
} else if err != nil {
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
return
}
res, err := h.client.CreateOrder(ctx, req.SKU, req.Method, bizRef, filterMetadata(req.Metadata))
if err != nil {
writePayErr(w, err)
return
}
if err := h.store.Insert(ctx, uid, bizRef, req.SKU, res.OrderNo, req.Method); err != nil {
// 不吞单:webhook 会按 biz_ref 兜底补台账,这里记日志便于追查。
slog.Error("pay: 台账写入失败(webhook 将按 biz_ref 兜底)", "order_no", res.OrderNo, "err", err)
}
writeJSON(w, sessionResponse{OrderNo: res.OrderNo, Session: res.Session})
}
// ─── GET /v1/pay/orders/{orderNo} ───────────────────────────────────────────
type orderStatusResponse struct {
OrderNo string `json:"order_no"`
PayStatus string `json:"pay_status"` // pay 侧状态词汇原样透传
Activated bool `json:"activated"` // 本地台账已消费(权益已开通)——客户端轮询以此为成功判据
ExpiresAt *string `json:"expires_at,omitempty"`
}
func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
uid, ok := auth.UserIDFromContext(ctx)
if !ok {
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
return
}
orderNo := chi.URLParam(r, "orderNo")
row, err := h.store.GetForUser(ctx, uid, orderNo)
if err == sql.ErrNoRows {
apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound)
return
} else if err != nil {
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
return
}
st, err := h.client.GetOrder(ctx, orderNo)
if err != nil {
writePayErr(w, err)
return
}
resp := orderStatusResponse{OrderNo: orderNo, PayStatus: st.Status, Activated: row.Status == "paid"}
if row.SubID.Valid {
if exp, err := h.store.SubscriptionExpiry(ctx, row.SubID.Int64); err == nil {
s := exp.UTC().Format(time.RFC3339)
resp.ExpiresAt = &s
}
}
writeJSON(w, resp)
}
// ─── POST /v1/pay/orders/{orderNo}/retry ────────────────────────────────────
type retryOrderRequest struct {
Method string `json:"method"`
Metadata map[string]string `json:"metadata,omitempty"`
}
func (h *Handler) Retry(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
uid, ok := auth.UserIDFromContext(ctx)
if !ok {
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
return
}
orderNo := chi.URLParam(r, "orderNo")
var req retryOrderRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&req); err != nil || req.Method == "" {
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
return
}
if _, err := h.store.GetForUser(ctx, uid, orderNo); err == sql.ErrNoRows {
apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound)
return
} else if err != nil {
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
return
}
res, err := h.client.Retry(ctx, orderNo, req.Method, filterMetadata(req.Metadata))
if err != nil {
writePayErr(w, err)
return
}
_ = h.store.UpdateMethod(ctx, uid, orderNo, req.Method)
writeJSON(w, sessionResponse{OrderNo: res.OrderNo, Session: res.Session})
}
// ─── POST /v1/pay/orders/{orderNo}/cancel ───────────────────────────────────
func (h *Handler) Cancel(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
uid, ok := auth.UserIDFromContext(ctx)
if !ok {
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
return
}
orderNo := chi.URLParam(r, "orderNo")
if _, err := h.store.GetForUser(ctx, uid, orderNo); err == sql.ErrNoRows {
apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound)
return
} else if err != nil {
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
return
}
canceled, err := h.client.Cancel(ctx, orderNo)
if err != nil {
writePayErr(w, err)
return
}
if canceled {
_ = h.store.MarkCanceled(ctx, uid, orderNo)
}
writeJSON(w, map[string]bool{"canceled": canceled})
}