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)