增加支付逻辑
This commit is contained in:
1 parent
4ac786aafb
commit
dfb2c91b8d
77 files changed
+6370
-45
No files matched your search
@@ -0,0 +1,331 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/payments"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
const paymentOrderColumns = `id, organization_id, account_id, request_key, mch_id, app_id,
|
||||
amount_fen, currency, status, COALESCE(code_url, ''), COALESCE(transaction_id, ''),
|
||||
created_at, updated_at, expires_at, paid_at`
|
||||
|
||||
const createPaymentOrderSQL = `INSERT INTO public.payment_orders
|
||||
(id, organization_id, account_id, request_key, mch_id, app_id, amount_fen, currency, expires_at)
|
||||
SELECT $1::text, organization.id, account.id, $4::text, $5::text, $6::text,
|
||||
$7::bigint, $8::text, $9::timestamptz
|
||||
FROM public.platform_organizations AS organization
|
||||
JOIN public.platform_users AS account ON account.id = $3::text
|
||||
WHERE organization.id = $2::text AND organization.status = 'active'
|
||||
AND account.status = 'active' AND account.organization_id = organization.id
|
||||
AND account.role IN ('organization_admin', 'super_admin')
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING ` + paymentOrderColumns
|
||||
|
||||
const getPaymentOrderSQL = `SELECT ` + paymentOrderColumns + ` FROM public.payment_orders WHERE id = $1::text`
|
||||
const getPaymentOrderByRequestSQL = `SELECT ` + paymentOrderColumns + ` FROM public.payment_orders WHERE organization_id = $1::text AND request_key = $2::text`
|
||||
const listPaymentOrdersSQL = `SELECT ` + paymentOrderColumns + ` FROM public.payment_orders WHERE organization_id = $1::text ORDER BY created_at DESC, id DESC LIMIT $2::integer`
|
||||
const pendingPaymentOrdersSQL = `SELECT ` + paymentOrderColumns + ` FROM public.payment_orders WHERE status = 'pending' ORDER BY updated_at ASC, created_at ASC, id ASC LIMIT $1::integer`
|
||||
const updatePendingPaymentOrderSQL = `UPDATE public.payment_orders SET
|
||||
code_url = CASE WHEN $2::text = '' THEN code_url ELSE $2::text END,
|
||||
updated_at = now()
|
||||
WHERE id = $1::text AND status = 'pending'
|
||||
RETURNING ` + paymentOrderColumns
|
||||
const closePaymentOrderSQL = `UPDATE public.payment_orders SET
|
||||
status = 'closed', code_url = NULL, updated_at = now()
|
||||
WHERE id = $1::text AND status = 'pending'
|
||||
RETURNING ` + paymentOrderColumns
|
||||
const lockPaymentOrderSQL = `SELECT ` + paymentOrderColumns + ` FROM public.payment_orders WHERE id = $1::text FOR UPDATE`
|
||||
const paidPaymentOrderSQL = `UPDATE public.payment_orders SET
|
||||
status = 'paid', transaction_id = $2::text, code_url = NULL,
|
||||
paid_at = $3::timestamptz, updated_at = now()
|
||||
WHERE id = $1::text AND status IN ('pending', 'closed')
|
||||
RETURNING ` + paymentOrderColumns
|
||||
|
||||
var paymentKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
|
||||
func (db *Database) paymentQuerier() (Querier, error) {
|
||||
if db == nil || db.config.Backend != BackendPostgres || db.querier == nil {
|
||||
return nil, payments.ErrUnavailable
|
||||
}
|
||||
return db.querier, nil
|
||||
}
|
||||
|
||||
func validPaymentKey(s string, maxLength int) bool {
|
||||
return len(s) > 0 && len(s) <= maxLength && paymentKeyPattern.MatchString(s)
|
||||
}
|
||||
|
||||
func (db *Database) CreatePaymentOrder(ctx context.Context, input payments.Order) (payments.Order, error) {
|
||||
q, err := db.paymentQuerier()
|
||||
if err != nil {
|
||||
return payments.Order{}, err
|
||||
}
|
||||
if !validPaymentKey(input.ID, 32) || !validPaymentKey(input.RequestKey, 64) ||
|
||||
input.OrganizationID == "" || input.AccountID == "" || input.MchID == "" || input.AppID == "" ||
|
||||
len(input.MchID) > 64 || len(input.AppID) > 64 || input.Currency != "CNY" ||
|
||||
input.ExpiresAt.IsZero() || !input.ExpiresAt.After(time.Now()) {
|
||||
return payments.Order{}, payments.ErrConflict
|
||||
}
|
||||
if input.AmountFen < 1 || input.AmountFen > 1_000_000 {
|
||||
return payments.Order{}, payments.ErrInvalidAmount
|
||||
}
|
||||
created, found, err := queryPaymentOrder(ctx, q, createPaymentOrderSQL,
|
||||
input.ID, input.OrganizationID, input.AccountID, input.RequestKey,
|
||||
input.MchID, input.AppID, input.AmountFen, input.Currency, input.ExpiresAt)
|
||||
if err != nil {
|
||||
return payments.Order{}, paymentSQLError("create payment order", err)
|
||||
}
|
||||
if found {
|
||||
return created, nil
|
||||
}
|
||||
previous, found, err := queryPaymentOrder(ctx, q, getPaymentOrderByRequestSQL, input.OrganizationID, input.RequestKey)
|
||||
if err != nil {
|
||||
return payments.Order{}, paymentSQLError("read payment request", err)
|
||||
}
|
||||
if !found || previous.AccountID != input.AccountID || previous.AmountFen != input.AmountFen ||
|
||||
previous.Currency != input.Currency || previous.AppID != input.AppID || previous.MchID != input.MchID {
|
||||
return payments.Order{}, payments.ErrConflict
|
||||
}
|
||||
return previous, nil
|
||||
}
|
||||
|
||||
func (db *Database) GetPaymentOrder(ctx context.Context, id string) (payments.Order, error) {
|
||||
q, err := db.paymentQuerier()
|
||||
if err != nil {
|
||||
return payments.Order{}, err
|
||||
}
|
||||
order, found, err := queryPaymentOrder(ctx, q, getPaymentOrderSQL, id)
|
||||
if err != nil {
|
||||
return payments.Order{}, paymentSQLError("get payment order", err)
|
||||
}
|
||||
if !found {
|
||||
return payments.Order{}, payments.ErrNotFound
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
func (db *Database) ListPaymentOrders(ctx context.Context, organizationID string, limit int) ([]payments.Order, error) {
|
||||
q, err := db.paymentQuerier()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if organizationID == "" {
|
||||
return nil, payments.ErrConflict
|
||||
}
|
||||
return queryPaymentOrders(ctx, q, listPaymentOrdersSQL, organizationID, boundedPaymentLimit(limit))
|
||||
}
|
||||
|
||||
func (db *Database) PendingPaymentOrders(ctx context.Context, limit int) ([]payments.Order, error) {
|
||||
q, err := db.paymentQuerier()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return queryPaymentOrders(ctx, q, pendingPaymentOrdersSQL, boundedPaymentLimit(limit))
|
||||
}
|
||||
|
||||
func boundedPaymentLimit(limit int) int {
|
||||
if limit < 1 {
|
||||
return 1
|
||||
}
|
||||
if limit > 500 {
|
||||
return 500
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func (db *Database) UpdatePaymentOrder(ctx context.Context, id, status, codeURL string) (payments.Order, error) {
|
||||
q, err := db.paymentQuerier()
|
||||
if err != nil {
|
||||
return payments.Order{}, err
|
||||
}
|
||||
if id == "" || len(codeURL) > 2048 || (status != payments.StatusPending && status != payments.StatusClosed) {
|
||||
return payments.Order{}, payments.ErrConflict
|
||||
}
|
||||
statement := closePaymentOrderSQL
|
||||
args := []any{id}
|
||||
if status == payments.StatusPending {
|
||||
statement = updatePendingPaymentOrderSQL
|
||||
args = append(args, codeURL)
|
||||
}
|
||||
order, found, err := queryPaymentOrder(ctx, q, statement, args...)
|
||||
if err != nil {
|
||||
return payments.Order{}, paymentSQLError("update payment order", err)
|
||||
}
|
||||
if found {
|
||||
return order, nil
|
||||
}
|
||||
return db.GetPaymentOrder(ctx, id)
|
||||
}
|
||||
|
||||
func (db *Database) SettlePaymentOrder(ctx context.Context, transaction payments.Transaction) (payments.Order, error) {
|
||||
if _, err := db.paymentQuerier(); err != nil || db.transactions == nil {
|
||||
return payments.Order{}, payments.ErrUnavailable
|
||||
}
|
||||
if transaction.OrderID == "" || transaction.MchID == "" || transaction.AppID == "" ||
|
||||
transaction.TransactionID == "" || transaction.TradeState != "SUCCESS" ||
|
||||
transaction.Currency != "CNY" || transaction.AmountFen < 1 || transaction.AmountFen > 1_000_000 {
|
||||
return payments.Order{}, payments.ErrVerification
|
||||
}
|
||||
tx, err := db.transactions.Begin(ctx)
|
||||
if err != nil {
|
||||
return payments.Order{}, paymentSQLError("begin payment settlement", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
order, found, err := queryPaymentOrder(ctx, tx, lockPaymentOrderSQL, transaction.OrderID)
|
||||
if err != nil {
|
||||
return payments.Order{}, paymentSQLError("lock payment order", err)
|
||||
}
|
||||
if !found {
|
||||
return payments.Order{}, payments.ErrNotFound
|
||||
}
|
||||
if order.MchID != transaction.MchID || order.AppID != transaction.AppID ||
|
||||
order.AmountFen != transaction.AmountFen || order.Currency != transaction.Currency {
|
||||
return payments.Order{}, payments.ErrVerification
|
||||
}
|
||||
if order.Status == payments.StatusPaid {
|
||||
if order.TransactionID != transaction.TransactionID {
|
||||
return payments.Order{}, payments.ErrConflict
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return payments.Order{}, paymentSQLError("commit payment replay", err)
|
||||
}
|
||||
committed = true
|
||||
return order, nil
|
||||
}
|
||||
if order.Status != payments.StatusPending && order.Status != payments.StatusClosed {
|
||||
return payments.Order{}, payments.ErrConflict
|
||||
}
|
||||
paidAt := transaction.SuccessTime
|
||||
if paidAt.IsZero() {
|
||||
paidAt = time.Now().UTC()
|
||||
}
|
||||
paid, found, err := queryPaymentOrder(ctx, tx, paidPaymentOrderSQL, order.ID, transaction.TransactionID, paidAt)
|
||||
if err != nil {
|
||||
return payments.Order{}, paymentSQLError("record payment transaction", err)
|
||||
}
|
||||
if !found {
|
||||
return payments.Order{}, payments.ErrConflict
|
||||
}
|
||||
metadata, err := json.Marshal(map[string]string{
|
||||
"source": "wechat_native", "paymentOrderId": order.ID,
|
||||
"transactionId": transaction.TransactionID, "mchId": transaction.MchID,
|
||||
"appId": transaction.AppID, "initiatedByAccountId": order.AccountID,
|
||||
})
|
||||
if err != nil {
|
||||
return payments.Order{}, err
|
||||
}
|
||||
rows, err := tx.Query(ctx, PostWalletEntrySQL,
|
||||
"wechat-native-entry:"+order.ID, order.OrganizationID, nil, nil,
|
||||
"recharge", order.AmountFen, "CNY", "wechat-native:"+order.MchID+":"+order.ID,
|
||||
"微信扫码充值", json.RawMessage(metadata))
|
||||
if err != nil {
|
||||
return payments.Order{}, paymentSQLError("post payment wallet credit", err)
|
||||
}
|
||||
if !rows.Next() {
|
||||
rowErr := rows.Err()
|
||||
rows.Close()
|
||||
if rowErr != nil {
|
||||
return payments.Order{}, paymentSQLError("read payment wallet credit", rowErr)
|
||||
}
|
||||
return payments.Order{}, fmt.Errorf("payment wallet credit returned no row")
|
||||
}
|
||||
var posting WalletEntry
|
||||
if err := rows.Scan(&posting.LedgerID, &posting.BalanceAfterFen, &posting.BalanceFen,
|
||||
&posting.TotalRechargedFen, &posting.TotalChargedFen, &posting.CreatedAt,
|
||||
&posting.UpdatedAt, &posting.DeltaFen); err != nil {
|
||||
rows.Close()
|
||||
return payments.Order{}, paymentSQLError("scan payment wallet credit", err)
|
||||
}
|
||||
rowErr := rows.Err()
|
||||
rows.Close()
|
||||
if rowErr != nil {
|
||||
return payments.Order{}, paymentSQLError("complete payment wallet credit", rowErr)
|
||||
}
|
||||
if posting.DeltaFen != order.AmountFen || posting.LedgerID != "wechat-native-entry:"+order.ID {
|
||||
return payments.Order{}, payments.ErrConflict
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
// The commit result can be ambiguous. A replay with this transaction ID
|
||||
// resolves it safely because paid status and wallet entry commit together.
|
||||
return payments.Order{}, paymentSQLError("commit payment settlement; query or replay the order", err)
|
||||
}
|
||||
committed = true
|
||||
return paid, nil
|
||||
}
|
||||
|
||||
func queryPaymentOrder(ctx context.Context, q Querier, statement string, args ...any) (payments.Order, bool, error) {
|
||||
rows, err := q.Query(ctx, statement, args...)
|
||||
if err != nil {
|
||||
return payments.Order{}, false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return payments.Order{}, false, rows.Err()
|
||||
}
|
||||
order, err := scanPaymentOrder(rows)
|
||||
if err != nil {
|
||||
return payments.Order{}, false, err
|
||||
}
|
||||
return order, true, rows.Err()
|
||||
}
|
||||
|
||||
func queryPaymentOrders(ctx context.Context, q Querier, statement string, args ...any) ([]payments.Order, error) {
|
||||
rows, err := q.Query(ctx, statement, args...)
|
||||
if err != nil {
|
||||
return nil, paymentSQLError("list payment orders", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]payments.Order, 0)
|
||||
for rows.Next() {
|
||||
order, err := scanPaymentOrder(rows)
|
||||
if err != nil {
|
||||
return nil, paymentSQLError("scan payment order", err)
|
||||
}
|
||||
out = append(out, order)
|
||||
}
|
||||
return out, paymentSQLError("read payment orders", rows.Err())
|
||||
}
|
||||
|
||||
func scanPaymentOrder(rows Rows) (payments.Order, error) {
|
||||
var order payments.Order
|
||||
var paidAt sql.NullTime
|
||||
err := rows.Scan(&order.ID, &order.OrganizationID, &order.AccountID, &order.RequestKey,
|
||||
&order.MchID, &order.AppID, &order.AmountFen, &order.Currency, &order.Status,
|
||||
&order.CodeURL, &order.TransactionID, &order.CreatedAt, &order.UpdatedAt,
|
||||
&order.ExpiresAt, &paidAt)
|
||||
if err != nil {
|
||||
return payments.Order{}, err
|
||||
}
|
||||
if paidAt.Valid {
|
||||
order.PaidAt = &paidAt.Time
|
||||
}
|
||||
if order.Status != payments.StatusPending {
|
||||
order.CodeURL = ""
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
func paymentSQLError(action string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && (pgErr.Code == "23505" || pgErr.Code == "23503" || pgErr.Code == "23514") {
|
||||
return fmt.Errorf("%w: %s: %v", payments.ErrConflict, action, err)
|
||||
}
|
||||
return fmt.Errorf("%s: %w", action, err)
|
||||
}
|
||||
|
||||
var _ payments.Repository = (*Database)(nil)
|
||||
Reference in new issue
Block a user