840 lines
35 KiB
Go
840 lines
35 KiB
Go
package financewithdrawal
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"math/big"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"hyapp-admin-server/internal/appctx"
|
||
"hyapp-admin-server/internal/integration/activityclient"
|
||
"hyapp-admin-server/internal/integration/walletclient"
|
||
"hyapp-admin-server/internal/integration/withdrawalsource"
|
||
"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
|
||
sources *withdrawalsource.Registry
|
||
notifier ReviewPendingNotifier
|
||
now func() time.Time
|
||
}
|
||
|
||
type externalWithdrawalApplicationInput struct {
|
||
AppCode string
|
||
SourceSystem string
|
||
SourceApplicationID string
|
||
UserID string
|
||
SalaryAssetType string
|
||
WithdrawAmount string
|
||
WithdrawAmountMinor int64
|
||
BalanceBefore string
|
||
ServiceChargeRatio string
|
||
ServiceCharge string
|
||
ActualAmount string
|
||
ActualAmountMinor *int64
|
||
WithdrawMethod string
|
||
WithdrawAddress string
|
||
CreatedAtMS int64
|
||
}
|
||
|
||
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 NewServiceWithSources(store *repository.Store, wallet walletclient.Client, activity activityclient.Client, sources *withdrawalsource.Registry) *Service {
|
||
service := NewService(store, wallet, activity)
|
||
service.sources = sources
|
||
return service
|
||
}
|
||
|
||
func (s *Service) BindReviewPendingNotifier(notifier ReviewPendingNotifier) *Service {
|
||
if s != nil {
|
||
s.notifier = notifier
|
||
}
|
||
return s
|
||
}
|
||
|
||
func (s *Service) CreateExternalApplication(ctx context.Context, input externalWithdrawalApplicationInput) (*withdrawalApplicationDTO, error) {
|
||
if s == nil || s.store == nil || s.sources == nil {
|
||
return nil, errors.New("external withdrawal intake is not configured")
|
||
}
|
||
input.AppCode = appctx.Normalize(input.AppCode)
|
||
input.SourceSystem = strings.ToLower(strings.TrimSpace(input.SourceSystem))
|
||
input.SourceApplicationID = strings.TrimSpace(input.SourceApplicationID)
|
||
input.UserID = strings.TrimSpace(input.UserID)
|
||
input.SalaryAssetType = strings.TrimSpace(input.SalaryAssetType)
|
||
input.WithdrawAmount = strings.TrimSpace(input.WithdrawAmount)
|
||
input.BalanceBefore = strings.TrimSpace(input.BalanceBefore)
|
||
input.ServiceChargeRatio = strings.TrimSpace(input.ServiceChargeRatio)
|
||
input.ServiceCharge = strings.TrimSpace(input.ServiceCharge)
|
||
input.ActualAmount = strings.TrimSpace(input.ActualAmount)
|
||
input.WithdrawMethod = strings.TrimSpace(input.WithdrawMethod)
|
||
input.WithdrawAddress = strings.TrimSpace(input.WithdrawAddress)
|
||
if !s.sources.HasSource(input.AppCode, input.SourceSystem) {
|
||
return nil, errors.New("withdrawal source is not configured")
|
||
}
|
||
if input.SourceApplicationID == "" || len(input.SourceApplicationID) > 128 || input.UserID == "" || len(input.UserID) > 64 {
|
||
return nil, errors.New("来源申请号或用户 ID 不正确")
|
||
}
|
||
if input.SalaryAssetType == "" || len(input.SalaryAssetType) > 64 || input.WithdrawMethod == "" || len(input.WithdrawMethod) > 64 || input.WithdrawAddress == "" || len(input.WithdrawAddress) > 255 {
|
||
return nil, errors.New("提现资产或收款信息不完整")
|
||
}
|
||
if err := validateExternalWithdrawalAmount(input.WithdrawAmount, input.WithdrawAmountMinor); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := validateExternalWithdrawalBalanceBefore(input.BalanceBefore, input.WithdrawAmount); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := validateExternalWithdrawalPayoutSnapshot(input); err != nil {
|
||
return nil, err
|
||
}
|
||
createdAtMS := input.CreatedAtMS
|
||
if createdAtMS <= 0 {
|
||
createdAtMS = s.now().UnixMilli()
|
||
}
|
||
application, created, err := s.store.CreateExternalWithdrawalApplication(model.UserWithdrawalApplication{
|
||
AppCode: input.AppCode,
|
||
SourceSystem: input.SourceSystem,
|
||
SourceApplicationID: input.SourceApplicationID,
|
||
UserID: input.UserID,
|
||
SalaryAssetType: input.SalaryAssetType,
|
||
WithdrawAmount: input.WithdrawAmount,
|
||
WithdrawAmountMinor: input.WithdrawAmountMinor,
|
||
BalanceBefore: optionalDecimalString(input.BalanceBefore),
|
||
ServiceChargeRatio: optionalDecimalString(input.ServiceChargeRatio),
|
||
ServiceCharge: optionalDecimalString(input.ServiceCharge),
|
||
ActualAmount: optionalDecimalString(input.ActualAmount),
|
||
ActualAmountMinor: input.ActualAmountMinor,
|
||
WithdrawMethod: input.WithdrawMethod,
|
||
WithdrawAddress: input.WithdrawAddress,
|
||
// 外部来源在提交前已经完成扣款;这两个字段保留来源事实,真正终态由 callback adapter 执行,绝不误调用 HY wallet。
|
||
FreezeCommandID: "external:" + input.SourceSystem + ":" + input.SourceApplicationID,
|
||
FreezeTransactionID: "external:" + input.SourceApplicationID,
|
||
Status: model.WithdrawalApplicationStatusPending,
|
||
OperationsStatus: model.WithdrawalOperationsStatusPending,
|
||
CreatedAtMS: createdAtMS,
|
||
UpdatedAtMS: createdAtMS,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 来源采用持久重试;只有首次成功落库才提醒运营,HTTP 响应丢失后的幂等重放不能反复轰炸审核群。
|
||
if created {
|
||
s.notifyReviewPending(ctx, *application, repository.WithdrawalApplicationReviewStageOperations)
|
||
}
|
||
dto := withdrawalApplicationDTOFromModel(*application)
|
||
return &dto, nil
|
||
}
|
||
|
||
func validateExternalWithdrawalAmount(amount string, amountMinor int64) error {
|
||
parsed, ok := new(big.Rat).SetString(strings.TrimSpace(amount))
|
||
if !ok || parsed.Sign() <= 0 || amountMinor <= 0 {
|
||
return errors.New("提现金额不正确")
|
||
}
|
||
minor := new(big.Rat).Mul(parsed, big.NewRat(100, 1))
|
||
if !minor.IsInt() || !minor.Num().IsInt64() || minor.Num().Int64() != amountMinor {
|
||
return errors.New("提现金额与最小单位金额不一致")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateExternalWithdrawalBalanceBefore(balanceBefore string, withdrawAmount string) error {
|
||
if balanceBefore == "" {
|
||
// 兼容字段上线前已经进入 Likei 持久重试队列的申请;这些历史单在审核页明确显示“-”。
|
||
return nil
|
||
}
|
||
balance, ok := new(big.Rat).SetString(balanceBefore)
|
||
if !ok || balance.Sign() < 0 {
|
||
return errors.New("提现前余额不正确")
|
||
}
|
||
amount, ok := new(big.Rat).SetString(withdrawAmount)
|
||
if !ok || balance.Cmp(amount) < 0 {
|
||
return errors.New("提现前余额不能小于提现金额")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateExternalWithdrawalPayoutSnapshot(input externalWithdrawalApplicationInput) error {
|
||
ratioText := strings.TrimSpace(input.ServiceChargeRatio)
|
||
chargeText := strings.TrimSpace(input.ServiceCharge)
|
||
actualText := strings.TrimSpace(input.ActualAmount)
|
||
presentCount := 0
|
||
for _, present := range []bool{ratioText != "", chargeText != "", actualText != "", input.ActualAmountMinor != nil} {
|
||
if present {
|
||
presentCount++
|
||
}
|
||
}
|
||
if presentCount == 0 {
|
||
// ChatApp3 的历史字段同时包含费率和已向上取整的手续费结果;缺快照时 Admin 无法安全推导实际打款额。
|
||
if strings.EqualFold(strings.TrimSpace(input.SourceSystem), "chatapp3") {
|
||
return errors.New("ChatApp3 提现缺少手续费与实际打款快照")
|
||
}
|
||
return nil
|
||
}
|
||
if presentCount != 4 {
|
||
return errors.New("提现手续费与实际打款快照不完整")
|
||
}
|
||
|
||
ratio, _, ok := parseExternalFixedDecimal(ratioText, 9, 4)
|
||
if !ok || ratio.Sign() < 0 || ratio.Cmp(big.NewRat(100, 1)) > 0 {
|
||
return errors.New("提现手续费率不正确")
|
||
}
|
||
charge, chargeMinor, ok := parseExternalFixedDecimal(chargeText, 18, 2)
|
||
if !ok || charge.Sign() < 0 {
|
||
return errors.New("提现手续费不正确")
|
||
}
|
||
actual, actualMinor, ok := parseExternalFixedDecimal(actualText, 18, 2)
|
||
if !ok || actual.Sign() <= 0 {
|
||
return errors.New("实际打款金额不正确")
|
||
}
|
||
if !actualMinor.IsInt64() || actualMinor.Int64() != *input.ActualAmountMinor {
|
||
return errors.New("实际打款金额与最小单位金额不一致")
|
||
}
|
||
gross, _, ok := parseExternalFixedDecimal(input.WithdrawAmount, 18, 2)
|
||
if !ok || new(big.Rat).Add(charge, actual).Cmp(gross) != 0 {
|
||
return errors.New("申请金额、手续费与实际打款金额不一致")
|
||
}
|
||
// 同时校验最小单位表示,防止 decimal 展示正确但上游分值被截断或溢出。
|
||
payoutMinor := new(big.Int).Add(chargeMinor, big.NewInt(*input.ActualAmountMinor))
|
||
if !payoutMinor.IsInt64() || payoutMinor.Int64() != input.WithdrawAmountMinor {
|
||
return errors.New("手续费、实际打款与申请金额的最小单位不一致")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func parseExternalFixedDecimal(value string, precision int, scale int) (*big.Rat, *big.Int, bool) {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" || len(value) > 64 || precision <= 0 || scale < 0 || scale > precision {
|
||
return nil, nil, false
|
||
}
|
||
negative := false
|
||
if value[0] == '+' || value[0] == '-' {
|
||
negative = value[0] == '-'
|
||
value = value[1:]
|
||
}
|
||
parts := strings.Split(value, ".")
|
||
if len(parts) > 2 || parts[0] == "" || !isExternalDecimalDigits(parts[0]) {
|
||
return nil, nil, false
|
||
}
|
||
fraction := ""
|
||
if len(parts) == 2 {
|
||
fraction = parts[1]
|
||
if fraction == "" || !isExternalDecimalDigits(fraction) {
|
||
return nil, nil, false
|
||
}
|
||
}
|
||
if len(fraction) > scale {
|
||
// 文本小数位超出列 scale 时即使多出的是 0 也拒绝,避免契约精度在入库时被隐式改写。
|
||
return nil, nil, false
|
||
}
|
||
scaleFactor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(scale)), nil)
|
||
scaledDigits := parts[0] + fraction + strings.Repeat("0", scale-len(fraction))
|
||
scaled, ok := new(big.Int).SetString(scaledDigits, 10)
|
||
if !ok {
|
||
return nil, nil, false
|
||
}
|
||
if negative {
|
||
scaled.Neg(scaled)
|
||
}
|
||
maxScaled := new(big.Int).Sub(new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(precision)), nil), big.NewInt(1))
|
||
if new(big.Int).Abs(new(big.Int).Set(scaled)).Cmp(maxScaled) > 0 {
|
||
return nil, nil, false
|
||
}
|
||
// 先按存储 scale 构造精确有理数,后续所有金额恒等式都不经过 float64 或四舍五入。
|
||
return new(big.Rat).SetFrac(new(big.Int).Set(scaled), scaleFactor), scaled, true
|
||
}
|
||
|
||
func isExternalDecimalDigits(value string) bool {
|
||
for i := 0; i < len(value); i++ {
|
||
if value[i] < '0' || value[i] > '9' {
|
||
return false
|
||
}
|
||
}
|
||
return value != ""
|
||
}
|
||
|
||
func optionalDecimalString(value string) *string {
|
||
if value == "" {
|
||
return nil
|
||
}
|
||
return &value
|
||
}
|
||
|
||
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.WithdrawalApplicationReviewStageFinance {
|
||
current, err := s.store.GetWithdrawalApplicationForApp(appCode, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if isExternalWithdrawal(*current) {
|
||
// 外部钱包回调和 Admin 终态不可能共享一个数据库事务;先持久首次财务 claim 才能安全调用回调。
|
||
return s.executeExternalFinanceDecision(ctx, actor, id, appCode, decision, remark, auditImageURL, commandID, nowMS)
|
||
}
|
||
}
|
||
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) {
|
||
if stage == repository.WithdrawalApplicationReviewStageOperations && decision == model.WithdrawalApplicationStatusApproved {
|
||
// 运营通过只确认业务资料并转交财务;申请金额继续保留在 frozen,且不能提前向用户发送最终通过通知。
|
||
return repository.WithdrawalApplicationAuditEffect{}, nil
|
||
}
|
||
transactionID, userID, walletErr := s.applyWithdrawalDecision(ctx, item, decision, commandID, appCode, actor, remark, auditImageURL, id)
|
||
if walletErr != nil {
|
||
return repository.WithdrawalApplicationAuditEffect{}, walletErr
|
||
}
|
||
if !isExternalWithdrawal(item) {
|
||
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,
|
||
})
|
||
if noticeErr != nil {
|
||
// 钱包扣冻/释放使用阶段固定 command id,通知使用阶段+结果事件 id;回调失败会回滚 admin 状态,重试不会重复资金动作或消息。
|
||
return repository.WithdrawalApplicationAuditEffect{}, noticeErr
|
||
}
|
||
}
|
||
return repository.WithdrawalApplicationAuditEffect{CommandID: commandID, TransactionID: transactionID}, nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if stage == repository.WithdrawalApplicationReviewStageOperations && decision == model.WithdrawalApplicationStatusApproved {
|
||
// 运营通过已经提交到数据库,随后独立提醒财务;机器人异常不能把已转交的申请回滚到运营队列。
|
||
s.notifyReviewPending(ctx, *updated, repository.WithdrawalApplicationReviewStageFinance)
|
||
}
|
||
dto := withdrawalApplicationDTOFromModel(*updated)
|
||
return &dto, nil
|
||
}
|
||
|
||
func (s *Service) notifyReviewPending(ctx context.Context, application model.UserWithdrawalApplication, stage repository.WithdrawalApplicationReviewStage) {
|
||
if s == nil || s.notifier == nil {
|
||
return
|
||
}
|
||
if err := s.notifier.NotifyReviewPending(ctx, application, stage); err != nil {
|
||
slog.Warn("withdrawal_review_dingtalk_notify_failed",
|
||
"application_id", application.ID,
|
||
"app_code", application.AppCode,
|
||
"review_stage", stage,
|
||
"error", err,
|
||
)
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
return validateWithdrawalDecisionTarget(item)
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if claimed.Status == model.WithdrawalApplicationStatusRejected && claimed.OperationsStatus == model.WithdrawalOperationsStatusRejected {
|
||
// 已完成的重复请求直接返回终态,避免再次调用 wallet/inbox;前端重试是安全的。
|
||
dto := withdrawalApplicationDTOFromModel(*claimed)
|
||
return &dto, nil
|
||
}
|
||
|
||
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, userID, err := s.applyWithdrawalDecision(ctx, *claimed, model.WithdrawalApplicationStatusRejected, commandID, appCode, claimedActor, claimedRemark, "", id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
sentAtMS := nowMS
|
||
if claimed.OperationsReviewedAtMS != nil && *claimed.OperationsReviewedAtMS > 0 {
|
||
sentAtMS = *claimed.OperationsReviewedAtMS
|
||
}
|
||
if !isExternalWithdrawal(*claimed) {
|
||
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,
|
||
})
|
||
if 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
|
||
}
|
||
|
||
// executeExternalFinanceDecision 把外部财务终审拆成 claim -> 来源幂等回调 -> finalize。
|
||
// claim 一旦提交,后续任何审核人的同决策重试都只能沿用第一次审计快照,相反决策在回调前被拒绝。
|
||
func (s *Service) executeExternalFinanceDecision(ctx context.Context, actor shared.Actor, id uint, appCode string, decision string, remark string, auditImageURL string, commandID string, nowMS int64) (*withdrawalApplicationDTO, error) {
|
||
claimed, err := s.store.ClaimExternalWithdrawalFinanceDecisionForApp(appCode, id, repository.WithdrawalApplicationAuditInput{
|
||
Stage: repository.WithdrawalApplicationReviewStageFinance,
|
||
Decision: decision,
|
||
ApproverUserID: actor.UserID,
|
||
ApproverName: actor.Username,
|
||
AuditRemark: remark,
|
||
AuditImageURL: auditImageURL,
|
||
AuditCommandID: commandID,
|
||
ApprovedAtMS: nowMS,
|
||
}, func(item model.UserWithdrawalApplication) error {
|
||
return validateWithdrawalDecisionTarget(item)
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
claimStatus := ""
|
||
if claimed.ExternalFinanceClaimStatus != nil {
|
||
claimStatus = strings.TrimSpace(*claimed.ExternalFinanceClaimStatus)
|
||
}
|
||
if claimStatus == model.WithdrawalExternalFinanceClaimStatusFinalized {
|
||
// 上次 finalize 可能已提交但返回链路中断;终态幂等返回不再重复回调。
|
||
dto := withdrawalApplicationDTOFromModel(*claimed)
|
||
return &dto, nil
|
||
}
|
||
if claimStatus != model.WithdrawalExternalFinanceClaimStatusClaimed || claimed.ExternalFinanceClaimDecision == nil ||
|
||
claimed.ApproverUserID == nil || *claimed.ApproverUserID == 0 || claimed.ApprovedAtMS == nil || *claimed.ApprovedAtMS <= 0 || strings.TrimSpace(claimed.AuditCommandID) == "" {
|
||
return nil, errors.New("外部提现财务 claim 不完整")
|
||
}
|
||
claimedDecision := strings.TrimSpace(*claimed.ExternalFinanceClaimDecision)
|
||
claimedRemark := strings.TrimSpace(claimed.AuditRemark)
|
||
claimedImageURL := strings.TrimSpace(claimed.AuditImageURL)
|
||
claimedActor := shared.Actor{UserID: *claimed.ApproverUserID, Username: claimed.ApproverName}
|
||
transactionID, _, err := s.applyWithdrawalDecision(ctx, *claimed, claimedDecision, claimed.AuditCommandID, appCode, claimedActor, claimedRemark, claimedImageURL, id)
|
||
if err != nil {
|
||
// claim 已在独立事务持久,回调失败只返错误供财务按相同决策重试,不得回退或改写 claim。
|
||
return nil, err
|
||
}
|
||
updated, err := s.store.FinalizeExternalWithdrawalFinanceDecisionForApp(appCode, id, claimedDecision, claimed.AuditCommandID, 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 validateWithdrawalDecisionTarget(item model.UserWithdrawalApplication) error {
|
||
if isExternalWithdrawal(item) {
|
||
if strings.TrimSpace(item.SourceApplicationID) == "" || strings.TrimSpace(item.UserID) == "" || item.WithdrawAmountMinor <= 0 {
|
||
return errors.New("外部提现单来源信息不完整")
|
||
}
|
||
return nil
|
||
}
|
||
_, err := withdrawalWalletUserID(item)
|
||
return err
|
||
}
|
||
|
||
func isExternalWithdrawal(item model.UserWithdrawalApplication) bool {
|
||
sourceSystem := strings.ToLower(strings.TrimSpace(item.SourceSystem))
|
||
return sourceSystem != "" && sourceSystem != "hyapp_wallet"
|
||
}
|
||
|
||
func (s *Service) applyWithdrawalDecision(ctx context.Context, item model.UserWithdrawalApplication, decision string, commandID string, appCode string, actor shared.Actor, remark string, auditImageURL string, applicationID uint) (string, int64, error) {
|
||
if isExternalWithdrawal(item) {
|
||
if s.sources == nil {
|
||
return "", 0, errors.New("withdrawal source callback is not configured")
|
||
}
|
||
sourceDecision := "NOT_PASS"
|
||
credentials := []string(nil)
|
||
if decision == model.WithdrawalApplicationStatusApproved {
|
||
sourceDecision = "PASS"
|
||
credentials = []string{strings.TrimSpace(auditImageURL)}
|
||
}
|
||
transactionID, err := s.sources.ApplyDecision(ctx, appCode, item.SourceSystem, withdrawalsource.DecisionRequest{
|
||
SourceApplicationID: item.SourceApplicationID,
|
||
Decision: sourceDecision,
|
||
CredentialURLs: credentials,
|
||
Remark: strings.TrimSpace(remark),
|
||
ReviewerUserID: actor.UserID,
|
||
ReviewerName: actor.Username,
|
||
CommandID: commandID,
|
||
})
|
||
return transactionID, 0, err
|
||
}
|
||
userID, err := withdrawalWalletUserID(item)
|
||
if err != nil {
|
||
return "", 0, err
|
||
}
|
||
transactionID, err := s.applyWalletDecision(ctx, decision, commandID, appCode, userID, item.SalaryAssetType, item.WithdrawAmountMinor, item.PointFeeAmount, item.PointNetAmount, item.PointsPerUSD, item.PointFeeBPS, actor, strings.TrimSpace(remark), applicationID)
|
||
return transactionID, userID, err
|
||
}
|
||
|
||
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 用请求哈希防相反决策,外部回调则由持久 finance claim 先锁定决策和审计上下文。
|
||
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
|
||
}
|