4a76134ca9
「付完刷新等十几秒」的服务端主因:GetOrder 的 activated 只认本地 row=paid,而 row 只有 webhook 到账才翻;webhook 投递+退避重投正是那十几秒。GetOrder 其实早已调 pay 网关查单(st),却只当 pay_status 展示、没用来开通。 改法(仅 pangolin,pay 无需改):GetOrder 拿到网关 st 后,若 st.Status=="paid" 但本地 row 未 paid → 用 st 拼 webhookEvent 就地调 settle(复用 webhook 同一段幂等开通逻辑), 把「等 webhook」压成「付完下一拍查单即开通」。webhook 保留作兜底。 - Handler 加可选 settler(orderSettler 接口,*WebhookHandler 已满足)+ SetSettler; main.go 装配时 payHandler.SetSettler(payWebhook)。 - 幂等双保险:row.Status!="paid" 守卫 + settle 内锁行/已 paid 短路 → 重复轮询/并发查 不二次开通、不二次发奖。settle 失败仅记日志,退回旧展示由下一拍或 webhook 兜。 - channel 查单不返回 → 回退 row.Method(仅影响渠道显示)。成功状态词 "paid" 已对齐 pay 仓 model.OrderStatusV2,提为 payOrderPaid 常量并注释「改前对齐 pay 契约」。 - 信任模型一致:主动调 pay 自有接口(server→server,order_no 持有凭据),与现有把 st.Status 当 pay_status 透传同源,非接收外部推送,不需 webhook 那样验签。 测试:新增端到端 sqlite——网关 paid→就地开通(activated + 台账翻 paid + 真授予订阅) 且重复轮询幂等(仍 1 条订阅);网关 pending→不开通。go test ./... 全绿、vet 干净。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEVUXAbFT6bF1Qw27RHWoD
369 lines
14 KiB
Go
369 lines
14 KiB
Go
package pay
|
|
|
|
import (
|
|
"context"
|
|
"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"
|
|
)
|
|
|
|
// orderSettler 抽象 webhook 的幂等开通入口(settle),供查单对账 reconcile-on-read
|
|
// 复用。*WebhookHandler 已满足。放接口而非直接持有 *WebhookHandler,便于测试注入。
|
|
type orderSettler interface {
|
|
settle(ctx context.Context, ev *webhookEvent) error
|
|
}
|
|
|
|
// payOrderPaid 是 pay 网关订单「已支付」状态词(= pay 侧 model.OrderStatusV2 "paid",
|
|
// 经 client.GetOrder 原样透传)。查单见此即视为款已到、可开通。改这里前先对齐 pay 契约。
|
|
const payOrderPaid = "paid"
|
|
|
|
// Handler 是面向 App 的下单代理(JWT 保护;user→biz_ref 映射在 server 侧,
|
|
// 客户端只传 sku+method+端型 metadata,永远不传金额)。
|
|
type Handler struct {
|
|
client *Client
|
|
store *Store
|
|
db *sql.DB
|
|
settler orderSettler // 可选:reconcile-on-read 开通入口(见 SetSettler),nil 则退回等 webhook
|
|
}
|
|
|
|
func NewHandler(client *Client, store *Store, db *sql.DB) *Handler {
|
|
return &Handler{client: client, store: store, db: db}
|
|
}
|
|
|
|
// SetSettler 挂载对账开通入口(reconcile-on-read):装配后 GetOrder 在发现 pay 网关
|
|
// 已 paid 但本地台账未开通时,就地幂等 settle 立即开通,不干等 webhook 送达
|
|
// (webhook 仍作兜底)。为 nil 时退回旧行为(只认本地 row.Status)。
|
|
func (h *Handler) SetSettler(s orderSettler) { h.settler = s }
|
|
|
|
// 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
|
|
}
|
|
// reconcile-on-read:pay 网关已 paid 但本地台账还没开通(webhook 未到/漏投)→ 就地
|
|
// 幂等 settle 立即开通,把「等 webhook 送达」那十几秒砍成「付完下一拍查单即开通」。
|
|
// settle 已 paid 会短路(不会重复开通/发奖);失败仅记日志、退回旧展示,由下一拍轮询
|
|
// 或 webhook 兜底。channel 查单不返回 → 回退 row.Method(仅影响渠道显示,不影响开通)。
|
|
if h.settler != nil && st.Status == payOrderPaid && row.Status != "paid" {
|
|
paidAt := ""
|
|
if st.PaidAt != nil {
|
|
paidAt = st.PaidAt.UTC().Format(time.RFC3339)
|
|
}
|
|
if serr := h.settler.settle(ctx, &webhookEvent{
|
|
EventType: "payment.succeeded",
|
|
OutTradeNo: orderNo,
|
|
BizRef: row.BizRef,
|
|
ProductBizCode: row.SKU,
|
|
AmountMinor: st.AmountMinor,
|
|
Currency: st.Currency,
|
|
Channel: row.Method,
|
|
PaidAt: paidAt,
|
|
}); serr != nil {
|
|
slog.Warn("pay: reconcile-on-read 开通失败(webhook 仍兜底)", "order_no", orderNo, "err", serr)
|
|
} else if r2, e2 := h.store.GetForUser(ctx, uid, orderNo); e2 == nil {
|
|
row = r2 // 反映刚开通:本拍即回 activated=true,免再等一拍
|
|
}
|
|
}
|
|
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})
|
|
}
|