2026-07-16 18:51:58 +08:00

479 lines
20 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 financewithdrawal
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
activityv1 "hyapp.local/api/proto/activity/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
const (
permissionViewWithdrawalApplications = "finance-withdrawal:view"
permissionAuditWithdrawalApplications = "finance-withdrawal:audit"
permissionViewOperationsWithdrawals = "operations-withdrawal:view"
permissionAuditOperationsWithdrawals = "operations-withdrawal:audit"
pointWithdrawalDefaultPointsPerUSD = int64(100000)
pointWithdrawalDefaultFeeBPS = int32(500)
)
var errWithdrawalMoneyScopeForbidden = errors.New("没有该 App 的财务范围")
type Service struct {
store *repository.Store
wallet walletclient.Client
activity activityclient.Client
now func() time.Time
}
type pointWithdrawalWallet interface {
SettlePointWithdrawal(ctx context.Context, req *walletv1.SettlePointWithdrawalRequest) (*walletv1.SettlePointWithdrawalResponse, error)
ReleasePointWithdrawal(ctx context.Context, req *walletv1.ReleasePointWithdrawalRequest) (*walletv1.ReleasePointWithdrawalResponse, error)
}
func NewService(store *repository.Store, wallet walletclient.Client, activity activityclient.Client) *Service {
return &Service{store: store, wallet: wallet, activity: activity, now: func() time.Time { return time.Now().UTC() }}
}
func (s *Service) ListApplications(actor shared.Actor, options repository.WithdrawalApplicationListOptions) ([]withdrawalApplicationDTO, int64, error) {
if s == nil || s.store == nil {
return nil, 0, errors.New("admin store is not configured")
}
var err error
options, err = s.scopeWithdrawalList(actor, options)
if err != nil {
return nil, 0, err
}
options.Stage = repository.WithdrawalApplicationReviewStageFinance
items, total, err := s.store.ListWithdrawalApplications(options)
if err != nil {
return nil, 0, err
}
return withdrawalApplicationDTOsFromModel(items), total, nil
}
func (s *Service) ListOperationsApplications(actor shared.Actor, options repository.WithdrawalApplicationListOptions) ([]withdrawalApplicationDTO, int64, error) {
if s == nil || s.store == nil {
return nil, 0, errors.New("admin store is not configured")
}
var err error
options, err = s.scopeWithdrawalList(actor, options)
if err != nil {
return nil, 0, err
}
options.Stage = repository.WithdrawalApplicationReviewStageOperations
items, total, err := s.store.ListWithdrawalApplications(options)
if err != nil {
return nil, 0, err
}
return withdrawalApplicationDTOsFromModel(items), total, nil
}
func (s *Service) scopeWithdrawalList(actor shared.Actor, options repository.WithdrawalApplicationListOptions) (repository.WithdrawalApplicationListOptions, error) {
access, err := s.store.MoneyAccessForUser(actor.UserID)
if err != nil {
return options, err
}
if requestedApp := strings.TrimSpace(options.AppCode); requestedApp != "" {
options.AppCode = appctx.Normalize(requestedApp)
if !access.AllowsApp(options.AppCode) {
return options, errWithdrawalMoneyScopeForbidden
}
return options, nil
}
options.AppCode = ""
if !access.All {
// 空 app 查询不代表全局;非全量财务用户只能在 admin_user_money_scopes 授权的 App 集合中分页。
options.RestrictAppCodes = true
options.AllowedAppCodes = access.AppCodes()
}
return options, nil
}
func (s *Service) ApproveApplication(ctx context.Context, actor shared.Actor, id uint, remark string, auditImageURL string, requestID string) (*withdrawalApplicationDTO, error) {
return s.auditApplication(ctx, actor, id, repository.WithdrawalApplicationReviewStageFinance, model.WithdrawalApplicationStatusApproved, remark, auditImageURL, requestID)
}
func (s *Service) RejectApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*withdrawalApplicationDTO, error) {
return s.auditApplication(ctx, actor, id, repository.WithdrawalApplicationReviewStageFinance, model.WithdrawalApplicationStatusRejected, remark, "", requestID)
}
func (s *Service) ApproveOperationsApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*withdrawalApplicationDTO, error) {
return s.auditApplication(ctx, actor, id, repository.WithdrawalApplicationReviewStageOperations, model.WithdrawalApplicationStatusApproved, remark, "", requestID)
}
func (s *Service) RejectOperationsApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*withdrawalApplicationDTO, error) {
return s.auditApplication(ctx, actor, id, repository.WithdrawalApplicationReviewStageOperations, model.WithdrawalApplicationStatusRejected, remark, "", requestID)
}
func (s *Service) auditApplication(ctx context.Context, actor shared.Actor, id uint, stage repository.WithdrawalApplicationReviewStage, decision string, remark string, auditImageURL string, requestID string) (*withdrawalApplicationDTO, error) {
if s == nil || s.store == nil || s.wallet == nil || s.activity == nil {
return nil, errors.New("admin finance withdrawal service is not configured")
}
if id == 0 || actor.UserID == 0 || !canAuditWithdrawalStage(stage, actor.Permissions) {
return nil, errors.New("没有操作权限")
}
remark = strings.TrimSpace(remark)
auditImageURL = strings.TrimSpace(auditImageURL)
if stage == repository.WithdrawalApplicationReviewStageFinance && decision == model.WithdrawalApplicationStatusApproved && auditImageURL == "" {
return nil, errors.New("提现通过凭证图片不能为空")
}
if decision == model.WithdrawalApplicationStatusRejected && remark == "" {
return nil, errors.New("拒绝原因不能为空")
}
appCode := appctx.FromContext(ctx)
access, err := s.store.MoneyAccessForUser(actor.UserID)
if err != nil {
return nil, err
}
if !access.AllowsApp(appCode) {
// 审核以目标行的 App header 为数据域;即使 JWT 含按钮权限,也不能跨 admin_user_money_scopes 触发钱包动作。
return nil, errWithdrawalMoneyScopeForbidden
}
nowMS := s.now().UnixMilli()
commandID := withdrawalAuditCommandID(id, stage)
if stage == repository.WithdrawalApplicationReviewStageOperations && decision == model.WithdrawalApplicationStatusRejected {
return s.executeOperationsRejection(ctx, actor, id, appCode, remark, requestID, commandID, nowMS)
}
updated, err := s.store.ReviewWithdrawalApplicationForApp(appCode, id, repository.WithdrawalApplicationAuditInput{
Stage: stage,
Decision: decision,
ApproverUserID: actor.UserID,
ApproverName: actor.Username,
AuditRemark: remark,
AuditImageURL: auditImageURL,
ApprovedAtMS: nowMS,
}, func(item model.UserWithdrawalApplication) (repository.WithdrawalApplicationAuditEffect, error) {
userID, parseErr := withdrawalWalletUserID(item)
if parseErr != nil {
return repository.WithdrawalApplicationAuditEffect{}, parseErr
}
if stage == repository.WithdrawalApplicationReviewStageOperations && decision == model.WithdrawalApplicationStatusApproved {
// 运营通过只确认业务资料并转交财务;申请金额继续保留在 frozen且不能提前向用户发送最终通过通知。
return repository.WithdrawalApplicationAuditEffect{}, nil
}
transactionID, walletErr := s.applyWalletDecision(ctx, decision, commandID, appCode, userID, item.SalaryAssetType, item.WithdrawAmountMinor, item.PointFeeAmount, item.PointNetAmount, item.PointsPerUSD, item.PointFeeBPS, actor, strings.TrimSpace(remark), id)
if walletErr != nil {
return repository.WithdrawalApplicationAuditEffect{}, walletErr
}
if noticeErr := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{
ApplicationID: id,
AppCode: appCode,
UserID: userID,
Stage: stage,
Decision: decision,
Remark: remark,
AuditImageURL: auditImageURL,
AuditTransactionID: transactionID,
WithdrawAmount: item.WithdrawAmount,
WithdrawMethod: item.WithdrawMethod,
RequestID: requestID,
SentAtMS: nowMS,
}); noticeErr != nil {
// 钱包扣冻/释放使用阶段固定 command id通知使用阶段+结果事件 id回调失败会回滚 admin 状态,重试不会重复资金动作或消息。
return repository.WithdrawalApplicationAuditEffect{}, noticeErr
}
return repository.WithdrawalApplicationAuditEffect{CommandID: commandID, TransactionID: transactionID}, nil
})
if err != nil {
return nil, err
}
dto := withdrawalApplicationDTOFromModel(*updated)
return &dto, nil
}
// executeOperationsRejection 把拒绝拆成 claim -> 外部幂等动作 -> finalize 三段。
// claim 是独立短事务,因此 wallet 已释放后即使 notice 或最终 admin 提交失败,申请仍停在 rejecting不能被改点通过或进入财务。
func (s *Service) executeOperationsRejection(ctx context.Context, actor shared.Actor, id uint, appCode string, remark string, requestID string, commandID string, nowMS int64) (*withdrawalApplicationDTO, error) {
claimed, err := s.store.ClaimWithdrawalOperationsRejectionForApp(appCode, id, repository.WithdrawalApplicationAuditInput{
Stage: repository.WithdrawalApplicationReviewStageOperations,
Decision: model.WithdrawalApplicationStatusRejected,
ApproverUserID: actor.UserID,
ApproverName: actor.Username,
AuditRemark: remark,
AuditCommandID: commandID,
ApprovedAtMS: nowMS,
}, func(item model.UserWithdrawalApplication) error {
_, err := withdrawalWalletUserID(item)
return err
})
if err != nil {
return nil, err
}
if claimed.Status == model.WithdrawalApplicationStatusRejected && claimed.OperationsStatus == model.WithdrawalOperationsStatusRejected {
// 已完成的重复请求直接返回终态,避免再次调用 wallet/inbox前端重试是安全的。
dto := withdrawalApplicationDTOFromModel(*claimed)
return &dto, nil
}
userID, err := withdrawalWalletUserID(*claimed)
if err != nil {
return nil, err
}
if claimed.OperationsReviewerUserID == nil || *claimed.OperationsReviewerUserID == 0 || strings.TrimSpace(claimed.OperationsAuditRemark) == "" {
return nil, errors.New("提现单运营拒绝 claim 不完整")
}
// 重试沿用第一次 claim 的审核人和拒绝原因,不能让资金 metadata、用户通知和后台审计快照随重试人改变。
claimedActor := shared.Actor{UserID: *claimed.OperationsReviewerUserID, Username: claimed.OperationsReviewerName}
claimedRemark := strings.TrimSpace(claimed.OperationsAuditRemark)
transactionID, err := s.applyWalletDecision(ctx, model.WithdrawalApplicationStatusRejected, commandID, appCode, userID, claimed.SalaryAssetType, claimed.WithdrawAmountMinor, claimed.PointFeeAmount, claimed.PointNetAmount, claimed.PointsPerUSD, claimed.PointFeeBPS, claimedActor, claimedRemark, id)
if err != nil {
return nil, err
}
sentAtMS := nowMS
if claimed.OperationsReviewedAtMS != nil && *claimed.OperationsReviewedAtMS > 0 {
sentAtMS = *claimed.OperationsReviewedAtMS
}
if err := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{
ApplicationID: id,
AppCode: appCode,
UserID: userID,
Stage: repository.WithdrawalApplicationReviewStageOperations,
Decision: model.WithdrawalApplicationStatusRejected,
Remark: claimedRemark,
AuditTransactionID: transactionID,
WithdrawAmount: claimed.WithdrawAmount,
WithdrawMethod: claimed.WithdrawMethod,
RequestID: requestID,
SentAtMS: sentAtMS,
}); err != nil {
// rejecting 已在前一事务提交;返回错误让运营页展示“重试拒绝”,绝不能回退成 pending。
return nil, err
}
updated, err := s.store.FinalizeWithdrawalOperationsRejectionForApp(appCode, id, commandID, transactionID, s.now().UnixMilli())
if err != nil {
return nil, err
}
dto := withdrawalApplicationDTOFromModel(*updated)
return &dto, nil
}
func withdrawalWalletUserID(item model.UserWithdrawalApplication) (int64, error) {
userID, err := strconv.ParseInt(strings.TrimSpace(item.UserID), 10, 64)
if err != nil || userID <= 0 || strings.TrimSpace(item.SalaryAssetType) == "" || item.WithdrawAmountMinor <= 0 || strings.TrimSpace(item.FreezeTransactionID) == "" {
return 0, errors.New("提现单冻结信息不完整")
}
return userID, nil
}
func (s *Service) applyWalletDecision(ctx context.Context, decision string, commandID string, appCode string, userID int64, assetType string, amountMinor int64, storedFeePoints int64, storedNetPoints int64, storedPointsPerUSD int64, storedFeeBPS int32, actor shared.Actor, remark string, applicationID uint) (string, error) {
reason := strings.TrimSpace(remark)
if reason == "" {
reason = "salary withdrawal " + decision
}
applicationIDText := strconv.FormatUint(uint64(applicationID), 10)
if isPointWithdrawalAssetType(assetType) {
pointWallet, ok := s.wallet.(pointWithdrawalWallet)
if !ok {
return "", errors.New("point withdrawal wallet executor is not configured")
}
feePoints, netPoints, pointsPerUSD, feeBPS := pointWithdrawalSnapshot(model.UserWithdrawalApplication{
WithdrawAmountMinor: amountMinor, PointFeeAmount: storedFeePoints, PointNetAmount: storedNetPoints,
PointsPerUSD: storedPointsPerUSD, PointFeeBPS: storedFeeBPS,
})
switch decision {
case model.WithdrawalApplicationStatusApproved:
resp, err := pointWallet.SettlePointWithdrawal(ctx, &walletv1.SettlePointWithdrawalRequest{
CommandId: commandID,
UserId: userID,
AssetType: assetType,
GrossPointAmount: amountMinor,
FeePointAmount: feePoints,
NetPointAmount: netPoints,
PointsPerUsd: pointsPerUSD,
FeeBps: feeBPS,
OperatorUserId: int64(actor.UserID),
Reason: reason,
AppCode: appCode,
WithdrawalApplicationId: applicationIDText,
})
if err != nil {
return "", err
}
return resp.GetTransactionId(), nil
case model.WithdrawalApplicationStatusRejected:
resp, err := pointWallet.ReleasePointWithdrawal(ctx, &walletv1.ReleasePointWithdrawalRequest{
CommandId: commandID,
UserId: userID,
AssetType: assetType,
GrossPointAmount: amountMinor,
FeePointAmount: feePoints,
NetPointAmount: netPoints,
PointsPerUsd: pointsPerUSD,
FeeBps: feeBPS,
OperatorUserId: int64(actor.UserID),
Reason: reason,
AppCode: appCode,
WithdrawalApplicationId: applicationIDText,
})
if err != nil {
return "", err
}
return resp.GetTransactionId(), nil
default:
return "", errors.New("审核结果不正确")
}
}
switch decision {
case model.WithdrawalApplicationStatusApproved:
resp, err := s.wallet.SettleSalaryWithdrawal(ctx, &walletv1.SettleSalaryWithdrawalRequest{
CommandId: commandID,
UserId: userID,
SalaryAssetType: assetType,
SalaryUsdMinor: amountMinor,
OperatorUserId: int64(actor.UserID),
Reason: reason,
AppCode: appCode,
WithdrawalApplicationId: applicationIDText,
})
if err != nil {
return "", err
}
return resp.GetTransactionId(), nil
case model.WithdrawalApplicationStatusRejected:
resp, err := s.wallet.ReleaseSalaryWithdrawal(ctx, &walletv1.ReleaseSalaryWithdrawalRequest{
CommandId: commandID,
UserId: userID,
SalaryAssetType: assetType,
SalaryUsdMinor: amountMinor,
OperatorUserId: int64(actor.UserID),
Reason: reason,
AppCode: appCode,
WithdrawalApplicationId: applicationIDText,
})
if err != nil {
return "", err
}
return resp.GetTransactionId(), nil
default:
return "", errors.New("审核结果不正确")
}
}
func withdrawalAuditCommandID(id uint, stage repository.WithdrawalApplicationReviewStage) string {
// 同一阶段的通过和拒绝复用 command id并发相反决策会在 wallet request hash 校验处冲突,不能先后 settle/release 同一冻结金额。
return fmt.Sprintf("salary-withdrawal:%d:%s", id, stage)
}
func isPointWithdrawalAssetType(assetType string) bool {
switch strings.ToUpper(strings.TrimSpace(assetType)) {
case "POINT", "COIN_SELLER_POINT":
return true
default:
return false
}
}
type auditNoticeInput struct {
ApplicationID uint
AppCode string
UserID int64
Stage repository.WithdrawalApplicationReviewStage
Decision string
Remark string
AuditImageURL string
AuditTransactionID string
WithdrawAmount string
WithdrawMethod string
RequestID string
SentAtMS int64
}
func (s *Service) sendWithdrawalAuditNotice(ctx context.Context, input auditNoticeInput) error {
body := "Your withdrawal request has been approved."
title := "Withdrawal request approved"
imageURL := ""
eventType := "finance_withdrawal_approved"
if input.Decision == model.WithdrawalApplicationStatusRejected {
body = "Your withdrawal request has been rejected. Reason: " + strings.TrimSpace(input.Remark)
title = "Withdrawal request rejected"
eventType = "finance_withdrawal_rejected"
if input.Stage == repository.WithdrawalApplicationReviewStageOperations {
eventType = "operations_withdrawal_rejected"
}
} else {
imageURL = strings.TrimSpace(input.AuditImageURL)
}
metadata, err := json.Marshal(map[string]any{
"application_id": input.ApplicationID,
"app_code": strings.TrimSpace(input.AppCode),
"audit_transaction_id": strings.TrimSpace(input.AuditTransactionID),
"decision": strings.TrimSpace(input.Decision),
"review_stage": string(input.Stage),
"withdraw_amount": strings.TrimSpace(input.WithdrawAmount),
"withdraw_method": strings.TrimSpace(input.WithdrawMethod),
})
if err != nil {
return err
}
_, err = s.activity.CreateInboxMessage(ctx, &activityv1.CreateInboxMessageRequest{
Meta: &activityv1.RequestMeta{
RequestId: strings.TrimSpace(input.RequestID),
Caller: "admin-server",
AppCode: strings.TrimSpace(input.AppCode),
SentAtMs: input.SentAtMS,
},
TargetUserId: input.UserID,
Producer: "admin-server",
ProducerEventId: withdrawalAuditNotificationEventID(input.ApplicationID, input.Stage, input.Decision),
ProducerEventType: eventType,
MessageType: "system",
AggregateType: "user_withdrawal_application",
AggregateId: strconv.FormatUint(uint64(input.ApplicationID), 10),
Title: title,
Summary: body,
Body: body,
ImageUrl: imageURL,
SentAtMs: input.SentAtMS,
MetadataJson: string(metadata),
})
if err != nil {
return fmt.Errorf("发送提现审核通知失败: %w", err)
}
return nil
}
func withdrawalAuditNotificationEventID(id uint, stage repository.WithdrawalApplicationReviewStage, decision string) string {
if stage == repository.WithdrawalApplicationReviewStageFinance {
// 旧财务通知可能已成功而 admin 申请仍 pending继续使用原 event id部署后重试才不会重复发用户通知。
return fmt.Sprintf("finance-withdrawal:%d:%s", id, strings.TrimSpace(decision))
}
return fmt.Sprintf("operations-withdrawal:%d:%s", id, strings.TrimSpace(decision))
}
func canAuditWithdrawal(permissions []string) bool {
return hasWithdrawalPermission(permissions, permissionAuditWithdrawalApplications)
}
func canAuditOperationsWithdrawal(permissions []string) bool {
return hasWithdrawalPermission(permissions, permissionAuditOperationsWithdrawals)
}
func canAuditWithdrawalStage(stage repository.WithdrawalApplicationReviewStage, permissions []string) bool {
if stage == repository.WithdrawalApplicationReviewStageOperations {
return canAuditOperationsWithdrawal(permissions)
}
return canAuditWithdrawal(permissions)
}
func hasWithdrawalPermission(permissions []string, expected string) bool {
for _, permission := range permissions {
if strings.TrimSpace(permission) == expected {
return true
}
}
return false
}