package store import ( "context" "database/sql" "encoding/json" "errors" "github.com/jmoiron/sqlx" ) type PaymentAccount struct { ID int64 `db:"id"` AccountType int `db:"account_type"` BankCode string `db:"bank_code"` BankName string `db:"bank_name"` HolderCipher string `db:"holder_cipher"` HolderMask string `db:"holder_mask"` IdentityNoCipher string `db:"identity_no_cipher"` IdentityNoHash string `db:"identity_no_hash"` IdentityNoMask string `db:"identity_no_mask"` MobileCipher string `db:"mobile_cipher"` MobileMask string `db:"mobile_mask"` AccountNoCipher string `db:"account_no_cipher"` AccountNoHash string `db:"account_no_hash"` AccountNoMask string `db:"account_no_mask"` QRAttachmentID int64 `db:"qr_attachment_id"` WechatQRAttachmentID int64 `db:"wechat_qr_attachment_id"` AlipayQRAttachmentID int64 `db:"alipay_qr_attachment_id"` VerifyStatus int `db:"verify_status"` IsDefault int `db:"is_default"` CreatedAt string `db:"created_at"` UpdatedAt string `db:"updated_at"` } type PaymentAccountCreation struct { TenantID int64 MemberID int64 AccountType int BankCode string BankName string HolderCipher string HolderMask string IdentityNoCipher string IdentityNoHash string IdentityNoMask string AccountNoCipher string AccountNoHash string AccountNoMask string WechatQRAttachmentID int64 AlipayQRAttachmentID int64 } type WithdrawalChannel struct { ID int64 `db:"id"` Code string `db:"channel_code"` Name string `db:"name"` ChannelType int `db:"channel_type"` MinAmount int64 `db:"min_amount"` MaxAmount int64 `db:"max_amount"` FeeType int `db:"fee_type"` FeeFixedAmount int64 `db:"fee_fixed_amount"` FeeRate int `db:"fee_rate"` } type WithdrawalWallet struct { AccountID int64 `db:"account_id"` AvailableAmount int64 `db:"available_amount"` FrozenAmount int64 `db:"frozen_amount"` AccountStatus int `db:"account_status"` DailyLimit int `db:"daily_limit"` TodayCount int `db:"today_count"` } type WithdrawalCreation struct { TenantID int64 MemberID int64 ExpectedTradePasswordHash string WithdrawalNo string PaymentAccountID int64 ExpectedAccountType int ExpectedBankCode string ExpectedBankName string ExpectedHolderCipher string ExpectedIdentityNoCipher string ExpectedIdentityNoHash string ExpectedAccountNoCipher string ExpectedAccountNoHash string ExpectedWechatQRAttachmentID int64 ExpectedAlipayQRAttachmentID int64 Channel WithdrawalChannel RequestAmount int64 FeeAmount int64 ActualAmount int64 Currency string AccountSnapshotCipher string AccountMask string BankName string HolderMask string AvailableLedgerNo string FrozenLedgerNo string } type WithdrawalOrder struct { ID int64 `db:"id"` WithdrawalNo string `db:"withdrawal_no"` PaymentAccountID int64 `db:"payment_account_id"` ChannelID int64 `db:"channel_id"` ChannelName string `db:"channel_name"` RequestAmount int64 `db:"request_amount"` FeeAmount int64 `db:"fee_amount"` ActualAmount int64 `db:"actual_amount"` Currency string `db:"currency"` AccountMask string `db:"account_mask"` BankName string `db:"bank_name"` HolderMask string `db:"holder_mask"` RiskResult json.RawMessage `db:"risk_result"` Status int `db:"withdrawal_status"` ReviewReasonCode string `db:"review_reason_code"` ReviewReason string `db:"review_reason"` ExternalNo string `db:"external_no"` ReviewedAt string `db:"reviewed_at"` ProcessedAt string `db:"processed_at"` CompletedAt string `db:"completed_at"` CreatedAt string `db:"created_at"` UpdatedAt string `db:"updated_at"` Replayed bool `db:"-"` } type WithdrawalPage struct { Items []WithdrawalOrder Total int64 } func (s *Store) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) { items := make([]PaymentAccount, 0) err := s.db.SelectContext(ctx, &items, `SELECT id,account_type,bank_code,bank_name,holder_mask, COALESCE(identity_no_mask,'') AS identity_no_mask,account_no_mask, COALESCE(wechat_qr_attachment_id,0) AS wechat_qr_attachment_id, COALESCE(alipay_qr_attachment_id,0) AS alipay_qr_attachment_id,verify_status,is_default, DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at, DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND deleted_at IS NULL ORDER BY is_default ASC,id DESC`, tenantID, memberID) return items, err } func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCreation) (*PaymentAccount, error) { tx, err := s.db.BeginTxx(ctx, nil) if err != nil { return nil, err } defer tx.Rollback() var memberID int64 err = tx.GetContext(ctx, &memberID, `SELECT id FROM hg_app_member WHERE tenant_id=? AND id=? AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID) if errors.Is(err, sql.ErrNoRows) { return nil, ErrConcurrentUpdate } if err != nil { return nil, err } var attachmentCount int err = tx.GetContext(ctx, &attachmentCount, `SELECT COUNT(*) FROM hg_sys_attachment WHERE id IN (?,?) AND app_id='olons_server' AND member_id=? AND status=1 AND mime_type LIKE 'image/%'`, input.WechatQRAttachmentID, input.AlipayQRAttachmentID, input.MemberID) if err != nil { return nil, err } if attachmentCount != 2 { return nil, ErrAttachmentInvalid } var accountCount int if err = tx.GetContext(ctx, &accountCount, `SELECT COUNT(*) FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND deleted_at IS NULL`, input.TenantID, input.MemberID); err != nil { return nil, err } isDefault := 2 if accountCount == 0 { isDefault = 1 } result, err := tx.ExecContext(ctx, `INSERT INTO hg_app_member_payment_account (tenant_id,member_id,account_type,bank_code,bank_name,holder_cipher,holder_mask, identity_no_cipher,identity_no_hash,identity_no_mask,account_no_cipher,account_no_hash,account_no_mask, wechat_qr_attachment_id,alipay_qr_attachment_id,verify_status, is_default,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,2,?,?,?)`, input.TenantID, input.MemberID, input.AccountType, input.BankCode, input.BankName, input.HolderCipher, input.HolderMask, input.IdentityNoCipher, input.IdentityNoHash, input.IdentityNoMask, input.AccountNoCipher, input.AccountNoHash, input.AccountNoMask, input.WechatQRAttachmentID, input.AlipayQRAttachmentID, isDefault, input.MemberID, input.MemberID) if err != nil { if isDuplicate(err) { return nil, ErrPaymentAccountExists } return nil, err } id, err := result.LastInsertId() if err != nil { return nil, err } if err = tx.Commit(); err != nil { return nil, err } return &PaymentAccount{ID: id, AccountType: input.AccountType, BankCode: input.BankCode, BankName: input.BankName, HolderMask: input.HolderMask, IdentityNoMask: input.IdentityNoMask, AccountNoMask: input.AccountNoMask, WechatQRAttachmentID: input.WechatQRAttachmentID, AlipayQRAttachmentID: input.AlipayQRAttachmentID, VerifyStatus: 2, IsDefault: isDefault}, nil } func nullableString(value string) any { if value == "" { return nil } return value } func (s *Store) PaymentAccountForWithdrawal(ctx context.Context, tenantID, memberID, accountID int64) (*PaymentAccount, error) { var row PaymentAccount err := s.db.GetContext(ctx, &row, `SELECT id,account_type,bank_code,bank_name,holder_cipher,holder_mask, COALESCE(identity_no_cipher,'') AS identity_no_cipher,COALESCE(identity_no_hash,'') AS identity_no_hash, COALESCE(identity_no_mask,'') AS identity_no_mask,account_no_cipher,account_no_hash,account_no_mask, COALESCE(wechat_qr_attachment_id,0) AS wechat_qr_attachment_id, COALESCE(alipay_qr_attachment_id,0) AS alipay_qr_attachment_id,verify_status,is_default, DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at, DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND id=? AND deleted_at IS NULL LIMIT 1`, tenantID, memberID, accountID) if errors.Is(err, sql.ErrNoRows) { return nil, ErrPaymentAccountInvalid } return &row, err } func (s *Store) ActiveWithdrawalChannel(ctx context.Context, tenantID int64, channelTypes []int) (*WithdrawalChannel, error) { if len(channelTypes) == 0 { return nil, ErrWithdrawalChannel } query, args, err := sqlx.In(`SELECT id,channel_code,name,channel_type,min_amount,max_amount, fee_type,fee_fixed_amount,fee_rate FROM hg_payment_channel WHERE tenant_id=? AND channel_type IN (?) AND direction IN (2,3) AND status=1 AND deleted_at IS NULL ORDER BY CASE WHEN channel_type=? THEN 0 ELSE 1 END,sort,id LIMIT 1`, tenantID, channelTypes, channelTypes[0]) if err != nil { return nil, err } var row WithdrawalChannel err = s.db.GetContext(ctx, &row, query, args...) if errors.Is(err, sql.ErrNoRows) { return nil, ErrWithdrawalChannel } return &row, err } func (s *Store) WithdrawalWallet(ctx context.Context, tenantID, memberID int64) (*WithdrawalWallet, error) { var row WithdrawalWallet err := s.db.GetContext(ctx, &row, `SELECT w.id AS account_id,w.available_amount,w.frozen_amount,w.account_status, COALESCE(lv.daily_withdraw_limit,0) AS daily_limit, (SELECT COUNT(*) FROM hg_withdrawal_order o WHERE o.tenant_id=w.tenant_id AND o.member_id=w.member_id AND o.created_at>=UTC_DATE() AND o.created_at 0 && input.RequestAmount > channel.MaxAmount) { return nil, ErrWithdrawalAmount } var wallet WithdrawalWallet err = tx.GetContext(ctx, &wallet, `SELECT w.id AS account_id,w.available_amount,w.frozen_amount,w.account_status, COALESCE(lv.daily_withdraw_limit,0) AS daily_limit, (SELECT COUNT(*) FROM hg_withdrawal_order o WHERE o.tenant_id=w.tenant_id AND o.member_id=w.member_id AND o.created_at>=UTC_DATE() AND o.created_at 0 && wallet.TodayCount >= wallet.DailyLimit { return nil, ErrWithdrawalLimit } if wallet.AvailableAmount < input.RequestAmount { return nil, ErrInsufficientBalance } riskResult := json.RawMessage(`{"result":"passed","mode":"sandbox"}`) result, err := tx.ExecContext(ctx, `INSERT INTO hg_withdrawal_order (tenant_id,withdrawal_no,member_id,payment_account_id,channel_id,request_amount,fee_amount, actual_amount,currency,account_snapshot_cipher,account_mask,bank_name,holder_mask,risk_result, withdrawal_status,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`, input.TenantID, input.WithdrawalNo, input.MemberID, input.PaymentAccountID, channel.ID, input.RequestAmount, input.FeeAmount, input.ActualAmount, input.Currency, input.AccountSnapshotCipher, input.AccountMask, input.BankName, input.HolderMask, riskResult, input.MemberID, input.MemberID) if err != nil { if isDuplicate(err) { return nil, ErrIdempotencyConflict } return nil, err } orderID, err := result.LastInsertId() if err != nil { return nil, err } beforeAvailable := wallet.AvailableAmount afterAvailable := beforeAvailable - input.RequestAmount beforeFrozen := wallet.FrozenAmount afterFrozen := beforeFrozen + input.RequestAmount update, err := tx.ExecContext(ctx, `UPDATE hg_wallet_account SET available_amount=?,frozen_amount=?,version=version+1, updated_at=UTC_TIMESTAMP(3) WHERE id=? AND tenant_id=? AND available_amount=? AND frozen_amount=? AND account_status=1`, afterAvailable, afterFrozen, wallet.AccountID, input.TenantID, beforeAvailable, beforeFrozen) if err != nil { return nil, err } if affected, _ := update.RowsAffected(); affected != 1 { return nil, ErrConcurrentUpdate } availableLedgerID, err := insertWithdrawalLedger(ctx, tx, input, wallet.AccountID, input.AvailableLedgerNo, "available", 2, beforeAvailable, afterAvailable, "available", "取出申请冻结可用余额") if err != nil { return nil, err } _ = availableLedgerID frozenLedgerID, err := insertWithdrawalLedger(ctx, tx, input, wallet.AccountID, input.FrozenLedgerNo, "frozen", 1, beforeFrozen, afterFrozen, "frozen", "取出申请增加冻结余额") if err != nil { return nil, err } if _, err = tx.ExecContext(ctx, `UPDATE hg_withdrawal_order SET freeze_ledger_id=? WHERE tenant_id=? AND id=?`, frozenLedgerID, input.TenantID, orderID); err != nil { return nil, err } if err = tx.Commit(); err != nil { return nil, err } return &WithdrawalOrder{ID: orderID, WithdrawalNo: input.WithdrawalNo, PaymentAccountID: input.PaymentAccountID, ChannelID: channel.ID, ChannelName: channel.Name, RequestAmount: input.RequestAmount, FeeAmount: input.FeeAmount, ActualAmount: input.ActualAmount, Currency: input.Currency, AccountMask: input.AccountMask, BankName: input.BankName, HolderMask: input.HolderMask, RiskResult: riskResult, Status: 1}, nil } func insertWithdrawalLedger(ctx context.Context, tx *sqlx.Tx, input WithdrawalCreation, accountID int64, ledgerNo, entryType string, direction int, beforeAmount, afterAmount int64, suffix, remark string) (int64, error) { result, err := tx.ExecContext(ctx, `INSERT INTO hg_wallet_ledger (tenant_id,ledger_no,account_id,member_id,biz_type,biz_no,entry_type,direction,amount, before_amount,after_amount,currency,reversal_of,idempotency_key,remark,operator_id,occurred_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,0,?,?,?,UTC_TIMESTAMP(3))`, input.TenantID, ledgerNo, accountID, input.MemberID, "withdraw_freeze", input.WithdrawalNo, entryType, direction, input.RequestAmount, beforeAmount, afterAmount, input.Currency, "withdrawal:"+input.WithdrawalNo+":"+suffix, remark, input.MemberID) if err != nil { return 0, err } return result.LastInsertId() } func (s *Store) WithdrawalOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*WithdrawalPage, error) { where := ` WHERE o.tenant_id=? AND o.member_id=?` args := []any{tenantID, memberID} if status > 0 { where += ` AND o.withdrawal_status=?` args = append(args, status) } var total int64 if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_withdrawal_order o`+where, args...); err != nil { return nil, err } args = append(args, limit, offset) items := make([]WithdrawalOrder, 0) err := s.db.SelectContext(ctx, &items, withdrawalSelectSQL()+where+ ` ORDER BY o.created_at DESC,o.id DESC LIMIT ? OFFSET ?`, args...) return &WithdrawalPage{Items: items, Total: total}, err } func (s *Store) WithdrawalOrder(ctx context.Context, tenantID, memberID, orderID int64) (*WithdrawalOrder, error) { var row WithdrawalOrder err := s.db.GetContext(ctx, &row, withdrawalSelectSQL()+ ` WHERE o.tenant_id=? AND o.member_id=? AND o.id=? LIMIT 1`, tenantID, memberID, orderID) if errors.Is(err, sql.ErrNoRows) { return nil, ErrWithdrawalNotFound } return &row, err } func withdrawalByNo(ctx context.Context, db queryer, tenantID, memberID int64, no string) (*WithdrawalOrder, error) { query := withdrawalSelectSQL() + ` WHERE o.tenant_id=? AND o.member_id=? AND o.withdrawal_no=? LIMIT 1` var row WithdrawalOrder err := db.GetContext(ctx, &row, query, tenantID, memberID, no) if errors.Is(err, sql.ErrNoRows) { return nil, ErrWithdrawalNotFound } return &row, err } func withdrawalSelectSQL() string { return `SELECT o.id,o.withdrawal_no,o.payment_account_id,o.channel_id,COALESCE(c.name,'') AS channel_name, o.request_amount,o.fee_amount,o.actual_amount,o.currency,o.account_mask,o.bank_name,o.holder_mask, COALESCE(o.risk_result,JSON_OBJECT()) AS risk_result,o.withdrawal_status,o.review_reason_code, o.review_reason,o.external_no, COALESCE(DATE_FORMAT(o.reviewed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS reviewed_at, COALESCE(DATE_FORMAT(o.processed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS processed_at, COALESCE(DATE_FORMAT(o.completed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS completed_at, DATE_FORMAT(o.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at, DATE_FORMAT(o.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at FROM hg_withdrawal_order o LEFT JOIN hg_payment_channel c ON c.tenant_id=o.tenant_id AND c.id=o.channel_id` } func maxInt64(left, right int64) int64 { if left > right { return left } return right }