840 lines
35 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"
"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
}