315 lines
12 KiB
Go

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"
permissionAuditFinanceApplications = "finance-application:audit"
pointWithdrawalDefaultPointsPerUSD = int64(100000)
pointWithdrawalDefaultFeeBPS = int32(500)
)
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(options repository.WithdrawalApplicationListOptions) ([]withdrawalApplicationDTO, int64, error) {
if s == nil || s.store == nil {
return nil, 0, errors.New("admin store is not configured")
}
items, total, err := s.store.ListWithdrawalApplications(options)
if err != nil {
return nil, 0, err
}
return withdrawalApplicationDTOsFromModel(items), total, 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, 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, model.WithdrawalApplicationStatusRejected, remark, "", requestID)
}
func (s *Service) auditApplication(ctx context.Context, actor shared.Actor, id uint, 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 || !canAuditWithdrawal(actor.Permissions) {
return nil, errors.New("没有操作权限")
}
remark = strings.TrimSpace(remark)
auditImageURL = strings.TrimSpace(auditImageURL)
if decision == model.WithdrawalApplicationStatusApproved && auditImageURL == "" {
return nil, errors.New("提现通过凭证图片不能为空")
}
if decision == model.WithdrawalApplicationStatusRejected && remark == "" {
return nil, errors.New("拒绝原因不能为空")
}
appCode := appctx.FromContext(ctx)
item, err := s.store.GetWithdrawalApplicationForApp(appCode, id)
if err != nil {
return nil, err
}
if item.Status != model.WithdrawalApplicationStatusPending {
return nil, repository.ErrWithdrawalApplicationAlreadyAudited
}
userID, err := strconv.ParseInt(strings.TrimSpace(item.UserID), 10, 64)
if err != nil || userID <= 0 || item.SalaryAssetType == "" || item.WithdrawAmountMinor <= 0 || item.FreezeTransactionID == "" {
return nil, errors.New("提现单冻结信息不完整")
}
commandID := withdrawalAuditCommandID(id, decision)
amountMinor := item.WithdrawAmountMinor
transactionID, err := s.applyWalletDecision(ctx, decision, commandID, appCode, userID, item.SalaryAssetType, amountMinor, actor, strings.TrimSpace(remark), id)
if err != nil {
return nil, err
}
nowMS := s.now().UnixMilli()
if err := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{
ApplicationID: id,
AppCode: appCode,
UserID: userID,
Decision: decision,
Remark: remark,
AuditImageURL: auditImageURL,
AuditTransactionID: transactionID,
WithdrawAmount: item.WithdrawAmount,
WithdrawMethod: item.WithdrawMethod,
RequestID: requestID,
SentAtMS: nowMS,
}); err != nil {
// 钱包同意/释放已经用 command id 做幂等;通知失败时不写后台终态,财务重试会复用同一钱包命令和消息事件,避免重复扣款、返还或重复通知。
return nil, err
}
updated, err := s.store.AuditWithdrawalApplicationForApp(appCode, id, repository.WithdrawalApplicationAuditInput{
Decision: decision,
ApproverUserID: actor.UserID,
ApproverName: actor.Username,
AuditRemark: remark,
AuditImageURL: auditImageURL,
AuditCommandID: commandID,
AuditTransactionID: transactionID,
ApprovedAtMS: nowMS,
})
if err != nil {
return nil, err
}
dto := withdrawalApplicationDTOFromModel(*updated)
return &dto, nil
}
func (s *Service) applyWalletDecision(ctx context.Context, decision string, commandID string, appCode string, userID int64, assetType string, amountMinor int64, 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 := amountMinor * int64(pointWithdrawalDefaultFeeBPS) / 10000
netPoints := amountMinor - feePoints
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: pointWithdrawalDefaultPointsPerUSD,
FeeBps: pointWithdrawalDefaultFeeBPS,
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: pointWithdrawalDefaultPointsPerUSD,
FeeBps: pointWithdrawalDefaultFeeBPS,
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, decision string) string {
return fmt.Sprintf("salary-withdrawal:%d:%s", id, decision)
}
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
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"
} 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),
"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.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, decision string) string {
return fmt.Sprintf("finance-withdrawal:%d:%s", id, strings.TrimSpace(decision))
}
func canAuditWithdrawal(permissions []string) bool {
for _, permission := range permissions {
switch strings.TrimSpace(permission) {
case permissionAuditWithdrawalApplications, permissionAuditFinanceApplications:
return true
}
}
return false
}