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