624 lines
20 KiB
Go
624 lines
20 KiB
Go
package financeapplication
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"math"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"hyapp-admin-server/internal/appctx"
|
||
"hyapp-admin-server/internal/integration/userclient"
|
||
"hyapp-admin-server/internal/integration/walletclient"
|
||
"hyapp-admin-server/internal/model"
|
||
"hyapp-admin-server/internal/modules/shared"
|
||
"hyapp-admin-server/internal/repository"
|
||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||
|
||
"google.golang.org/grpc/codes"
|
||
"google.golang.org/grpc/status"
|
||
)
|
||
|
||
const (
|
||
permissionCreateApplication = "finance-application:create"
|
||
permissionAuditApplication = "finance-application:audit"
|
||
|
||
financeInternalUserIDMinDigits = 15
|
||
|
||
financeAssetCoin = "COIN"
|
||
financeAssetCoinSellerCoin = "COIN_SELLER_COIN"
|
||
financeAssetHostSalaryUSD = "HOST_SALARY_USD"
|
||
financeAssetAgencySalaryUSD = "AGENCY_SALARY_USD"
|
||
financeAssetBDSalaryUSD = "BD_SALARY_USD"
|
||
|
||
financeStockTypeUSDTPurchase = "usdt_purchase"
|
||
financeStockTypeUSDTDeduction = "usdt_deduction"
|
||
)
|
||
|
||
var (
|
||
errInvalidCreateInput = errors.New("财务申请参数不正确")
|
||
errInvalidAuditInput = errors.New("审核参数不正确")
|
||
)
|
||
|
||
type operationDefinition struct {
|
||
Value string
|
||
PermissionCode string
|
||
RequiresWalletIdentity bool
|
||
AllowsZeroRechargeAmount bool
|
||
}
|
||
|
||
type Service struct {
|
||
store *repository.Store
|
||
wallet walletclient.Client
|
||
user userclient.Client
|
||
now func() time.Time
|
||
notifier ApplicationNotifier
|
||
}
|
||
|
||
type Option func(*Service)
|
||
|
||
type ApplicationNotifier interface {
|
||
NotifyApplicationCreated(ctx context.Context, application applicationDTO) error
|
||
}
|
||
|
||
func WithWalletClient(wallet walletclient.Client) Option {
|
||
return func(service *Service) {
|
||
service.wallet = wallet
|
||
}
|
||
}
|
||
|
||
func WithUserClient(user userclient.Client) Option {
|
||
return func(service *Service) {
|
||
service.user = user
|
||
}
|
||
}
|
||
|
||
func WithApplicationNotifier(notifier ApplicationNotifier) Option {
|
||
return func(service *Service) {
|
||
service.notifier = notifier
|
||
}
|
||
}
|
||
|
||
func NewService(store *repository.Store, options ...Option) *Service {
|
||
service := &Service{store: store, now: func() time.Time { return time.Now().UTC() }}
|
||
for _, option := range options {
|
||
if option != nil {
|
||
option(service)
|
||
}
|
||
}
|
||
return service
|
||
}
|
||
|
||
func (s *Service) CreateApplication(ctx context.Context, actor shared.Actor, req createApplicationRequest) (*applicationDTO, error) {
|
||
if s == nil || s.store == nil {
|
||
return nil, errors.New("admin store is not configured")
|
||
}
|
||
input, err := s.normalizeCreateInput(actor, req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.store.CreateFinanceApplication(&input); err != nil {
|
||
return nil, err
|
||
}
|
||
item, err := s.store.GetFinanceApplication(input.ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
dto := applicationDTOFromModel(*item)
|
||
notified := false
|
||
if s.notifier != nil {
|
||
// 申请落库后再通知财务群;通知失败只影响提醒,不回滚申请,避免外部机器人抖动造成业务申请丢失。
|
||
if err := s.notifier.NotifyApplicationCreated(ctx, dto); err != nil {
|
||
slog.Warn("finance_application_dingtalk_notify_failed", "application_id", dto.ID, "app_code", dto.AppCode, "target_user_id", dto.TargetUserID, "error", err)
|
||
} else {
|
||
notified = true
|
||
}
|
||
}
|
||
dto.DingTalkNotified = ¬ified
|
||
return &dto, nil
|
||
}
|
||
|
||
func (s *Service) ListApplications(options repository.FinanceApplicationListOptions) ([]applicationDTO, int64, error) {
|
||
if s == nil || s.store == nil {
|
||
return nil, 0, errors.New("admin store is not configured")
|
||
}
|
||
items, total, err := s.store.ListFinanceApplications(options)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
return applicationDTOsFromModel(items), total, nil
|
||
}
|
||
|
||
func (s *Service) ListMyApplications(actor shared.Actor, options repository.FinanceApplicationListOptions) ([]applicationDTO, int64, error) {
|
||
if s == nil || s.store == nil {
|
||
return nil, 0, errors.New("admin store is not configured")
|
||
}
|
||
if actor.UserID == 0 || !hasPermission(actor.Permissions, permissionCreateApplication) {
|
||
return nil, 0, errors.New("没有操作权限")
|
||
}
|
||
// 申请人范围必须在服务端写入查询条件;前端只传列表筛选项,不能决定 applicant_user_id。
|
||
options.ApplicantUserID = actor.UserID
|
||
items, total, err := s.store.ListFinanceApplications(options)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
return applicationDTOsFromModel(items), total, nil
|
||
}
|
||
|
||
func (s *Service) ApproveApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*applicationDTO, error) {
|
||
return s.auditApplication(ctx, actor, id, model.FinanceApplicationStatusApproved, remark, requestID)
|
||
}
|
||
|
||
func (s *Service) RejectApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*applicationDTO, error) {
|
||
return s.auditApplication(ctx, actor, id, model.FinanceApplicationStatusRejected, remark, requestID)
|
||
}
|
||
|
||
func (s *Service) auditApplication(ctx context.Context, actor shared.Actor, id uint, decision string, remark string, requestID string) (*applicationDTO, error) {
|
||
if s == nil || s.store == nil {
|
||
return nil, errors.New("admin store is not configured")
|
||
}
|
||
if id == 0 || !hasPermission(actor.Permissions, permissionAuditApplication) {
|
||
return nil, errInvalidAuditInput
|
||
}
|
||
execution := walletExecutionResult{}
|
||
if decision == model.FinanceApplicationStatusApproved {
|
||
application, err := s.store.GetFinanceApplication(id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if application.Status != model.FinanceApplicationStatusPending {
|
||
return nil, repository.ErrFinanceApplicationAlreadyAudited
|
||
}
|
||
execution, err = s.executeApprovedApplication(ctx, actor, *application, requestID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
item, err := s.store.AuditFinanceApplication(id, repository.FinanceApplicationAuditInput{
|
||
Decision: decision,
|
||
AuditorUserID: actor.UserID,
|
||
AuditorName: actor.Username,
|
||
AuditRemark: strings.TrimSpace(remark),
|
||
AuditedAtMS: s.now().UnixMilli(),
|
||
WalletCommandID: execution.CommandID,
|
||
WalletTransactionID: execution.TransactionID,
|
||
WalletAssetType: execution.AssetType,
|
||
WalletAmountDelta: execution.AmountDelta,
|
||
WalletBalanceAfter: execution.BalanceAfter,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
dto := applicationDTOFromModel(*item)
|
||
return &dto, nil
|
||
}
|
||
|
||
type walletExecutionResult struct {
|
||
CommandID string
|
||
TransactionID string
|
||
AssetType string
|
||
AmountDelta int64
|
||
BalanceAfter int64
|
||
}
|
||
|
||
func (s *Service) executeApprovedApplication(ctx context.Context, actor shared.Actor, application model.FinanceApplication, requestID string) (walletExecutionResult, error) {
|
||
if s.wallet == nil || s.user == nil {
|
||
return walletExecutionResult{}, errors.New("finance wallet executor is not configured")
|
||
}
|
||
if actor.UserID == 0 {
|
||
return walletExecutionResult{}, errors.New("没有操作权限")
|
||
}
|
||
appCode := appctx.Normalize(application.AppCode)
|
||
ctx = appctx.WithContext(ctx, appCode)
|
||
target, err := s.resolveTargetUser(ctx, strings.TrimSpace(requestID), application.TargetUserID)
|
||
if err != nil {
|
||
return walletExecutionResult{}, err
|
||
}
|
||
commandID := financeWalletCommandID(application.ID, application.Operation)
|
||
reason := financeWalletReason(application)
|
||
evidenceRef := financeWalletEvidenceRef(application.ID)
|
||
switch application.Operation {
|
||
case "user_coin_credit", "user_coin_debit":
|
||
// 普通用户金币只落 COIN 资产;扣减用带符号金额交给 wallet-service 做余额不透支校验。
|
||
amount := signedAmount(application.CoinAmount, application.Operation == "user_coin_debit")
|
||
return s.adminAdjustAsset(ctx, commandID, appCode, target.UserID, financeAssetCoin, amount, int64(actor.UserID), reason, evidenceRef)
|
||
case "user_wallet_credit", "user_wallet_debit":
|
||
assetType, err := financeSalaryAssetType(application.WalletIdentity)
|
||
if err != nil {
|
||
return walletExecutionResult{}, err
|
||
}
|
||
if err := s.requireWalletIdentity(ctx, strings.TrimSpace(requestID), target.UserID, application.WalletIdentity); err != nil {
|
||
return walletExecutionResult{}, err
|
||
}
|
||
amountMinor, err := decimalAmountMinor(application.RechargeAmount)
|
||
if err != nil {
|
||
return walletExecutionResult{}, err
|
||
}
|
||
// 身份钱包是美元工资资产,单位使用 wallet-service 现有的 minor 口径;金币数量不参与这类调账。
|
||
amount := signedAmount(amountMinor, application.Operation == "user_wallet_debit")
|
||
return s.adminAdjustAsset(ctx, commandID, appCode, target.UserID, assetType, amount, int64(actor.UserID), reason, evidenceRef)
|
||
case "coin_seller_coin_credit", "coin_seller_coin_debit":
|
||
if err := s.requireActiveCoinSeller(ctx, strings.TrimSpace(requestID), target); err != nil {
|
||
return walletExecutionResult{}, err
|
||
}
|
||
paidAmountMicro, err := decimalAmountMicro(application.RechargeAmount)
|
||
if err != nil {
|
||
return walletExecutionResult{}, err
|
||
}
|
||
stockType := financeStockType(application.Operation)
|
||
resp, err := s.wallet.AdminCreditCoinSellerStock(ctx, &walletv1.AdminCreditCoinSellerStockRequest{
|
||
CommandId: commandID,
|
||
SellerUserId: target.UserID,
|
||
StockType: stockType,
|
||
CoinAmount: application.CoinAmount,
|
||
PaidCurrencyCode: "USDT",
|
||
PaidAmountMicro: paidAmountMicro,
|
||
PaymentRef: evidenceRef,
|
||
EvidenceRef: evidenceRef,
|
||
OperatorUserId: int64(actor.UserID),
|
||
Reason: reason,
|
||
AppCode: appCode,
|
||
SellerCountryId: target.CountryID,
|
||
SellerRegionId: target.RegionID,
|
||
})
|
||
if err != nil {
|
||
return walletExecutionResult{}, err
|
||
}
|
||
return walletExecutionResult{
|
||
CommandID: commandID,
|
||
TransactionID: resp.GetTransactionId(),
|
||
AssetType: financeAssetCoinSellerCoin,
|
||
AmountDelta: resp.GetCoinAmount(),
|
||
BalanceAfter: resp.GetBalanceAfter(),
|
||
}, nil
|
||
default:
|
||
return walletExecutionResult{}, errors.New("财务操作不正确")
|
||
}
|
||
}
|
||
|
||
func (s *Service) adminAdjustAsset(ctx context.Context, commandID string, appCode string, targetUserID int64, assetType string, amount int64, operatorUserID int64, reason string, evidenceRef string) (walletExecutionResult, error) {
|
||
resp, err := s.wallet.AdminCreditAsset(ctx, &walletv1.AdminCreditAssetRequest{
|
||
CommandId: commandID,
|
||
TargetUserId: targetUserID,
|
||
AssetType: assetType,
|
||
Amount: amount,
|
||
OperatorUserId: operatorUserID,
|
||
Reason: reason,
|
||
EvidenceRef: evidenceRef,
|
||
AppCode: appCode,
|
||
})
|
||
if err != nil {
|
||
return walletExecutionResult{}, err
|
||
}
|
||
balanceAfter := int64(0)
|
||
if balance := resp.GetBalance(); balance != nil {
|
||
balanceAfter = balance.GetAvailableAmount()
|
||
}
|
||
return walletExecutionResult{
|
||
CommandID: commandID,
|
||
TransactionID: resp.GetTransactionId(),
|
||
AssetType: assetType,
|
||
AmountDelta: amount,
|
||
BalanceAfter: balanceAfter,
|
||
}, nil
|
||
}
|
||
|
||
func (s *Service) resolveTargetUser(ctx context.Context, requestID string, rawTarget string) (*userclient.User, error) {
|
||
keyword := strings.TrimSpace(rawTarget)
|
||
if keyword == "" {
|
||
return nil, errors.New("目标用户ID不正确")
|
||
}
|
||
numericID, numericOK := parsePositiveInt64(keyword)
|
||
userIDChecked := false
|
||
if numericOK && len(strings.TrimLeft(keyword, "0")) >= financeInternalUserIDMinDigits {
|
||
userIDChecked = true
|
||
if user, found, err := s.getUserByID(ctx, requestID, numericID); err != nil || found {
|
||
return user, err
|
||
}
|
||
}
|
||
if identity, err := s.user.ResolveDisplayUserID(ctx, userclient.ResolveDisplayUserIDRequest{
|
||
RequestID: requestID,
|
||
Caller: "admin-server",
|
||
DisplayUserID: keyword,
|
||
}); err != nil {
|
||
if !isGRPCNotFound(err) {
|
||
return nil, err
|
||
}
|
||
} else if identity != nil && identity.UserID > 0 {
|
||
user, found, err := s.getUserByID(ctx, requestID, identity.UserID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if found {
|
||
return user, nil
|
||
}
|
||
}
|
||
if numericOK && !userIDChecked {
|
||
if user, found, err := s.getUserByID(ctx, requestID, numericID); err != nil || found {
|
||
return user, err
|
||
}
|
||
}
|
||
return nil, errors.New("目标用户不存在")
|
||
}
|
||
|
||
func (s *Service) getUserByID(ctx context.Context, requestID string, userID int64) (*userclient.User, bool, error) {
|
||
user, err := s.user.GetUser(ctx, userclient.GetUserRequest{
|
||
RequestID: requestID,
|
||
Caller: "admin-server",
|
||
UserID: userID,
|
||
})
|
||
if err != nil {
|
||
if isGRPCNotFound(err) {
|
||
return nil, false, nil
|
||
}
|
||
return nil, false, err
|
||
}
|
||
if user == nil || user.UserID <= 0 {
|
||
return nil, false, nil
|
||
}
|
||
return user, true, nil
|
||
}
|
||
|
||
func (s *Service) requireWalletIdentity(ctx context.Context, requestID string, userID int64, walletIdentity string) error {
|
||
summary, err := s.user.GetUserRoleSummary(ctx, userclient.GetUserRoleSummaryRequest{
|
||
RequestID: requestID,
|
||
Caller: "admin-server",
|
||
UserID: userID,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
switch strings.TrimSpace(walletIdentity) {
|
||
case "host":
|
||
if summary != nil && summary.IsHost {
|
||
return nil
|
||
}
|
||
case "agency":
|
||
if summary != nil && summary.IsAgency {
|
||
return nil
|
||
}
|
||
case "bd":
|
||
if summary != nil && summary.IsBD {
|
||
return nil
|
||
}
|
||
}
|
||
return errors.New("目标用户没有对应钱包身份")
|
||
}
|
||
|
||
func (s *Service) requireActiveCoinSeller(ctx context.Context, requestID string, user *userclient.User) error {
|
||
if user == nil || user.UserID <= 0 {
|
||
return errors.New("目标用户不存在")
|
||
}
|
||
if user.CountryID <= 0 || user.RegionID <= 0 {
|
||
return errors.New("币商国家或区域不完整")
|
||
}
|
||
summary, err := s.user.GetUserRoleSummary(ctx, userclient.GetUserRoleSummaryRequest{
|
||
RequestID: requestID,
|
||
Caller: "admin-server",
|
||
UserID: user.UserID,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if summary == nil || !summary.IsCoinSeller {
|
||
return errors.New("目标用户不是启用中的币商")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) normalizeCreateInput(actor shared.Actor, req createApplicationRequest) (model.FinanceApplication, error) {
|
||
appCode := strings.TrimSpace(req.AppCode)
|
||
operation := strings.TrimSpace(req.Operation)
|
||
targetUserID := strings.TrimSpace(req.TargetUserID)
|
||
credentialImageURL := strings.TrimSpace(req.CredentialImageURL)
|
||
credentialText := strings.TrimSpace(req.CredentialText)
|
||
definition, ok := financeOperationDefinitions()[operation]
|
||
if !ok || appCode == "" || targetUserID == "" || req.CoinAmount <= 0 || actor.UserID == 0 {
|
||
return model.FinanceApplication{}, errInvalidCreateInput
|
||
}
|
||
if !hasPermission(actor.Permissions, permissionCreateApplication) || !hasPermission(actor.Permissions, definition.PermissionCode) {
|
||
return model.FinanceApplication{}, errors.New("没有操作权限")
|
||
}
|
||
rechargeAmount, err := normalizeRechargeAmount(req.RechargeAmount, definition.AllowsZeroRechargeAmount)
|
||
if err != nil {
|
||
return model.FinanceApplication{}, err
|
||
}
|
||
walletIdentity := strings.TrimSpace(req.WalletIdentity)
|
||
if definition.RequiresWalletIdentity {
|
||
if !validWalletIdentity(walletIdentity) {
|
||
return model.FinanceApplication{}, errors.New("钱包身份不正确")
|
||
}
|
||
} else {
|
||
// 非钱包类申请不保存身份字段,避免后续审核或执行阶段把历史残留身份误当成业务参数。
|
||
walletIdentity = ""
|
||
}
|
||
if credentialImageURL == "" && credentialText == "" {
|
||
return model.FinanceApplication{}, errors.New("请上传凭证图片或填写凭证文字")
|
||
}
|
||
return model.FinanceApplication{
|
||
AppCode: appCode,
|
||
Operation: operation,
|
||
WalletIdentity: walletIdentity,
|
||
TargetUserID: targetUserID,
|
||
CoinAmount: req.CoinAmount,
|
||
RechargeAmount: rechargeAmount,
|
||
CredentialImageURL: credentialImageURL,
|
||
CredentialText: credentialText,
|
||
ApplicantUserID: actor.UserID,
|
||
ApplicantName: actor.Username,
|
||
Status: model.FinanceApplicationStatusPending,
|
||
}, nil
|
||
}
|
||
|
||
func financeOperationDefinitions() map[string]operationDefinition {
|
||
return map[string]operationDefinition{
|
||
"user_coin_credit": {
|
||
Value: "user_coin_credit",
|
||
PermissionCode: "finance-operation:user-coin-credit",
|
||
AllowsZeroRechargeAmount: true,
|
||
},
|
||
"user_coin_debit": {
|
||
Value: "user_coin_debit",
|
||
PermissionCode: "finance-operation:user-coin-debit",
|
||
AllowsZeroRechargeAmount: true,
|
||
},
|
||
"user_wallet_debit": {
|
||
Value: "user_wallet_debit",
|
||
PermissionCode: "finance-operation:user-wallet-debit",
|
||
RequiresWalletIdentity: true,
|
||
},
|
||
"user_wallet_credit": {
|
||
Value: "user_wallet_credit",
|
||
PermissionCode: "finance-operation:user-wallet-credit",
|
||
RequiresWalletIdentity: true,
|
||
},
|
||
"coin_seller_coin_credit": {
|
||
Value: "coin_seller_coin_credit",
|
||
PermissionCode: "finance-operation:coin-seller-coin-credit",
|
||
},
|
||
"coin_seller_coin_debit": {
|
||
Value: "coin_seller_coin_debit",
|
||
PermissionCode: "finance-operation:coin-seller-coin-debit",
|
||
},
|
||
}
|
||
}
|
||
|
||
func normalizeRechargeAmount(amount float64, allowZero bool) (string, error) {
|
||
if math.IsNaN(amount) || math.IsInf(amount, 0) || amount < 0 {
|
||
return "", errors.New("充值金额不正确")
|
||
}
|
||
cents := int64(math.Round(amount * 100))
|
||
// 普通用户金币调账的执行金额来自 coin_amount,recharge_amount 只是凭证口径,允许记录 0;身份钱包和币商进货会把它转换成真实 USD/USDT 金额,必须保持正数。
|
||
if cents < 0 || (!allowZero && cents == 0) {
|
||
return "", errors.New("充值金额不正确")
|
||
}
|
||
return fmt.Sprintf("%d.%02d", cents/100, cents%100), nil
|
||
}
|
||
|
||
func validWalletIdentity(value string) bool {
|
||
switch value {
|
||
case "host", "agency", "bd":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func financeSalaryAssetType(identity string) (string, error) {
|
||
switch strings.TrimSpace(identity) {
|
||
case "host":
|
||
return financeAssetHostSalaryUSD, nil
|
||
case "agency":
|
||
return financeAssetAgencySalaryUSD, nil
|
||
case "bd":
|
||
return financeAssetBDSalaryUSD, nil
|
||
default:
|
||
return "", errors.New("钱包身份不正确")
|
||
}
|
||
}
|
||
|
||
func financeStockType(operation string) string {
|
||
if operation == "coin_seller_coin_debit" {
|
||
return financeStockTypeUSDTDeduction
|
||
}
|
||
return financeStockTypeUSDTPurchase
|
||
}
|
||
|
||
func signedAmount(amount int64, debit bool) int64 {
|
||
if debit {
|
||
return -amount
|
||
}
|
||
return amount
|
||
}
|
||
|
||
func decimalAmountMinor(value string) (int64, error) {
|
||
return decimalAmountWithScale(value, 2)
|
||
}
|
||
|
||
func decimalAmountMicro(value string) (int64, error) {
|
||
return decimalAmountWithScale(value, 6)
|
||
}
|
||
|
||
func decimalAmountWithScale(value string, scale int) (int64, error) {
|
||
text := strings.TrimSpace(value)
|
||
if text == "" {
|
||
return 0, errors.New("充值金额不正确")
|
||
}
|
||
parts := strings.Split(text, ".")
|
||
if len(parts) > 2 {
|
||
return 0, errors.New("充值金额不正确")
|
||
}
|
||
whole, err := strconv.ParseInt(parts[0], 10, 64)
|
||
if err != nil || whole < 0 {
|
||
return 0, errors.New("充值金额不正确")
|
||
}
|
||
fracText := ""
|
||
if len(parts) == 2 {
|
||
fracText = parts[1]
|
||
}
|
||
if len(fracText) > scale {
|
||
return 0, errors.New("充值金额不正确")
|
||
}
|
||
for _, r := range fracText {
|
||
if r < '0' || r > '9' {
|
||
return 0, errors.New("充值金额不正确")
|
||
}
|
||
}
|
||
for len(fracText) < scale {
|
||
fracText += "0"
|
||
}
|
||
frac := int64(0)
|
||
if fracText != "" {
|
||
frac, err = strconv.ParseInt(fracText, 10, 64)
|
||
if err != nil {
|
||
return 0, errors.New("充值金额不正确")
|
||
}
|
||
}
|
||
multiplier := int64(1)
|
||
for i := 0; i < scale; i++ {
|
||
multiplier *= 10
|
||
}
|
||
const maxInt64 = 1<<63 - 1
|
||
if whole > (maxInt64-frac)/multiplier {
|
||
return 0, errors.New("充值金额不正确")
|
||
}
|
||
amount := whole*multiplier + frac
|
||
if amount <= 0 {
|
||
return 0, errors.New("充值金额不正确")
|
||
}
|
||
return amount, nil
|
||
}
|
||
|
||
func parsePositiveInt64(value string) (int64, bool) {
|
||
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||
return parsed, err == nil && parsed > 0
|
||
}
|
||
|
||
func financeWalletCommandID(id uint, operation string) string {
|
||
return fmt.Sprintf("finance-application:%d:%s", id, strings.TrimSpace(operation))
|
||
}
|
||
|
||
func financeWalletEvidenceRef(id uint) string {
|
||
return fmt.Sprintf("finance-application:%d", id)
|
||
}
|
||
|
||
func financeWalletReason(application model.FinanceApplication) string {
|
||
return fmt.Sprintf("finance application %d %s", application.ID, operationLabel(application.Operation))
|
||
}
|
||
|
||
func isGRPCNotFound(err error) bool {
|
||
return status.Code(err) == codes.NotFound
|
||
}
|
||
|
||
func hasPermission(permissions []string, code string) bool {
|
||
for _, permission := range permissions {
|
||
if permission == code {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|