2026-07-22 13:55:55 +08:00

188 lines
8.7 KiB
Go
Raw 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 wallet
import (
"context"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
)
// FreezePointWithdrawal 把 POINT 从 available 冻结到 frozenrepository 会在账变事务内
// 重新解析已发布 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,
}
}