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 }