2026-07-16 18:51:58 +08:00

256 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}