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 } item, ok := CatalogBySKU(req.SKU) if !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 } // 优惠档(Promo SKU)每账号限购一次:该用户已有该 SKU 的 paid 台账则拒单, // 不打上游(真机实测发现用户可反复购买优惠档)。Retry/Cancel 不受限——恢复原单不受限。 if item.Promo { used, err := h.store.HasPaidPurchase(ctx, uid, req.SKU) if err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } if used { apierr.WriteJSON(w, http.StatusConflict, apierr.New("PROMO_ALREADY_USED", "优惠套餐每个账号限购一次,你已购买过", "This promotional plan is limited to one per account and you have already purchased it")) return } } res, err := h.client.CreateOrder(ctx, req.SKU, req.Method, bizRef, filterMetadata(req.Metadata)) if err != nil { writePayErr(w, err) return } // 下单即落展示金额/结算币种(按支付方式);webhook 到账后由实际结算金额覆盖。 amount := DisplayAmountMinor(item, req.Method) currency := SettlementCurrency(req.Method) if err := h.store.Insert(ctx, uid, bizRef, req.SKU, res.OrderNo, req.Method, amount, currency); 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 (列表) ────────────────────────────────────────────── // orderView 是订单的台账视图(列表/详情共用富字段;金额为结算币种 minor:CNY 分 / USDT 微元)。 type orderView struct { OrderNo string `json:"order_no"` SKU string `json:"sku"` Plan string `json:"plan"` Days int `json:"days"` Method string `json:"method"` Status string `json:"status"` // 本地台账:created | paid | canceled AmountMinor int64 `json:"amount_minor"` Currency string `json:"currency"` Channel string `json:"channel,omitempty"` CreatedAt string `json:"created_at"` // RFC3339 PaidAt *string `json:"paid_at,omitempty"` } // 未付订单展示层过期窗口(近似 pay 会话时效;仅影响列表/详情显示,不改台账)。 const orderPendingTTL = 30 * time.Minute func orderViewFromRow(row *PurchaseRow) orderView { v := orderView{ OrderNo: row.OutTradeNo, SKU: row.SKU, Method: row.Method, Status: row.Status, AmountMinor: row.AmountMinor, Currency: row.Currency, Channel: row.Channel, CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339), } if it, ok := CatalogBySKU(row.SKU); ok { v.Plan, v.Days = it.Plan, it.Days // 展示金额兜底:老单/webhook 前 amount_minor=0(旧下单不落金额)→ 按档位+ // 支付方式的结算币种回退到展示价,列表/详情不再显 0。webhook 到账后为实际金额,不触发。 if v.AmountMinor == 0 { v.AmountMinor = DisplayAmountMinor(it, row.Method) if v.Currency == "" { v.Currency = SettlementCurrency(row.Method) } } } if row.PaidAt.Valid { s := row.PaidAt.Time.UTC().Format(time.RFC3339) v.PaidAt = &s } if row.Status == "created" && row.CreatedAt.Add(orderPendingTTL).Before(time.Now()) { v.Status = "expired" // 展示层:台账仍 created,可继续支付/取消 } return v } // List 列出当前用户历史订单(倒序;纯本地台账,不逐单回查 pay 上游)。 func (h *Handler) List(w http.ResponseWriter, r *http.Request) { ctx := r.Context() uid, ok := auth.UserIDFromContext(ctx) if !ok { apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) return } rows, err := h.store.ListByUser(ctx, uid, 100) if err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } out := make([]orderView, 0, len(rows)) for i := range rows { out = append(out, orderViewFromRow(&rows[i])) } writeJSON(w, map[string]any{"orders": out}) } // ─── GET /v1/pay/orders/{orderNo} ─────────────────────────────────────────── type orderStatusResponse struct { orderView 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{orderView: orderViewFromRow(row), 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}) }