Files
pangolin/server/internal/pay/handler_reconcile_sqlite_test.go
T
wangjia 4a76134ca9 feat(server/pay): 查单对账开通(reconcile-on-read)——付完不再干等 webhook
「付完刷新等十几秒」的服务端主因: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
2026-09-06 09:37:54 +08:00

108 lines
3.8 KiB
Go

package pay
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/wangjia/pangolin/server/internal/codes"
)
// reconcileRig:假 pay(查单可配)+ sqlite 台账 + 真 WebhookHandler 作 settler。
// 验证 GetOrder 的 reconcile-on-read:pay 网关已 paid 时就地幂等开通,不干等 webhook。
func reconcileRig(t *testing.T, payFn http.HandlerFunc) (*chi.Mux, *Store, *sql.DB) {
t.Helper()
db := openMigratedSQLite(t)
seedUser(t, db, 1, "uuid-1")
srv := fakePay(t, payFn)
st := NewStore(db)
h := NewHandler(NewClient(srv.URL, "pangolin", testSecret), st, db)
// 真 granter + webhook,settle 会真的开通订阅(与线上同一段逻辑)。
codesSvc := codes.NewService(codes.NewStore(db), nil, 5, time.Hour)
wh := NewWebhookHandler(st, codesSvc, db, nil, "pangolin", testSecret, 5*time.Minute, 15*time.Minute)
h.SetSettler(wh)
r := chi.NewRouter()
r.Get("/v1/pay/orders/{orderNo}", h.GetOrder)
return r, st, db
}
func getOrder(t *testing.T, router *chi.Mux, orderNo string, uid int64) (int, bool) {
t.Helper()
w := httptest.NewRecorder()
router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodGet, "/v1/pay/orders/"+orderNo, nil), uid))
var resp struct {
Activated bool `json:"activated"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
return w.Code, resp.Activated
}
// 网关已 paid、本地还 created → 查单即就地开通(activated=true、台账翻 paid、订阅真授予);
// 且重复轮询幂等:再查一次不二次开通(仍 1 条订阅)。
func TestGetOrder_ReconcileOnRead_ActivatesWhenGatewayPaid(t *testing.T) {
router, st, db := reconcileRig(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"data":{"order_no":"pay001","status":"paid",
"subject":"Pro","amount_minor":2999,"currency":"CNY"}}`))
})
ctx := context.Background()
if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "alipay", 2999, "CNY"); err != nil {
t.Fatal(err)
}
code, activated := getOrder(t, router, "pay001", 1)
if code != http.StatusOK || !activated {
t.Fatalf("首查应就地开通:code=%d activated=%v", code, activated)
}
row, err := st.GetForUser(ctx, 1, "pay001")
if err != nil || row.Status != "paid" {
t.Fatalf("本地台账应已翻 paid: row=%+v err=%v", row, err)
}
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id=1 AND source='pay'`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("应授予 1 条 pay 订阅,得 %d", n)
}
// 幂等:再查一次(轮询会持续查),不得二次开通。
if _, activated2 := getOrder(t, router, "pay001", 1); !activated2 {
t.Fatal("二次查询仍应 activated")
}
_ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id=1 AND source='pay'`).Scan(&n)
if n != 1 {
t.Fatalf("重复轮询不得二次开通,订阅数 = %d, want 1", n)
}
}
// 网关仍 pending → 不开通(activated=false、无订阅);证明只有 paid 才触发对账。
func TestGetOrder_ReconcileOnRead_SkipsWhenGatewayPending(t *testing.T) {
router, st, db := reconcileRig(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"data":{"order_no":"pay001","status":"pending","currency":"CNY"}}`))
})
ctx := context.Background()
if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "alipay", 2999, "CNY"); err != nil {
t.Fatal(err)
}
if code, activated := getOrder(t, router, "pay001", 1); code != http.StatusOK || activated {
t.Fatalf("pending 不应开通:code=%d activated=%v", code, activated)
}
row, _ := st.GetForUser(ctx, 1, "pay001")
if row.Status != "created" {
t.Fatalf("pending 时本地台账应仍 created, got %q", row.Status)
}
var n int
_ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id=1`).Scan(&n)
if n != 0 {
t.Fatalf("pending 不得开通订阅,得 %d", n)
}
}