4bb92209ca
- store(SQLite,modernc 纯 Go):pay_orders + addr_cursor(HD 派生游标,地址不复用);
建单/查单/ListPending/MarkPaid(幂等,仅 pending→paid)/MarkExpired。
- pay 服务:CreateOrder 每单 NextAddrIndex→从 xpub watch-only 派生唯一收款地址→写 pending 单(TTL 15min)。
- tron:TronGrid 客户端读已确认 TRC20 到账(only_confirmed + USDT 合约,micro-USDT 整数)。
- watcher:Tick 先过期逾期单,再对每个 pending 单查到账、金额≥期望→MarkPaid;幂等(同 tx 只认一次)、
网络错误跳过下轮重试;Loop 定时轮询。
- httpapi:POST /order、GET /order/{orderNo}、/healthz;cmd/paywatch 用 env 装配 + 优雅退出。
- 测试:store/service/watcher(mock TronGrid)/httpapi 全绿——建单派生地址正确、到账侦测、
欠额不认、幂等、超时过期、404/400。热服务无私钥。
- README:安全模型 + Phase A 离线备钱包步骤 + 运行/API/测试。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
87 lines
2.5 KiB
Go
87 lines
2.5 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 {
|
|
SKU string `json:"sku"`
|
|
Amount int64 `json:"amount"` // 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.SKU, req.Amount)
|
|
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))
|
|
}
|