2026-07-02 10:53:44 +08:00

624 lines
20 KiB
Go
Raw Permalink 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 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 = &notified
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_amountrecharge_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
}