Files
NianAIGC/backend/internal/postgres/payments.go
T
2026-10-12 13:57:23 +08:00

332 lines
12 KiB
Go

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)