package wallet import ( "context" "strings" "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/domain/ledger" ) // FreezePointWithdrawal 把 POINT 从 available 冻结到 frozen;repository 会在账变事务内 // 重新解析已发布 wallet 政策,不能信任 gateway 预览时携带的比例与手续费。 func (s *Service) FreezePointWithdrawal(ctx context.Context, command ledger.PointWithdrawalCommand) (ledger.PointWithdrawalReceipt, error) { if err := normalizePointWithdrawalCommand(&command, false, true, false); err != nil { return ledger.PointWithdrawalReceipt{}, err } if command.Reason == "" { command.Reason = "point withdrawal freeze" } if s.repository == nil { return ledger.PointWithdrawalReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") } command.AppCode = appcode.Normalize(command.AppCode) ctx = appcode.WithContext(ctx, command.AppCode) receipt, err := s.repository.FreezeSalaryWithdrawal(ctx, pointWithdrawalAsSalaryCommand(command)) if err != nil { return ledger.PointWithdrawalReceipt{}, err } return pointWithdrawalReceiptFromSalary(receipt), nil } // SettlePointWithdrawal 在财务审核通过时扣掉 frozen 积分;冻结时已扣 available,因此这里不能再次扣 available。 func (s *Service) SettlePointWithdrawal(ctx context.Context, command ledger.PointWithdrawalCommand) (ledger.PointWithdrawalReceipt, error) { if err := normalizePointWithdrawalCommand(&command, true, false, true); err != nil { return ledger.PointWithdrawalReceipt{}, err } if command.Reason == "" { command.Reason = "point withdrawal approved" } if s.repository == nil { return ledger.PointWithdrawalReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") } command.AppCode = appcode.Normalize(command.AppCode) ctx = appcode.WithContext(ctx, command.AppCode) receipt, err := s.repository.SettleSalaryWithdrawal(ctx, pointWithdrawalAsSalaryCommand(command)) if err != nil { return ledger.PointWithdrawalReceipt{}, err } return pointWithdrawalReceiptFromSalary(receipt), nil } // ReleasePointWithdrawal 在审核拒绝或申请落库失败时把 frozen 积分返还 available,保证用户不会被永久冻结。 func (s *Service) ReleasePointWithdrawal(ctx context.Context, command ledger.PointWithdrawalCommand) (ledger.PointWithdrawalReceipt, error) { if err := normalizePointWithdrawalCommand(&command, false, false, true); err != nil { return ledger.PointWithdrawalReceipt{}, err } if command.Reason == "" { command.Reason = "point withdrawal released" } if s.repository == nil { return ledger.PointWithdrawalReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") } command.AppCode = appcode.Normalize(command.AppCode) ctx = appcode.WithContext(ctx, command.AppCode) receipt, err := s.repository.ReleaseSalaryWithdrawal(ctx, pointWithdrawalAsSalaryCommand(command)) if err != nil { return ledger.PointWithdrawalReceipt{}, err } return pointWithdrawalReceiptFromSalary(receipt), nil } func normalizePointWithdrawalCommand(command *ledger.PointWithdrawalCommand, requireOperator bool, requireRegion bool, validateFrozenSnapshot bool) error { if command == nil || strings.TrimSpace(command.CommandID) == "" || command.UserID <= 0 { return xerr.New(xerr.InvalidArgument, "point withdrawal command is incomplete") } if requireOperator && command.OperatorUserID <= 0 { return xerr.New(xerr.InvalidArgument, "operator_user_id is required") } if requireRegion && command.RegionID <= 0 { return xerr.New(xerr.InvalidArgument, "region_id is required") } command.CommandID = strings.TrimSpace(command.CommandID) if strings.TrimSpace(command.AppCode) == "" { return xerr.New(xerr.InvalidArgument, "app_code is required") } command.AppCode = appcode.Normalize(command.AppCode) command.AssetType = strings.ToUpper(strings.TrimSpace(command.AssetType)) if !ledger.ValidPointWithdrawalAssetType(command.AssetType) { return xerr.New(xerr.InvalidArgument, "point asset_type is invalid") } if command.GrossPointAmount <= 0 && !(command.AssetType == ledger.AssetPointDiamond && !validateFrozenSnapshot && command.GrossUSDMinor > 0) { return xerr.New(xerr.InvalidArgument, "point withdrawal amount is invalid") } // 首次冻结的比例、门槛、手续费和净额由 repository 在同一数据库事务内覆盖; // 审核与回滚则必须严格复用申请落库时的首次冻结快照。 if !validateFrozenSnapshot { command.Reason = strings.TrimSpace(command.Reason) command.WithdrawalRef = strings.TrimSpace(command.WithdrawalRef) command.WithdrawalApplicationID = strings.TrimSpace(command.WithdrawalApplicationID) command.FreezeTransactionID = strings.TrimSpace(command.FreezeTransactionID) if len(command.CommandID) > 128 || len(command.Reason) > 512 || len(command.WithdrawalRef) > 128 || len(command.WithdrawalApplicationID) > 64 { return xerr.New(xerr.InvalidArgument, "point withdrawal text fields are too long") } return nil } if command.PointsPerUSD <= 0 { return xerr.New(xerr.InvalidArgument, "point withdrawal points_per_usd is invalid") } if command.FeeBPS < 0 || command.FeeBPS > 10000 { return xerr.New(xerr.InvalidArgument, "point withdrawal fee_bps is invalid") } expectedFee := command.GrossPointAmount * command.FeeBPS / 10000 if command.AssetType == ledger.AssetPointDiamond { // 永久积分冻结按美元分先 floor 手续费;审核/回滚只允许复用首次冻结保存的 fee/net 快照。 expectedFee = command.FeePointAmount } if command.FeePointAmount == 0 { command.FeePointAmount = expectedFee } if command.FeePointAmount != expectedFee { return xerr.New(xerr.InvalidArgument, "point withdrawal fee is invalid") } expectedNet := command.GrossPointAmount - command.FeePointAmount if command.NetPointAmount == 0 { command.NetPointAmount = expectedNet } if command.NetPointAmount != expectedNet || command.NetPointAmount < 0 { return xerr.New(xerr.InvalidArgument, "point withdrawal net amount is invalid") } command.Reason = strings.TrimSpace(command.Reason) command.WithdrawalRef = strings.TrimSpace(command.WithdrawalRef) command.WithdrawalApplicationID = strings.TrimSpace(command.WithdrawalApplicationID) command.FreezeTransactionID = strings.TrimSpace(command.FreezeTransactionID) if command.AssetType == ledger.AssetPointDiamond && command.FreezeTransactionID == "" { return xerr.New(xerr.InvalidArgument, "freeze_transaction_id is required for point diamond terminal mutation") } if len(command.CommandID) > 128 || len(command.Reason) > 512 || len(command.WithdrawalRef) > 128 || len(command.WithdrawalApplicationID) > 64 || len(command.FreezeTransactionID) > 128 { return xerr.New(xerr.InvalidArgument, "point withdrawal text fields are too long") } return nil } func pointWithdrawalAsSalaryCommand(command ledger.PointWithdrawalCommand) ledger.SalaryWithdrawalCommand { return ledger.SalaryWithdrawalCommand{ AppCode: command.AppCode, CommandID: command.CommandID, UserID: command.UserID, SalaryAssetType: command.AssetType, SalaryUSDMinor: command.GrossPointAmount, GrossUSDMinor: command.GrossUSDMinor, PointFeeAmount: command.FeePointAmount, PointNetAmount: command.NetPointAmount, PointsPerUSD: command.PointsPerUSD, PointWithdrawFeeBPS: command.FeeBPS, OperatorUserID: command.OperatorUserID, Reason: command.Reason, WithdrawalRef: command.WithdrawalRef, WithdrawalApplicationID: command.WithdrawalApplicationID, FreezeTransactionID: command.FreezeTransactionID, RegionID: command.RegionID, } } func pointWithdrawalReceiptFromSalary(receipt ledger.SalaryWithdrawalReceipt) ledger.PointWithdrawalReceipt { return ledger.PointWithdrawalReceipt{ TransactionID: receipt.TransactionID, UserID: receipt.UserID, AssetType: receipt.SalaryAssetType, GrossPointAmount: receipt.SalaryUSDMinor, FeePointAmount: receipt.PointFeeAmount, NetPointAmount: receipt.PointNetAmount, PointsPerUSD: receipt.PointsPerUSD, FeeBPS: receipt.PointWithdrawFeeBPS, PointPolicyInstanceCode: receipt.PointPolicyInstanceCode, GrossUSDMinor: receipt.GrossUSDMinor, FeeUSDMinor: receipt.FeeUSDMinor, NetUSDMinor: receipt.NetUSDMinor, PointPolicyID: receipt.PointPolicyID, PointPolicyVersion: receipt.PointPolicyVersion, CoinsPerUSD: receipt.CoinsPerUSD, AvailableAfter: receipt.AvailableAfter, FrozenAfter: receipt.FrozenAfter, Version: receipt.Version, CreatedAtMS: receipt.CreatedAtMS, } }