Files
pangolin/pay/internal/httpapi/handler.go
T
wangjia c5949a595a feat(pay): 收款模型改为单地址+唯一金额(#34/34A Phase A-C)
从"每单唯一 HD 地址"改为"单个固定收款地址 + 每单唯一金额",归集成本 O(订单数)→O(1)。
- store: pay_orders 加 user_ref/expect_amount(唯一金额)/matched_tx_id;新 orphan_payments 表;
  ActiveOrderByUser(同用户单订单)、AmountRecentlyUsed(迟到窗口内金额不复用)、TxHandled(幂等)、
  RecordOrphan。去掉每单派生游标。
- pay: CreateOrder(userRef,sku,priceMicro)——同用户单订单校验 + 分配唯一金额(base+随机微尾数[1,9999]、
  cooldown 内不复用),address 恒为收款地址。
- tron: Transfer 加 BlockTs(区块时间秒),取 block_timestamp。
- watcher: 单地址取到账,按"金额==expect && block_ts>建单"匹配 → paid;不匹配的到账 → orphan;幂等。
- httpapi: POST /order 加 user_ref,同用户重复 → 409;main 收款地址=PAY_RECEIVE_ADDRESS 或 xpub index0。
- 测试:唯一金额/同地址、同用户单订单、精确匹配、付错成孤儿、迟到不误配新单、付款早于建单不匹配、
  超时、幂等、409,全绿。README 更新为单地址模型+API(user_ref/精确金额/orphan)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 15:15:38 +08:00

92 lines
2.7 KiB
Go

// Package httpapi exposes the order endpoints a storefront (e.g. 独角数卡) calls:
// create a payment (get a receiving address) and poll its status.
package httpapi
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/wangjia/pangolin/pay/internal/pay"
"github.com/wangjia/pangolin/pay/internal/store"
)
type Handler struct{ svc *pay.Service }
// New wires the routes (Go 1.22 method+wildcard patterns).
func New(svc *pay.Service) http.Handler {
h := &Handler{svc: svc}
mux := http.NewServeMux()
mux.HandleFunc("POST /order", h.createOrder)
mux.HandleFunc("GET /order/{orderNo}", h.getOrder)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
return mux
}
type createReq struct {
UserRef string `json:"user_ref"`
SKU string `json:"sku"`
Amount int64 `json:"amount"` // base price, micro-USDT (1e-6)
}
type orderResp struct {
OrderNo string `json:"order_no"`
Address string `json:"address"`
ExpectAmount int64 `json:"expect_amount"`
Status string `json:"status"`
ExpiresAt string `json:"expires_at"`
TxID string `json:"tx_id,omitempty"`
}
func toResp(o *store.Order) orderResp {
return orderResp{
OrderNo: o.OrderNo,
Address: o.Address,
ExpectAmount: o.ExpectAmount,
Status: string(o.Status),
ExpiresAt: o.ExpiresAt.UTC().Format(time.RFC3339),
TxID: o.TxID,
}
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func (h *Handler) createOrder(w http.ResponseWriter, r *http.Request) {
var req createReq
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
o, err := h.svc.CreateOrder(r.Context(), req.UserRef, req.SKU, req.Amount)
if errors.Is(err, pay.ErrUserHasActiveOrder) {
writeJSON(w, http.StatusConflict, map[string]string{"error": "user already has an active order"})
return
}
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusCreated, toResp(o))
}
func (h *Handler) getOrder(w http.ResponseWriter, r *http.Request) {
o, err := h.svc.GetOrder(r.Context(), r.PathValue("orderNo"))
if errors.Is(err, store.ErrNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal"})
return
}
writeJSON(w, http.StatusOK, toResp(o))
}