package mysql import ( "context" "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() }() 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 { 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), )) }