315 lines
12 KiB
Go
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
|
|
}
|