Files
olons_server/internal/store/withdrawals.go
T

598 lines
26 KiB
Go

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"`
DigitalRMBCodeCipher string `db:"digital_rmb_code_cipher"`
DigitalRMBCodeHash string `db:"digital_rmb_code_hash"`
DigitalRMBCodeMask string `db:"digital_rmb_code_mask"`
QRAttachmentID int64 `db:"qr_attachment_id"`
WechatQRAttachmentID int64 `db:"wechat_qr_attachment_id"`
AlipayQRAttachmentID int64 `db:"alipay_qr_attachment_id"`
WechatQRURL string `db:"wechat_qr_url"`
AlipayQRURL string `db:"alipay_qr_url"`
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
DigitalRMBCodeCipher string
DigitalRMBCodeHash string
DigitalRMBCodeMask 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
ExpectedDigitalRMBCodeCipher string
ExpectedDigitalRMBCodeHash 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(digital_rmb_code_mask,'') AS digital_rmb_code_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
}
// PaymentAccountDetails returns encrypted fields and their masked fallbacks for the authenticated owner.
func (s *Store) PaymentAccountDetails(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) {
items := make([]PaymentAccount, 0)
err := s.db.SelectContext(ctx, &items, `SELECT a.id,a.account_type,a.bank_code,a.bank_name,
COALESCE(a.holder_cipher,'') AS holder_cipher,
COALESCE(a.holder_mask,'') AS holder_mask,
COALESCE(a.identity_no_cipher,'') AS identity_no_cipher,
COALESCE(a.identity_no_mask,'') AS identity_no_mask,
COALESCE(a.account_no_cipher,'') AS account_no_cipher,
COALESCE(a.account_no_mask,'') AS account_no_mask,
COALESCE(a.digital_rmb_code_cipher,'') AS digital_rmb_code_cipher,
COALESCE(a.digital_rmb_code_mask,'') AS digital_rmb_code_mask,
COALESCE(a.wechat_qr_attachment_id,0) AS wechat_qr_attachment_id,
COALESCE(a.alipay_qr_attachment_id,0) AS alipay_qr_attachment_id,
COALESCE(wechat.file_url,'') AS wechat_qr_url,
COALESCE(alipay.file_url,'') AS alipay_qr_url,
a.verify_status,a.is_default
FROM hg_app_member_payment_account a
LEFT JOIN hg_sys_attachment wechat ON wechat.id=a.wechat_qr_attachment_id
AND wechat.member_id=a.member_id AND wechat.app_id='olons_server' AND wechat.status=1
LEFT JOIN hg_sys_attachment alipay ON alipay.id=a.alipay_qr_attachment_id
AND alipay.member_id=a.member_id AND alipay.app_id='olons_server' AND alipay.status=1
WHERE a.tenant_id=? AND a.member_id=? AND a.deleted_at IS NULL
ORDER BY a.is_default ASC,a.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,
digital_rmb_code_cipher,digital_rmb_code_hash,digital_rmb_code_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.DigitalRMBCodeCipher, input.DigitalRMBCodeHash, input.DigitalRMBCodeMask,
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, DigitalRMBCodeMask: input.DigitalRMBCodeMask,
WechatQRAttachmentID: input.WechatQRAttachmentID,
AlipayQRAttachmentID: input.AlipayQRAttachmentID,
VerifyStatus: 2, IsDefault: isDefault}, nil
}
func (s *Store) UpdatePaymentAccount(ctx context.Context, accountID int64, input PaymentAccountCreation) (*PaymentAccount, error) {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
var isDefault int
err = tx.GetContext(ctx, &isDefault, `SELECT is_default FROM hg_app_member_payment_account
WHERE id=? AND tenant_id=? AND member_id=? AND deleted_at IS NULL LIMIT 1 FOR UPDATE`,
accountID, input.TenantID, input.MemberID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrPaymentAccountInvalid
}
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
}
_, err = tx.ExecContext(ctx, `UPDATE hg_app_member_payment_account SET
bank_name=?,holder_cipher=?,holder_mask=?,identity_no_cipher=?,identity_no_hash=?,identity_no_mask=?,
account_no_cipher=?,account_no_hash=?,account_no_mask=?,digital_rmb_code_cipher=?,
digital_rmb_code_hash=?,digital_rmb_code_mask=?,wechat_qr_attachment_id=?,alipay_qr_attachment_id=?,
verify_status=2,updated_by=?,updated_at=NOW() WHERE id=? AND tenant_id=? AND member_id=? AND deleted_at IS NULL`,
input.BankName, input.HolderCipher, input.HolderMask, input.IdentityNoCipher, input.IdentityNoHash,
input.IdentityNoMask, input.AccountNoCipher, input.AccountNoHash, input.AccountNoMask,
input.DigitalRMBCodeCipher, input.DigitalRMBCodeHash, input.DigitalRMBCodeMask,
input.WechatQRAttachmentID, input.AlipayQRAttachmentID, input.MemberID,
accountID, input.TenantID, input.MemberID)
if err != nil {
if isDuplicate(err) {
return nil, ErrPaymentAccountExists
}
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return &PaymentAccount{ID: accountID, AccountType: input.AccountType, BankCode: input.BankCode,
BankName: input.BankName, HolderMask: input.HolderMask, IdentityNoMask: input.IdentityNoMask,
AccountNoMask: input.AccountNoMask, DigitalRMBCodeMask: input.DigitalRMBCodeMask,
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(digital_rmb_code_cipher,'') AS digital_rmb_code_cipher,
COALESCE(digital_rmb_code_hash,'') AS digital_rmb_code_hash,
COALESCE(digital_rmb_code_mask,'') AS digital_rmb_code_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<UTC_DATE()+INTERVAL 1 DAY) AS today_count
FROM hg_wallet_account w JOIN hg_app_member m ON m.tenant_id=w.tenant_id AND m.id=w.member_id
LEFT JOIN hg_app_member_level l ON l.tenant_id=m.tenant_id AND l.id=m.level_id AND l.status=1 AND l.deleted_at IS NULL
LEFT JOIN hg_app_member_level_version lv ON lv.tenant_id=l.tenant_id AND lv.id=l.current_version_id AND lv.publish_status=3
WHERE w.tenant_id=? AND w.member_id=? AND w.currency='CNY' AND m.member_status=1 AND m.deleted_at IS NULL LIMIT 1`,
tenantID, memberID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAccountRestricted
}
return &row, err
}
func (s *Store) CreateWithdrawal(ctx context.Context, input WithdrawalCreation) (*WithdrawalOrder, 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 trade_password_hash=? AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`,
input.TenantID, input.MemberID, input.ExpectedTradePasswordHash)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrConcurrentUpdate
}
if err != nil {
return nil, err
}
if existing, findErr := withdrawalByNo(ctx, tx, input.TenantID, input.MemberID, input.WithdrawalNo); findErr == nil {
if existing.PaymentAccountID != input.PaymentAccountID || existing.RequestAmount != input.RequestAmount || existing.Currency != input.Currency {
return nil, ErrIdempotencyConflict
}
existing.Replayed = true
if err := tx.Commit(); err != nil {
return nil, err
}
return existing, nil
} else if !errors.Is(findErr, ErrWithdrawalNotFound) {
return nil, findErr
}
var account PaymentAccount
err = tx.GetContext(ctx, &account, `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(digital_rmb_code_cipher,'') AS digital_rmb_code_cipher,
COALESCE(digital_rmb_code_hash,'') AS digital_rmb_code_hash,
COALESCE(digital_rmb_code_mask,'') AS digital_rmb_code_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,'' AS created_at,'' AS updated_at
FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND id=?
AND verify_status=2 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID, input.PaymentAccountID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrPaymentAccountInvalid
}
if err != nil {
return nil, err
}
if account.AccountType != input.ExpectedAccountType || account.BankCode != input.ExpectedBankCode ||
account.BankName != input.ExpectedBankName || account.HolderCipher != input.ExpectedHolderCipher ||
account.IdentityNoCipher != input.ExpectedIdentityNoCipher || account.IdentityNoHash != input.ExpectedIdentityNoHash ||
account.AccountNoCipher != input.ExpectedAccountNoCipher || account.AccountNoHash != input.ExpectedAccountNoHash ||
account.DigitalRMBCodeCipher != input.ExpectedDigitalRMBCodeCipher ||
account.DigitalRMBCodeHash != input.ExpectedDigitalRMBCodeHash ||
account.WechatQRAttachmentID != input.ExpectedWechatQRAttachmentID ||
account.AlipayQRAttachmentID != input.ExpectedAlipayQRAttachmentID {
return nil, ErrConcurrentUpdate
}
var channel WithdrawalChannel
err = tx.GetContext(ctx, &channel, `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 id=?
AND direction IN (2,3) AND status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.Channel.ID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrWithdrawalChannel
}
if err != nil {
return nil, err
}
if channel != input.Channel {
return nil, ErrConcurrentUpdate
}
if input.RequestAmount < maxInt64(1, channel.MinAmount) || (channel.MaxAmount > 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<UTC_DATE()+INTERVAL 1 DAY) AS today_count
FROM hg_wallet_account w JOIN hg_app_member m ON m.tenant_id=w.tenant_id AND m.id=w.member_id
LEFT JOIN hg_app_member_level l ON l.tenant_id=m.tenant_id AND l.id=m.level_id AND l.status=1 AND l.deleted_at IS NULL
LEFT JOIN hg_app_member_level_version lv ON lv.tenant_id=l.tenant_id AND lv.id=l.current_version_id AND lv.publish_status=3
WHERE w.tenant_id=? AND w.member_id=? AND w.currency=? LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID, input.Currency)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAccountRestricted
}
if err != nil {
return nil, err
}
if wallet.AccountStatus != 1 {
return nil, ErrAccountRestricted
}
if wallet.DailyLimit > 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
}