256 lines
11 KiB
Go
256 lines
11 KiB
Go
package mysql
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/xerr"
|
||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||
)
|
||
|
||
// FreezeSalaryWithdrawal 在同一事务里把工资 available 转入 frozen,并落交易、分录和余额 outbox。
|
||
func (r *Repository) FreezeSalaryWithdrawal(ctx context.Context, command ledger.SalaryWithdrawalCommand) (ledger.SalaryWithdrawalReceipt, error) {
|
||
return r.applySalaryWithdrawal(ctx, command, salaryWithdrawalMutation{
|
||
BizType: bizTypeSalaryWithdrawalFreeze,
|
||
AvailableDelta: -command.SalaryUSDMinor,
|
||
FrozenDelta: command.SalaryUSDMinor,
|
||
ExternalRef: command.WithdrawalRef,
|
||
})
|
||
}
|
||
|
||
// SettleSalaryWithdrawal 在审核通过时只扣 frozen;这保证提交申请时已经冻结的金额不会被重复扣 available。
|
||
func (r *Repository) SettleSalaryWithdrawal(ctx context.Context, command ledger.SalaryWithdrawalCommand) (ledger.SalaryWithdrawalReceipt, error) {
|
||
return r.applySalaryWithdrawal(ctx, command, salaryWithdrawalMutation{
|
||
BizType: bizTypeSalaryWithdrawalSettle,
|
||
AvailableDelta: 0,
|
||
FrozenDelta: -command.SalaryUSDMinor,
|
||
ExternalRef: command.WithdrawalApplicationID,
|
||
})
|
||
}
|
||
|
||
// ReleaseSalaryWithdrawal 在拒绝或创建申请失败回滚时把 frozen 返还 available。
|
||
func (r *Repository) ReleaseSalaryWithdrawal(ctx context.Context, command ledger.SalaryWithdrawalCommand) (ledger.SalaryWithdrawalReceipt, error) {
|
||
return r.applySalaryWithdrawal(ctx, command, salaryWithdrawalMutation{
|
||
BizType: bizTypeSalaryWithdrawalRelease,
|
||
AvailableDelta: command.SalaryUSDMinor,
|
||
FrozenDelta: -command.SalaryUSDMinor,
|
||
ExternalRef: command.WithdrawalApplicationID,
|
||
})
|
||
}
|
||
|
||
type salaryWithdrawalMutation struct {
|
||
BizType string
|
||
AvailableDelta int64
|
||
FrozenDelta int64
|
||
ExternalRef string
|
||
}
|
||
|
||
func (r *Repository) applySalaryWithdrawal(ctx context.Context, command ledger.SalaryWithdrawalCommand, mutation salaryWithdrawalMutation) (ledger.SalaryWithdrawalReceipt, error) {
|
||
if r == nil || r.db == nil {
|
||
return ledger.SalaryWithdrawalReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||
}
|
||
ctx = contextWithCommandApp(ctx, command.AppCode)
|
||
command.AppCode = appcode.FromContext(ctx)
|
||
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return ledger.SalaryWithdrawalReceipt{}, err
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
// 财务旧版 command id 带 approved/rejected,新版按 finance stage 固定。在 command 幂等前先锁定提现申请的唯一终局,
|
||
// 才能让旧 command 成功但 admin 未终态的 partial-pending 重试返回原回执,且阻止另一个 command 执行相反决策。
|
||
if receipt, exists, err := r.lookupSalaryWithdrawalTerminal(ctx, tx, command, mutation.BizType); err != nil || exists {
|
||
if err != nil || !exists {
|
||
return ledger.SalaryWithdrawalReceipt{}, err
|
||
}
|
||
return receipt, nil
|
||
}
|
||
|
||
requestHash := salaryWithdrawalRequestHash(command, mutation.BizType)
|
||
if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, mutation.BizType, xerr.IdempotencyConflict); err != nil || exists {
|
||
if err != nil || !exists {
|
||
return ledger.SalaryWithdrawalReceipt{}, err
|
||
}
|
||
return r.receiptForSalaryWithdrawalTransaction(ctx, tx, txRow.TransactionID)
|
||
}
|
||
|
||
nowMs := time.Now().UTC().UnixMilli()
|
||
account, err := r.lockAccount(ctx, tx, command.UserID, command.SalaryAssetType, false, nowMs)
|
||
if err != nil {
|
||
return ledger.SalaryWithdrawalReceipt{}, err
|
||
}
|
||
if account.AvailableAmount+mutation.AvailableDelta < 0 || account.FrozenAmount+mutation.FrozenDelta < 0 {
|
||
return ledger.SalaryWithdrawalReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
|
||
}
|
||
|
||
transactionID := transactionID(command.AppCode, command.CommandID)
|
||
availableAfter := account.AvailableAmount + mutation.AvailableDelta
|
||
frozenAfter := account.FrozenAmount + mutation.FrozenDelta
|
||
metadata := salaryWithdrawalMetadata{
|
||
AppCode: command.AppCode,
|
||
UserID: command.UserID,
|
||
SalaryAssetType: command.SalaryAssetType,
|
||
SalaryUSDMinor: command.SalaryUSDMinor,
|
||
PointFeeAmount: command.PointFeeAmount,
|
||
PointNetAmount: command.PointNetAmount,
|
||
PointsPerUSD: command.PointsPerUSD,
|
||
PointWithdrawFeeBPS: command.PointWithdrawFeeBPS,
|
||
OperatorUserID: command.OperatorUserID,
|
||
Reason: command.Reason,
|
||
WithdrawalRef: command.WithdrawalRef,
|
||
WithdrawalApplicationID: command.WithdrawalApplicationID,
|
||
AvailableAfter: availableAfter,
|
||
FrozenAfter: frozenAfter,
|
||
Version: account.Version + 1,
|
||
CreatedAtMS: nowMs,
|
||
}
|
||
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, mutation.BizType, requestHash, strings.TrimSpace(mutation.ExternalRef), metadata, nowMs); err != nil {
|
||
return ledger.SalaryWithdrawalReceipt{}, err
|
||
}
|
||
if err := r.applyAccountDelta(ctx, tx, account, mutation.AvailableDelta, mutation.FrozenDelta, nowMs); err != nil {
|
||
return ledger.SalaryWithdrawalReceipt{}, err
|
||
}
|
||
if err := r.insertEntry(ctx, tx, walletEntry{
|
||
TransactionID: transactionID,
|
||
UserID: command.UserID,
|
||
AssetType: command.SalaryAssetType,
|
||
AvailableDelta: mutation.AvailableDelta,
|
||
FrozenDelta: mutation.FrozenDelta,
|
||
AvailableAfter: availableAfter,
|
||
FrozenAfter: frozenAfter,
|
||
CreatedAtMS: nowMs,
|
||
}); err != nil {
|
||
return ledger.SalaryWithdrawalReceipt{}, err
|
||
}
|
||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
||
balanceChangedEvent(transactionID, command.CommandID, command.UserID, command.SalaryAssetType, mutation.AvailableDelta, mutation.FrozenDelta, availableAfter, frozenAfter, account.Version+1, metadata, nowMs, mutation.BizType),
|
||
}); err != nil {
|
||
return ledger.SalaryWithdrawalReceipt{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return ledger.SalaryWithdrawalReceipt{}, err
|
||
}
|
||
return receiptFromSalaryWithdrawalMetadata(transactionID, metadata), nil
|
||
}
|
||
|
||
func salaryWithdrawalRequestHash(command ledger.SalaryWithdrawalCommand, bizType string) string {
|
||
if strings.TrimSpace(command.WithdrawalApplicationID) != "" && (bizType == bizTypeSalaryWithdrawalSettle || bizType == bizTypeSalaryWithdrawalRelease) {
|
||
// 审核人和备注是钱包 metadata 快照,不决定资金突变。将它们排除后,admin 事务失败换人/改备注重试仍命中原交易。
|
||
// bizType 仍在哈希中,且 lookupSalaryWithdrawalTerminal 会在申请维度检查相反终局,settle/release 绝不能共存。
|
||
return stableHash(fmt.Sprintf("salary_withdrawal_terminal|%s|%s|%d|%s|%d|%d|%d|%d|%d|%s",
|
||
bizType,
|
||
appcode.Normalize(command.AppCode),
|
||
command.UserID,
|
||
strings.ToUpper(strings.TrimSpace(command.SalaryAssetType)),
|
||
command.SalaryUSDMinor,
|
||
command.PointFeeAmount,
|
||
command.PointNetAmount,
|
||
command.PointsPerUSD,
|
||
command.PointWithdrawFeeBPS,
|
||
strings.TrimSpace(command.WithdrawalApplicationID),
|
||
))
|
||
}
|
||
// 冻结和申请创建失败的回滚没有 application id,继续使用旧哈希保持已有 command 兼容。
|
||
return stableHash(fmt.Sprintf("salary_withdrawal|%s|%s|%d|%s|%d|%d|%d|%d|%d|%d|%s|%s|%s",
|
||
bizType,
|
||
appcode.Normalize(command.AppCode),
|
||
command.UserID,
|
||
strings.ToUpper(strings.TrimSpace(command.SalaryAssetType)),
|
||
command.SalaryUSDMinor,
|
||
command.PointFeeAmount,
|
||
command.PointNetAmount,
|
||
command.PointsPerUSD,
|
||
command.PointWithdrawFeeBPS,
|
||
command.OperatorUserID,
|
||
strings.TrimSpace(command.Reason),
|
||
strings.TrimSpace(command.WithdrawalRef),
|
||
strings.TrimSpace(command.WithdrawalApplicationID),
|
||
))
|
||
}
|
||
|
||
// lookupSalaryWithdrawalTerminal 先用小型主键锁表串行化同一申请,再使用 (app_code, external_ref) 现有索引查找旧/新终局。
|
||
// InnoDB 纯 gap lock 之间可兼容,不能单独作为“未存在终局”的互斥;显式锁行使首次并发 settle/release 也只有一个能落账。
|
||
func (r *Repository) lookupSalaryWithdrawalTerminal(ctx context.Context, tx *sql.Tx, command ledger.SalaryWithdrawalCommand, requestedBizType string) (ledger.SalaryWithdrawalReceipt, bool, error) {
|
||
applicationID := strings.TrimSpace(command.WithdrawalApplicationID)
|
||
if applicationID == "" || (requestedBizType != bizTypeSalaryWithdrawalSettle && requestedBizType != bizTypeSalaryWithdrawalRelease) {
|
||
return ledger.SalaryWithdrawalReceipt{}, false, nil
|
||
}
|
||
nowMS := time.Now().UTC().UnixMilli()
|
||
if _, err := tx.ExecContext(ctx, `
|
||
INSERT INTO wallet_withdrawal_terminal_locks (
|
||
app_code, withdrawal_application_id, created_at_ms, updated_at_ms
|
||
) VALUES (?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE withdrawal_application_id = VALUES(withdrawal_application_id)`,
|
||
appcode.FromContext(ctx), applicationID, nowMS, nowMS,
|
||
); err != nil {
|
||
return ledger.SalaryWithdrawalReceipt{}, false, err
|
||
}
|
||
rows, err := tx.QueryContext(ctx, `
|
||
SELECT transaction_id, biz_type, COALESCE(CAST(metadata_json AS CHAR), '{}')
|
||
FROM wallet_transactions
|
||
WHERE app_code = ?
|
||
AND external_ref = ?
|
||
AND biz_type IN (?, ?)
|
||
ORDER BY created_at_ms ASC, transaction_id ASC
|
||
FOR UPDATE`, appcode.FromContext(ctx), applicationID, bizTypeSalaryWithdrawalSettle, bizTypeSalaryWithdrawalRelease)
|
||
if err != nil {
|
||
return ledger.SalaryWithdrawalReceipt{}, false, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var terminal *struct {
|
||
TransactionID string
|
||
BizType string
|
||
Metadata salaryWithdrawalMetadata
|
||
}
|
||
for rows.Next() {
|
||
var transactionID string
|
||
var bizType string
|
||
var metadataJSON string
|
||
if err := rows.Scan(&transactionID, &bizType, &metadataJSON); err != nil {
|
||
return ledger.SalaryWithdrawalReceipt{}, false, err
|
||
}
|
||
if terminal != nil {
|
||
// 历史如果已经存在多个终局,钱包不能猜测哪个是真实决策,必须 fail closed 交由人工对账。
|
||
return ledger.SalaryWithdrawalReceipt{}, true, xerr.New(xerr.IdempotencyConflict, "withdrawal application has multiple wallet terminal transactions")
|
||
}
|
||
var metadata salaryWithdrawalMetadata
|
||
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
|
||
return ledger.SalaryWithdrawalReceipt{}, false, err
|
||
}
|
||
terminal = &struct {
|
||
TransactionID string
|
||
BizType string
|
||
Metadata salaryWithdrawalMetadata
|
||
}{TransactionID: transactionID, BizType: bizType, Metadata: metadata}
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return ledger.SalaryWithdrawalReceipt{}, false, err
|
||
}
|
||
if terminal == nil {
|
||
return ledger.SalaryWithdrawalReceipt{}, false, nil
|
||
}
|
||
if terminal.BizType != requestedBizType || !salaryWithdrawalTerminalMatches(command, terminal.Metadata) {
|
||
return ledger.SalaryWithdrawalReceipt{}, true, xerr.New(xerr.IdempotencyConflict, "withdrawal application terminal idempotency conflict")
|
||
}
|
||
return receiptFromSalaryWithdrawalMetadata(terminal.TransactionID, terminal.Metadata), true, nil
|
||
}
|
||
|
||
func salaryWithdrawalTerminalMatches(command ledger.SalaryWithdrawalCommand, metadata salaryWithdrawalMetadata) bool {
|
||
return appcode.Normalize(metadata.AppCode) == appcode.Normalize(command.AppCode) &&
|
||
metadata.UserID == command.UserID &&
|
||
strings.ToUpper(strings.TrimSpace(metadata.SalaryAssetType)) == strings.ToUpper(strings.TrimSpace(command.SalaryAssetType)) &&
|
||
metadata.SalaryUSDMinor == command.SalaryUSDMinor &&
|
||
metadata.PointFeeAmount == command.PointFeeAmount &&
|
||
metadata.PointNetAmount == command.PointNetAmount &&
|
||
metadata.PointsPerUSD == command.PointsPerUSD &&
|
||
metadata.PointWithdrawFeeBPS == command.PointWithdrawFeeBPS &&
|
||
strings.TrimSpace(metadata.WithdrawalApplicationID) == strings.TrimSpace(command.WithdrawalApplicationID)
|
||
}
|