// Package agentrecharge 收口员工线下代充值申请和审批终态业务用例。 package agentrecharge import ( "context" "fmt" "strings" "github.com/bytedance/sonic" "gorm.io/gorm" "gorm.io/gorm/clause" approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // CreateOfflineCommand 描述员工创建线下代充值审批申请的稳定输入。 type CreateOfflineCommand struct { SubmitterAccountID uint SubmitterUserType int ShopID uint RechargeNo string Amount int64 PaymentVoucherKeys []string OtherVoucherKeys []string // OfflinePaymentMethodID 是提交人选择的线下收款方式字典项 ID。 OfflinePaymentMethodID uint // ExternalTransactionNo 是人工确认后的交易流水号,独立于在线渠道第三方交易号。 ExternalTransactionNo string Remark string } // CreateOfflineResult 返回已原子保存的业务申请和初始审批状态。 type CreateOfflineResult struct { Record *model.AgentRechargeRecord ShopName string SubmitterName string ApprovalStatus int } // OfflineCreationService 创建员工线下代充值申请及唯一通用审批实例。 type OfflineCreationService struct { db *gorm.DB approval approvalapp.Port audit RechargeAuditWriter } // NewOfflineCreationService 创建员工线下代充值申请用例。 func NewOfflineCreationService(db *gorm.DB, approval approvalapp.Port, audit RechargeAuditWriter) *OfflineCreationService { return &OfflineCreationService{db: db, approval: approval, audit: audit} } // TriggerHistorical 为历史待审批线下代充值补发一次企业微信审批。 func (s *OfflineCreationService) TriggerHistorical(ctx context.Context, recordID uint) (*CreateOfflineResult, error) { if s == nil || s.db == nil || s.approval == nil || s.audit == nil || recordID == 0 { return nil, errors.New(errors.CodeServiceUnavailable, "员工线下代充值审批能力未配置") } var record model.AgentRechargeRecord if err := s.db.WithContext(ctx).First(&record, recordID).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeNotFound, "充值记录不存在") } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史线下代充值申请失败") } if record.PaymentMethod != constants.RechargeMethodOffline || record.Status != constants.RechargeStatusPending || record.ApprovalInstanceID != nil { return nil, errors.New(errors.CodeConflict, "充值申请状态不允许补发审批") } account, shop, wallet, err := s.loadHistoricalFacts(ctx, &record) if err != nil { return nil, err } preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{ BusinessType: constants.ApprovalBusinessTypeOfflineRecharge, SubmitterAccountID: record.UserID, CorrelationID: record.RechargeNo, }) if err != nil { return nil, err } command := CreateOfflineCommand{ SubmitterAccountID: record.UserID, SubmitterUserType: account.UserType, ShopID: record.ShopID, RechargeNo: record.RechargeNo, Amount: record.Amount, PaymentVoucherKeys: []string(record.PaymentVoucherKey), Remark: record.Remark, OtherVoucherKeys: []string(record.OtherVoucherKeys), } if record.ExternalTransactionNo != nil { command.ExternalTransactionNo = *record.ExternalTransactionNo } // 补发审批使用历史记录已冻结的收款方式快照,不回查当前字典,避免历史材料被字典变更改写。 var frozenCode, frozenName string if record.OfflinePaymentMethodCode != nil { frozenCode = *record.OfflinePaymentMethodCode } if record.OfflinePaymentMethodName != nil { frozenName = *record.OfflinePaymentMethodName } submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName, frozenCode, frozenName) if err != nil { return nil, err } var approvalStatus int err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var current model.AgentRechargeRecord if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, recordID).Error; err != nil { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeNotFound, "充值记录不存在") } return errors.Wrap(errors.CodeDatabaseError, err, "锁定历史线下代充值申请失败") } if current.PaymentMethod != constants.RechargeMethodOffline || current.Status != constants.RechargeStatusPending || current.ApprovalInstanceID != nil { return errors.New(errors.CodeConflict, "充值申请状态不允许补发审批") } reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{ Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeOfflineRecharge, BusinessID: current.ID, SubmitterAccountID: current.UserID, SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot, CorrelationID: current.RechargeNo, }) if err != nil { return err } result := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}). Where("id = ? AND payment_method = ? AND status = ? AND approval_instance_id IS NULL", current.ID, constants.RechargeMethodOffline, constants.RechargeStatusPending). Update("approval_instance_id", reference.InstanceID) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联线下代充值审批实例失败") } if result.RowsAffected != 1 { return errors.New(errors.CodeConflict, "线下代充值审批实例关联已变化") } current.ApprovalInstanceID = &reference.InstanceID record = current approvalStatus = reference.Status var instance model.ApprovalInstance if err := tx.WithContext(ctx).First(&instance, reference.InstanceID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询线下代充值审批审计快照失败") } return s.audit.WriteAgentRecharge(ctx, tx, RechargeAudit{ ActionCode: constants.AuditActionAgentRechargeCreated, Summary: "补发员工线下代充值审批", Record: ¤t, Approval: &instance, Wallet: wallet, AfterData: map[string]any{"status": current.Status, "approval_instance_id": current.ApprovalInstanceID}, }) }) if err != nil { return nil, err } return &CreateOfflineResult{ Record: &record, ShopName: shop.ShopName, SubmitterName: account.Username, ApprovalStatus: approvalStatus, }, nil } // Execute 在业务写入前校验审批渠道,并在同一事务保存充值申请、审批实例和提交 Outbox。 func (s *OfflineCreationService) Execute(ctx context.Context, command CreateOfflineCommand) (*CreateOfflineResult, error) { if s == nil || s.db == nil || s.approval == nil || s.audit == nil { return nil, errors.New(errors.CodeServiceUnavailable, "员工线下代充值审批能力未配置") } if err := validateCreateOfflineCommand(command); err != nil { return nil, err } account, shop, wallet, err := s.loadCreationFacts(ctx, command) if err != nil { return nil, err } preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{ BusinessType: constants.ApprovalBusinessTypeOfflineRecharge, SubmitterAccountID: command.SubmitterAccountID, CorrelationID: strings.TrimSpace(command.RechargeNo), }) if err != nil { return nil, err } paymentChannel := constants.RechargeMethodOffline externalTransactionNo := strings.TrimSpace(command.ExternalTransactionNo) var record *model.AgentRechargeRecord var approvalStatus int err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { paymentMethod, err := loadEnabledOfflinePaymentMethod(ctx, tx, command.OfflinePaymentMethodID) if err != nil { return err } submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName, paymentMethod.Code, paymentMethod.Name) if err != nil { return err } record = &model.AgentRechargeRecord{ UserID: command.SubmitterAccountID, AgentWalletID: wallet.ID, ShopID: command.ShopID, RechargeNo: strings.TrimSpace(command.RechargeNo), Amount: command.Amount, PaymentMethod: constants.RechargeMethodOffline, PaymentChannel: &paymentChannel, PaymentVoucherKey: model.StringJSONBArray(command.PaymentVoucherKeys), Remark: strings.TrimSpace(command.Remark), ExternalTransactionNo: &externalTransactionNo, OfflinePaymentMethodID: &paymentMethod.ID, OfflinePaymentMethodCode: &paymentMethod.Code, OfflinePaymentMethodName: &paymentMethod.Name, OtherVoucherKeys: model.StringJSONBArray(command.OtherVoucherKeys), Status: constants.RechargeStatusPending, ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag, } if err := tx.WithContext(ctx).Create(record).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建员工线下代充值申请失败") } reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{ Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeOfflineRecharge, BusinessID: record.ID, SubmitterAccountID: command.SubmitterAccountID, SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot, CorrelationID: record.RechargeNo, }) if err != nil { return err } result := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}). Where("id = ? AND approval_instance_id IS NULL", record.ID). Update("approval_instance_id", reference.InstanceID) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联员工线下代充值审批实例失败") } if result.RowsAffected != 1 { return errors.New(errors.CodeConflict, "员工线下代充值审批实例关联已变化") } record.ApprovalInstanceID = &reference.InstanceID approvalStatus = reference.Status var instance model.ApprovalInstance if err := tx.WithContext(ctx).First(&instance, reference.InstanceID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询线下代充值审批审计快照失败") } return s.audit.WriteAgentRecharge(ctx, tx, RechargeAudit{ ActionCode: constants.AuditActionAgentRechargeCreated, Summary: "创建员工线下代充值申请", Record: record, Approval: &instance, Wallet: wallet, AfterData: map[string]any{"status": record.Status}, }) }) if err != nil { return nil, err } return &CreateOfflineResult{ Record: record, ShopName: shop.ShopName, SubmitterName: account.Username, ApprovalStatus: approvalStatus, }, nil } func validateCreateOfflineCommand(command CreateOfflineCommand) error { if command.SubmitterUserType != constants.UserTypePlatform && command.SubmitterUserType != constants.UserTypeSuperAdmin { return errors.New(errors.CodeForbidden, "线下充值仅平台管理员可操作") } if command.SubmitterAccountID == 0 || command.ShopID == 0 || strings.TrimSpace(command.RechargeNo) == "" { return errors.New(errors.CodeInvalidParam) } if command.Amount < constants.AgentRechargeMinAmount || command.Amount > constants.AgentRechargeMaxAmount { return errors.New(errors.CodeInvalidParam, "充值金额超出允许范围") } if command.OfflinePaymentMethodID == 0 { return errors.New(errors.CodeInvalidParam, "线下充值必须选择线下收款方式") } if err := validateRechargeTransactionNo(command.ExternalTransactionNo); err != nil { return err } if err := validateVoucherKeys(command.PaymentVoucherKeys, 1, constants.AgentRechargePaymentVoucherMaxCount, "线下充值必须上传 1 至 5 个支付凭证"); err != nil { return err } return validateVoucherKeys(command.OtherVoucherKeys, 0, constants.AgentRechargeOtherVoucherMaxCount, "线下充值其他凭证最多 5 个") } // validateRechargeTransactionNo 校验交易流水号必填且不超过长度上限;不参与去重与幂等判定。 func validateRechargeTransactionNo(value string) error { trimmed := strings.TrimSpace(value) if trimmed == "" { return errors.New(errors.CodeInvalidParam, "线下充值必须填写交易流水号") } if len([]rune(trimmed)) > constants.AgentRechargeExternalTransactionNoMaxLength { return errors.New(errors.CodeInvalidParam, "交易流水号长度超出限制") } return nil } // validateVoucherKeys 校验凭证对象键数量与内容,minCount 为 0 时允许为空。 func validateVoucherKeys(keys []string, minCount, maxCount int, message string) error { if len(keys) < minCount || len(keys) > maxCount { return errors.New(errors.CodeInvalidParam, message) } seen := make(map[string]struct{}, len(keys)) for _, key := range keys { trimmed := strings.TrimSpace(key) if trimmed == "" { return errors.New(errors.CodeInvalidParam, "线下充值凭证对象键不能为空") } if len([]rune(trimmed)) > constants.AgentRechargeVoucherKeyMaxLength { return errors.New(errors.CodeInvalidParam, "线下充值凭证对象键长度超出限制") } if _, exists := seen[trimmed]; exists { return errors.New(errors.CodeInvalidParam, "线下充值凭证对象键不能重复") } seen[trimmed] = struct{}{} } return nil } // loadEnabledOfflinePaymentMethod 读取启用的线下收款方式字典项;不存在或已停用一律拒绝。 // 仅校验存在性与启停,不做编码或名称的二次改写,快照以字典当前值为准。 func loadEnabledOfflinePaymentMethod(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionPaymentMethod, error) { if id == 0 { return nil, errors.New(errors.CodeInvalidParam, "线下充值必须选择线下收款方式") } var paymentMethod model.EmployeeCollectionPaymentMethod if err := tx.WithContext(ctx).First(&paymentMethod, id).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound) } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下收款方式失败") } if paymentMethod.Status != constants.EmployeeCollectionPaymentMethodStatusEnabled { return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodDisabled) } return &paymentMethod, nil } func (s *OfflineCreationService) loadHistoricalFacts( ctx context.Context, record *model.AgentRechargeRecord, ) (*model.Account, *model.Shop, *model.AgentWallet, error) { if record == nil || record.UserID == 0 || record.ShopID == 0 || record.AgentWalletID == 0 { return nil, nil, nil, errors.New(errors.CodeInvalidParam) } var account model.Account if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", record.UserID, constants.StatusEnabled).First(&account).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, nil, nil, errors.New(errors.CodeForbidden, "原创建账号不可用") } return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史线下代充值创建人失败") } var shop model.Shop if err := s.db.WithContext(ctx).Where("id = ?", record.ShopID).First(&shop).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, nil, nil, errors.New(errors.CodeNotFound, "目标店铺不存在") } return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史线下代充值目标店铺失败") } var wallet model.AgentWallet if err := s.db.WithContext(ctx). Where("id = ? AND shop_id = ? AND wallet_type = ? AND status = ?", record.AgentWalletID, record.ShopID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal). First(&wallet).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, nil, nil, errors.New(errors.CodeWalletNotFound, "原充值主钱包不存在或不可用") } return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史线下代充值主钱包失败") } return &account, &shop, &wallet, nil } func (s *OfflineCreationService) loadCreationFacts( ctx context.Context, command CreateOfflineCommand, ) (*model.Account, *model.Shop, *model.AgentWallet, error) { var account model.Account if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", command.SubmitterAccountID, constants.StatusEnabled).First(&account).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, nil, nil, errors.New(errors.CodeForbidden, "提交人账号不可用") } return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下代充值提交人失败") } var shop model.Shop if err := s.db.WithContext(ctx).Where("id = ?", command.ShopID).First(&shop).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, nil, nil, errors.New(errors.CodeNotFound, "目标店铺不存在") } return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询目标店铺失败") } var wallet model.AgentWallet if err := s.db.WithContext(ctx). Where("shop_id = ? AND wallet_type = ? AND status = ?", command.ShopID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal). First(&wallet).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, nil, nil, errors.New(errors.CodeWalletNotFound, "目标店铺主钱包不存在或不可用") } return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询目标店铺主钱包失败") } return &account, &shop, &wallet, nil } func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopName, paymentMethodCode, paymentMethodName string) ([]byte, []byte, error) { submitterSnapshot, err := sonic.Marshal(map[string]any{ "account_id": command.SubmitterAccountID, "account_name": submitterName, "user_type": command.SubmitterUserType, }) if err != nil { return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码线下代充值提交人快照失败") } requestSnapshot, err := sonic.Marshal(map[string]any{ constants.ApprovalFieldRechargeNo: strings.TrimSpace(command.RechargeNo), constants.ApprovalFieldShopID: command.ShopID, constants.ApprovalFieldShopName: shopName, constants.ApprovalFieldAmount: fmt.Sprintf("%d.%02d", command.Amount/100, command.Amount%100), constants.ApprovalFieldAmountCent: command.Amount, constants.ApprovalFieldPaymentVoucherKey: command.PaymentVoucherKeys, constants.ApprovalFieldRemark: strings.TrimSpace(command.Remark), constants.ApprovalFieldSubmitterID: command.SubmitterAccountID, constants.ApprovalFieldSubmitterName: submitterName, constants.ApprovalFieldOfflinePaymentMethod: paymentMethodName, constants.ApprovalFieldOfflinePaymentMethodCode: paymentMethodCode, constants.ApprovalFieldExternalTransactionNo: strings.TrimSpace(command.ExternalTransactionNo), constants.ApprovalFieldOtherVoucherKey: command.OtherVoucherKeys, }) if err != nil { return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码线下代充值审批业务快照失败") } return submitterSnapshot, requestSnapshot, nil }