Replace virtual payments with WeChat Pay API v3 and verify callbacks and order polling. Cache WeChat access tokens in Redis and write structured Zap logs. Support configurable capacity plans across the API, admin and mini program, with purchase snapshots and quota enforcement. Allow personal albums before binding and share them with the current partner. Update deployment configuration and domain routing.
211 lines
8.2 KiB
Go
211 lines
8.2 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/wechatpay-apiv3/wechatpay-go/core"
|
|
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
|
|
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
|
|
"past/internal/config"
|
|
"past/internal/httpapi"
|
|
"past/internal/model"
|
|
"past/internal/store"
|
|
"past/internal/wxpay"
|
|
)
|
|
|
|
type paymentFake struct {
|
|
prepayErr error
|
|
query *payments.Transaction
|
|
notify *payments.Transaction
|
|
notifyErr error
|
|
last wxpay.PrepayOrder
|
|
}
|
|
|
|
func (f *paymentFake) Prepay(_ context.Context, o wxpay.PrepayOrder) (*jsapi.PrepayWithRequestPaymentResponse, error) {
|
|
f.last = o
|
|
if f.prepayErr != nil {
|
|
return nil, f.prepayErr
|
|
}
|
|
return &jsapi.PrepayWithRequestPaymentResponse{PrepayId: core.String("prepay-test"), TimeStamp: core.String("123456"), NonceStr: core.String("nonce"), Package: core.String("prepay_id=prepay-test"), SignType: core.String("RSA"), PaySign: core.String("signature")}, nil
|
|
}
|
|
func (f *paymentFake) Query(context.Context, string) (*payments.Transaction, error) {
|
|
return f.query, nil
|
|
}
|
|
func (f *paymentFake) ParseNotify(context.Context, *http.Request) (*payments.Transaction, error) {
|
|
return f.notify, f.notifyErr
|
|
}
|
|
|
|
func exerciseWechatPayment(t *testing.T, db *store.DB, app *httpapi.App, engine http.Handler, request func(string, string, string, M, int) M, covered map[string]int, happy map[string]bool, uid, token, otherToken string) {
|
|
t.Helper()
|
|
oldCfg := app.Cfg.WechatPay
|
|
defer func() { app.Cfg.WechatPay = oldCfg; app.WechatPay = nil }()
|
|
app.Cfg.WechatPay = config.WechatPayConfig{Enabled: true, AppID: "wx-pay-test", MchID: "merchant-test"}
|
|
gateway := &paymentFake{}
|
|
app.WechatPay = gateway
|
|
if err := db.ORM.Model(&model.User{}).Where("id=?", uid).Updates(map[string]any{"openid": "pay-openid", "storage_vip_expire_at": time.Now().AddDate(0, -3, 0)}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.ORM.Where("user_id=?", uid).Delete(&model.StorageEntitlement{}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
create := func(plan string) model.StorageOrder {
|
|
action := "storage_create_order"
|
|
if plan == "year" {
|
|
action = "storage_order_create"
|
|
}
|
|
r := request(action, "POST", token, M{"plan_key": plan, "amount_cents": 1}, 200)
|
|
if r["pay_provider"] != "wechat" || r["payment"].(M)["package"] != "prepay_id=prepay-test" {
|
|
t.Fatalf("bad payment params: %+v", r)
|
|
}
|
|
var o model.StorageOrder
|
|
if err := db.ORM.Where("out_trade_no=?", r["out_trade_no"]).First(&o).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if o.AmountCents != gateway.last.Amount || o.AmountCents <= 1 || o.PrepayId != "prepay-test" || o.PayerOpenId != "pay-openid" || o.PayAppId != "wx-pay-test" || o.PayMchId != "merchant-test" || o.PayProvider != "wechat" {
|
|
t.Fatalf("incorrect payment snapshot: %+v", o)
|
|
}
|
|
return o
|
|
}
|
|
order := create("month")
|
|
transaction := func(o model.StorageOrder) *payments.Transaction {
|
|
return &payments.Transaction{Appid: core.String(o.PayAppId), Mchid: core.String(o.PayMchId), OutTradeNo: core.String(o.OutTradeNo), TransactionId: core.String("wx-" + o.OutTradeNo), TradeState: core.String("SUCCESS"), TradeType: core.String("JSAPI"), SuccessTime: core.String(time.Now().Format(time.RFC3339)), Amount: &payments.TransactionAmount{Total: core.Int64(o.AmountCents), Currency: core.String("CNY")}, Payer: &payments.TransactionPayer{Openid: core.String(o.PayerOpenId)}}
|
|
}
|
|
|
|
request("storage_order_status", "GET", otherToken, M{"out_trade_no": order.OutTradeNo}, 404)
|
|
for _, field := range []string{"amount", "currency", "appid", "merchant", "payer", "state", "trade_type", "time", "nil_amount", "transaction"} {
|
|
tx := transaction(order)
|
|
switch field {
|
|
case "amount":
|
|
tx.Amount.Total = core.Int64(1)
|
|
case "currency":
|
|
tx.Amount.Currency = core.String("USD")
|
|
case "appid":
|
|
tx.Appid = core.String("foreign-app")
|
|
case "merchant":
|
|
tx.Mchid = core.String("foreign-merchant")
|
|
case "payer":
|
|
tx.Payer.Openid = core.String("foreign-payer")
|
|
case "state":
|
|
tx.TradeState = core.String("NOTPAY")
|
|
case "trade_type":
|
|
tx.TradeType = core.String("NATIVE")
|
|
case "time":
|
|
tx.SuccessTime = nil
|
|
case "nil_amount":
|
|
tx.Amount = nil
|
|
case "transaction":
|
|
tx.TransactionId = nil
|
|
}
|
|
if err := deliverWechatStorageOrder(db, tx); err == nil {
|
|
t.Fatalf("invalid %s accepted", field)
|
|
}
|
|
}
|
|
callback := func(want int) {
|
|
r := httptest.NewRequest("POST", "/api/wechatPayNotify", strings.NewReader(`{}`))
|
|
r.Header.Set("Content-Type", "application/json")
|
|
out := httptest.NewRecorder()
|
|
engine.ServeHTTP(out, r)
|
|
covered["wechat_pay_notify"]++
|
|
if out.Code != want {
|
|
t.Fatalf("wechat callback %d want %d: %s", out.Code, want, out.Body.String())
|
|
}
|
|
if out.Code == 204 {
|
|
happy["wechat_pay_notify"] = true
|
|
}
|
|
}
|
|
gateway.notifyErr = errors.New("bad signature")
|
|
callback(400)
|
|
gateway.notifyErr = nil
|
|
gateway.notify = transaction(order)
|
|
gateway.notify.Amount.Total = core.Int64(1)
|
|
callback(500)
|
|
var unpaid model.StorageOrder
|
|
db.ORM.First(&unpaid, "id=?", order.Id)
|
|
if unpaid.Status != "pending" {
|
|
t.Fatal("invalid notify changed status")
|
|
}
|
|
gateway.query = transaction(order)
|
|
gateway.query.TradeState = core.String("NOTPAY")
|
|
request("storage_order_status", "GET", token, M{"out_trade_no": order.OutTradeNo}, 200)
|
|
gateway.notify = transaction(order)
|
|
// Simultaneous authentic notifications are fulfilled exactly once.
|
|
results := make(chan error, 6)
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 6; i++ {
|
|
wg.Add(1)
|
|
go func() { defer wg.Done(); results <- deliverWechatStorageOrder(db, transaction(order)) }()
|
|
}
|
|
wg.Wait()
|
|
close(results)
|
|
for err := range results {
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
callback(204)
|
|
callback(204)
|
|
var user model.User
|
|
db.ORM.First(&user, "id=?", uid)
|
|
target := time.Now().AddDate(0, 1, 0)
|
|
if user.StorageVipExpireAt == nil || user.StorageVipExpireAt.Before(target.Add(-time.Minute)) || user.StorageVipExpireAt.After(target.Add(time.Minute)) || user.StoragePlan != "month" {
|
|
t.Fatalf("expired VIP renewal or idempotency failed: %+v", user.StorageVipExpireAt)
|
|
}
|
|
before := *user.StorageVipExpireAt
|
|
// Active query recovers a lost callback; requesting status again cannot extend VIP.
|
|
annual := create("year")
|
|
gateway.query = transaction(annual)
|
|
r := request("storage_order_status", "GET", token, M{"out_trade_no": annual.OutTradeNo}, 200)
|
|
if r["status"] != "success" {
|
|
t.Fatalf("query did not deliver: %+v", r)
|
|
}
|
|
request("storage_order_status", "GET", token, M{"out_trade_no": annual.OutTradeNo}, 200)
|
|
db.ORM.First(&user, "id=?", uid)
|
|
if !user.StorageVipExpireAt.Equal(before.AddDate(1, 0, 0)) {
|
|
t.Fatalf("annual membership wrong: %v", user.StorageVipExpireAt)
|
|
}
|
|
// A successful WeChat transaction cannot be applied to another order.
|
|
duplicate := create("month")
|
|
same := transaction(duplicate)
|
|
same.TransactionId = transaction(order).TransactionId
|
|
if err := deliverWechatStorageOrder(db, same); err == nil {
|
|
t.Fatal("duplicate WeChat transaction accepted")
|
|
}
|
|
gateway.query = transaction(duplicate)
|
|
gateway.query.TradeState = core.String("CLOSED")
|
|
r = request("storage_order_status", "GET", token, M{"out_trade_no": duplicate.OutTradeNo}, 200)
|
|
if r["status"] != "closed" {
|
|
t.Fatal("closed order not recorded")
|
|
}
|
|
// Transactional rollback if the owner has been deleted.
|
|
vanished := create("month")
|
|
var beforeDeleted model.User
|
|
db.ORM.First(&beforeDeleted, "id=?", uid)
|
|
if err := db.ORM.Model(&model.User{}).Where("id=?", uid).Update("deleted_at", time.Now()).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := deliverWechatStorageOrder(db, transaction(vanished)); err == nil {
|
|
t.Fatal("deleted owner granted membership")
|
|
}
|
|
db.ORM.Unscoped().Model(&model.User{}).Where("id=?", uid).Update("deleted_at", nil)
|
|
var rolledBack model.StorageOrder
|
|
db.ORM.First(&rolledBack, "id=?", vanished.Id)
|
|
if rolledBack.Status != "pending" {
|
|
t.Fatal("failed grant marked paid")
|
|
}
|
|
// Search by order number, UUID, and public ID must all stay valid SQL.
|
|
var owner model.User
|
|
db.ORM.First(&owner, "id=?", uid)
|
|
for _, term := range []string{order.OutTradeNo, uid, owner.UserId} {
|
|
request("admin_storage_orders", "GET", "admin", M{"search": term}, 200)
|
|
}
|
|
t.Log(fmt.Sprintf("WeChat payment snapshots, validation, replay safety and query recovery verified"))
|
|
}
|