Files
junhong_cmp_fiber/internal/application/wecom/scene.go
break b063617153
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m35s
完善退款审批材料与恢复流程
2026-09-20 17:59:52 +08:00

598 lines
38 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 wecom
import (
"context"
"crypto/sha256"
"encoding/hex"
stdErrors "errors"
"fmt"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// TemplateControl 是模板详情中可供业务映射的最小控件结构。
type TemplateControl struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Required bool `json:"required"`
OptionKeys []string `json:"option_keys"`
}
// TemplateDefinition 是企微 Adapter 返回的模板最小结构。
type TemplateDefinition struct {
TemplateID string `json:"template_id"`
Name string `json:"name"`
Controls []TemplateControl `json:"controls"`
}
// TemplateProvider 定义审批模板详情外部端口。
type TemplateProvider interface {
GetTemplateDetail(ctx context.Context, applicationID uint, templateID string) (TemplateDefinition, error)
}
// SceneRepository 定义审批场景当前配置持久化边界。
type SceneRepository interface {
FindForUpdate(ctx context.Context, tx *gorm.DB, businessType string) (*model.WeComApprovalScene, error)
Create(ctx context.Context, tx *gorm.DB, scene *model.WeComApprovalScene) error
Update(ctx context.Context, tx *gorm.DB, scene *model.WeComApprovalScene) error
List(ctx context.Context, page, pageSize int) ([]model.WeComApprovalScene, int64, error)
}
// SceneService 保存经企微模板详情校验的业务场景当前映射。
type SceneService struct {
db *gorm.DB
provider TemplateProvider
repo SceneRepository
audit systemconfigapp.AuditWriter
now func() time.Time
}
// NewSceneService 创建企业微信审批场景配置用例。
func NewSceneService(db *gorm.DB, provider TemplateProvider, repo SceneRepository, audit systemconfigapp.AuditWriter) *SceneService {
return &SceneService{db: db, provider: provider, repo: repo, audit: audit, now: time.Now}
}
// InspectTemplate 实时读取企微模板详情,供管理员配置控件映射。
func (s *SceneService) InspectTemplate(ctx context.Context, applicationID uint, request dto.InspectWeComTemplateRequest) (*dto.WeComTemplateDetailResponse, error) {
if s == nil || s.provider == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信模板服务未配置")
}
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden)
}
templateID := strings.TrimSpace(request.TemplateID)
if applicationID == 0 || templateID == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
definition, err := s.provider.GetTemplateDetail(ctx, applicationID, templateID)
if err != nil {
return nil, err
}
controls := make([]dto.WeComTemplateControlResponse, 0, len(definition.Controls))
for _, control := range definition.Controls {
controls = append(controls, dto.WeComTemplateControlResponse{
ID: control.ID, Type: control.Type, Title: control.Title,
Required: control.Required, OptionKeys: control.OptionKeys,
})
}
return &dto.WeComTemplateDetailResponse{
TemplateID: definition.TemplateID, Name: definition.Name, Controls: controls,
}, nil
}
// ListBusinessFields 返回指定审批场景允许映射的业务快照字段。
func (s *SceneService) ListBusinessFields(ctx context.Context, businessType string) (*dto.WeComBusinessFieldListResponse, error) {
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return nil, errors.New(errors.CodeForbidden)
}
businessType = strings.TrimSpace(businessType)
items, ok := sceneBusinessFields(businessType)
if !ok {
return nil, errors.New(errors.CodeInvalidParam, "不支持的审批业务类型")
}
return &dto.WeComBusinessFieldListResponse{
BusinessType: businessType, BusinessTypeName: approvalBusinessTypeName(businessType), Items: items,
}, nil
}
// Save 校验模板控件后创建或替换指定稳定业务场景映射。
func (s *SceneService) Save(ctx context.Context, businessType string, request dto.SaveWeComApprovalSceneRequest) (*dto.WeComApprovalSceneResponse, error) {
if s == nil || s.db == nil || s.provider == nil || s.repo == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信审批场景服务未配置")
}
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden)
}
businessType = strings.TrimSpace(businessType)
if !validApprovalBusinessType(businessType) {
return nil, errors.New(errors.CodeInvalidParam, "不支持的审批业务类型")
}
if request.ApplicationID == 0 || strings.TrimSpace(request.TemplateID) == "" || len(request.ControlMapping) == 0 ||
(request.Status != constants.StatusDisabled && request.Status != constants.StatusEnabled) {
return nil, errors.New(errors.CodeInvalidParam)
}
definition, err := s.provider.GetTemplateDetail(ctx, request.ApplicationID, strings.TrimSpace(request.TemplateID))
if err != nil {
s.recordFailure(ctx, businessType, request, constants.AuditResultFailed, errors.CodeInternalError, "校验企业微信审批模板失败")
return nil, err
}
if err := validateSceneMapping(businessType, request.ControlMapping, definition.Controls); err != nil {
s.recordFailure(ctx, businessType, request, constants.AuditResultDenied, errors.CodeInvalidParam, "拒绝保存非法企业微信审批场景映射")
return nil, err
}
if err := validateRefundConditionalMappings(businessType, request.ControlMapping, definition.Controls); err != nil {
s.recordFailure(ctx, businessType, request, constants.AuditResultDenied, errors.CodeInvalidParam, "拒绝保存非法退款条件材料控件映射")
return nil, err
}
request.ControlMapping = normalizeSceneMapping(request.ControlMapping)
mappingJSON, err := sonic.Marshal(request.ControlMapping)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信控件映射失败")
}
snapshotJSON, err := sonic.Marshal(definition)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信模板快照失败")
}
fingerprintBytes := sha256.Sum256(snapshotJSON)
fingerprint := hex.EncodeToString(fingerprintBytes[:])
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
now := s.now().UTC()
var saved *model.WeComApprovalScene
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext(?))", "wecom-scene:"+businessType).Error; err != nil {
return err
}
existing, err := s.repo.FindForUpdate(ctx, tx, businessType)
if err != nil {
return err
}
before := map[string]any{"configured": false, "business_type": businessType}
if existing == nil {
existing = &model.WeComApprovalScene{BusinessType: businessType, CreatedBy: operatorID, CreatedAt: now}
} else {
before = sceneAuditSnapshot(existing)
}
existing.ApplicationID = request.ApplicationID
existing.TemplateID = definition.TemplateID
existing.TemplateName = definition.Name
existing.ControlMapping = mappingJSON
existing.TemplateSnapshot = snapshotJSON
existing.TemplateFingerprint = fingerprint
existing.Status = request.Status
existing.LastVerifiedAt = now
existing.UpdatedBy = operatorID
existing.UpdatedAt = now
if existing.ID == 0 {
if err := s.repo.Create(ctx, tx, existing); err != nil {
return err
}
} else if err := s.repo.Update(ctx, tx, existing); err != nil {
return err
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
resourceID := strings.TrimSpace(existing.BusinessType)
if existing.ID != 0 {
resourceID = fmt.Sprintf("%d", existing.ID)
}
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationWeComApprovalSceneSave, Description: "保存企业微信审批模板映射",
ConfigKey: "wecom.approval_scene." + businessType, BeforeData: before,
AfterData: sceneAuditSnapshot(existing), RequestID: requestID, CorrelationID: requestID,
ResourceID: &resourceID, DisplayName: existing.TemplateName, Identity: sceneAuditIdentity(existing),
}); err != nil {
return err
}
saved = existing
return nil
})
if err != nil {
s.recordFailure(ctx, businessType, request, constants.AuditResultFailed, errors.CodeDatabaseError, "保存企业微信审批场景失败")
var appErr *errors.AppError
if stdErrors.As(err, &appErr) {
return nil, appErr
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存企业微信审批场景失败")
}
return sceneResponse(*saved)
}
// List 分页查询企业微信审批场景当前配置。
func (s *SceneService) List(ctx context.Context, request dto.WeComApprovalSceneListRequest) (*dto.WeComApprovalSceneListResponse, error) {
if s == nil || s.repo == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信审批场景服务未配置")
}
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return nil, errors.New(errors.CodeForbidden)
}
if request.Page <= 0 {
request.Page = constants.DefaultPage
}
if request.PageSize <= 0 {
request.PageSize = constants.DefaultPageSize
}
if request.PageSize > constants.MaxPageSize {
return nil, errors.New(errors.CodeInvalidParam)
}
scenes, total, err := s.repo.List(ctx, request.Page, request.PageSize)
if err != nil {
return nil, err
}
items := make([]dto.WeComApprovalSceneResponse, 0, len(scenes))
for _, scene := range scenes {
item, err := sceneResponse(scene)
if err != nil {
return nil, err
}
items = append(items, *item)
}
return &dto.WeComApprovalSceneListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil
}
func validateSceneMapping(businessType string, mapping []dto.WeComControlMappingItem, controls []TemplateControl) error {
controlByID := make(map[string]TemplateControl, len(controls))
for _, control := range controls {
controlByID[control.ID] = control
}
mappedControls := make(map[string]struct{}, len(mapping))
businessFields := make(map[string]struct{}, len(mapping))
for _, item := range mapping {
item.BusinessField = strings.TrimSpace(item.BusinessField)
item.ControlID = strings.TrimSpace(item.ControlID)
if item.BusinessField == "" || item.ControlID == "" {
return errors.New(errors.CodeInvalidParam, "企业微信控件映射字段不能为空")
}
if !allowedSceneBusinessField(businessType, item.BusinessField) {
return errors.New(errors.CodeInvalidParam, "企业微信控件映射包含当前业务不支持的字段")
}
if _, exists := businessFields[item.BusinessField]; exists {
return errors.New(errors.CodeInvalidParam, "企业微信业务字段映射重复")
}
if _, exists := mappedControls[item.ControlID]; exists {
return errors.New(errors.CodeInvalidParam, "企业微信模板控件不能重复映射")
}
control, exists := controlByID[item.ControlID]
if !exists || !strings.EqualFold(control.Type, strings.TrimSpace(item.ControlType)) {
return errors.New(errors.CodeInvalidParam, "企业微信模板控件 ID 或类型已失效")
}
validOptions := make(map[string]struct{}, len(control.OptionKeys))
for _, key := range control.OptionKeys {
validOptions[key] = struct{}{}
}
for _, key := range item.OptionMapping {
if _, exists := validOptions[key]; !exists {
return errors.New(errors.CodeInvalidParam, "企业微信模板选择项 key 已失效")
}
}
businessFields[item.BusinessField] = struct{}{}
mappedControls[item.ControlID] = struct{}{}
}
for _, control := range controls {
if control.Required {
if _, exists := mappedControls[control.ID]; !exists {
return errors.New(errors.CodeInvalidParam, "企业微信模板存在未映射的必填控件: "+control.Title)
}
}
}
// 本变更新增字段必须配置映射:缺失即明确失败,避免审批材料被静默丢弃后审批人看不到关键依据。
// 既有未映射的可选控件不受影响,保持既有静默跳过行为。
wanted, ok := sceneBusinessFields(businessType)
if !ok {
return errors.New(errors.CodeInvalidParam, "企业微信审批业务类型无效")
}
for _, field := range wanted {
if !field.RequiredMapping {
continue
}
if _, exists := businessFields[field.Code]; !exists {
return errors.New(errors.CodeInvalidParam, "企业微信场景缺少必须映射的业务字段: "+field.Name)
}
}
return nil
}
// RequiredSceneField 描述审批字段映射的固定或条件必需规则。
type RequiredSceneField struct {
Code string
Name string
RequiredMapping bool
}
// RequiredSceneFields 返回场景级固定必需字段。
func RequiredSceneFields(businessType string) []RequiredSceneField {
fields, ok := sceneBusinessFields(businessType)
if !ok {
return nil
}
result := make([]RequiredSceneField, 0, len(fields))
for _, field := range fields {
if field.RequiredMapping {
result = append(result, RequiredSceneField{Code: field.Code, Name: field.Name, RequiredMapping: true})
}
}
return result
}
// RequiredRefundFields 返回退款提交固定与条件必需字段;快照方式编码缺失或未知时由表单构建拒绝。
func RequiredRefundFields(snapshot map[string]any) []RequiredSceneField {
methodCode, ok := snapshot[constants.ApprovalFieldRefundMethodCode].(string)
methodCode = strings.TrimSpace(methodCode)
if !ok || methodCode == "" || (methodCode != constants.RefundMethodOriginalRoute && methodCode != constants.RefundMethodCustomerAccount && methodCode != constants.RefundMethodAssetWallet && methodCode != constants.RefundMethodAgentWallet) {
return []RequiredSceneField{{Code: constants.ApprovalFieldRefundMethodCode, Name: "退款方式编码"}}
}
result := []RequiredSceneField{{Code: constants.ApprovalFieldRefundMethod, Name: "退款方式", RequiredMapping: true}}
if methodCode == constants.RefundMethodCustomerAccount {
result = append(result, RequiredSceneField{Code: constants.ApprovalFieldCustomerAccountInfo, Name: "客户收款信息"}, RequiredSceneField{Code: constants.ApprovalFieldCustomerVoucherKey, Name: "客户收款凭证"})
}
return result
}
// approvalBusinessTypes 返回全部已登记审批业务场景,供只读字段汇总复用。
func approvalBusinessTypes() []string {
return []string{
constants.ApprovalBusinessTypeRefund,
constants.ApprovalBusinessTypeOfflineRecharge,
constants.ApprovalBusinessTypeEmployeeCollection,
constants.ApprovalBusinessTypeAgentDistribution,
constants.ApprovalBusinessTypeWithdrawalQualification,
constants.ApprovalBusinessTypeCommissionWithdrawal,
}
}
// validateRefundConditionalMappings 校验退款条件字段使用正确的企微控件类型。
func validateRefundConditionalMappings(businessType string, mapping []dto.WeComControlMappingItem, controls []TemplateControl) error {
if businessType != constants.ApprovalBusinessTypeRefund {
return nil
}
types := make(map[string]string, len(controls))
for _, control := range controls {
types[control.ID] = strings.ToLower(strings.TrimSpace(control.Type))
}
for _, item := range mapping {
switch item.BusinessField {
case constants.ApprovalFieldCustomerAccountInfo:
if types[item.ControlID] != "textarea" {
return errors.New(errors.CodeInvalidParam, "客户收款信息必须映射到多行文本控件")
}
case constants.ApprovalFieldCustomerVoucherKey:
if types[item.ControlID] != "file" {
return errors.New(errors.CodeInvalidParam, "客户收款凭证必须映射到附件控件")
}
}
}
return nil
}
func allowedSceneBusinessField(businessType, businessField string) bool {
fields, ok := sceneBusinessFields(businessType)
if !ok {
return false
}
for _, field := range fields {
if field.Code == businessField {
return true
}
}
return false
}
func sceneBusinessFields(businessType string) ([]dto.WeComBusinessFieldResponse, bool) {
switch businessType {
case constants.ApprovalBusinessTypeOfflineRecharge:
return []dto.WeComBusinessFieldResponse{
{Code: constants.ApprovalFieldRechargeNo, Name: "充值单号", ValueType: constants.ApprovalFieldValueTypeString, Description: "员工线下代充值单号"},
{Code: constants.ApprovalFieldShopID, Name: "目标店铺 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本次充值目标店铺的系统 ID"},
{Code: constants.ApprovalFieldShopName, Name: "目标店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次充值目标店铺名称快照"},
{Code: constants.ApprovalFieldAmount, Name: "充值金额", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的充值金额"},
{Code: constants.ApprovalFieldAmountCent, Name: "充值金额(分)", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "以分为单位的充值金额整数"},
{Code: constants.ApprovalFieldPaymentVoucherKey, Name: "付款凭证", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "提交时上传到企微文件控件的付款凭证列表"},
{Code: constants.ApprovalFieldRemark, Name: "备注", ValueType: constants.ApprovalFieldValueTypeString, Description: "员工提交线下代充值时填写的备注"},
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
{Code: constants.ApprovalFieldOfflinePaymentMethod, Name: "线下收款方式", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次充值使用的线下收款方式名称快照"},
{Code: constants.ApprovalFieldOfflinePaymentMethodCode, Name: "线下收款方式编码", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次充值使用的线下收款方式稳定编码快照"},
{Code: constants.ApprovalFieldExternalTransactionNo, Name: "交易流水号", ValueType: constants.ApprovalFieldValueTypeString, Description: "人工确认的第三方交易流水号,用于审批人核验;与在线渠道交易号无关"},
{Code: constants.ApprovalFieldOtherVoucherKey, Name: "其他凭证", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "提交时上传到企微文件控件的其他凭证列表"},
}, true
case constants.ApprovalBusinessTypeRefund:
return []dto.WeComBusinessFieldResponse{
{Code: constants.ApprovalFieldRefundNo, Name: "退款单号", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次退款申请单号"},
{Code: constants.ApprovalFieldOrderID, Name: "订单 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "退款关联订单的系统 ID"},
{Code: constants.ApprovalFieldOrderNo, Name: "订单号", ValueType: constants.ApprovalFieldValueTypeString, Description: "退款关联订单号"},
{Code: constants.ApprovalFieldAssetIdentifier, Name: "资产标识", ValueType: constants.ApprovalFieldValueTypeString, Description: "退款关联卡或设备的业务标识"},
{Code: constants.ApprovalFieldAssetType, Name: "资产类型", ValueType: constants.ApprovalFieldValueTypeString, Description: "退款关联资产类型编码"},
{Code: constants.ApprovalFieldActualReceivedAmount, Name: "订单实收金额", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的订单实收金额"},
{Code: constants.ApprovalFieldRequestedRefundAmount, Name: "申请退款金额", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的本次申请退款金额"},
{Code: constants.ApprovalFieldRefundVoucherKey, Name: "退款凭证", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "提交时上传到企微文件控件的退款凭证列表"},
{Code: constants.ApprovalFieldRefundReason, Name: "退款原因", ValueType: constants.ApprovalFieldValueTypeString, Description: "业务提交人填写的退款原因"},
{Code: constants.ApprovalFieldPackageUsageID, Name: "套餐使用记录 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "退款关联套餐使用记录 ID无关联记录时可能为空"},
{Code: constants.ApprovalFieldRefundAssetType, Name: "资产类型", ValueType: constants.ApprovalFieldValueTypeString, Description: "退款关联资产类型card 单卡、device 设备;沿用退款申请的下单快照推导", RequiredMapping: true},
{Code: constants.ApprovalFieldRefundDeviceType, Name: "设备类型", ValueType: constants.ApprovalFieldValueTypeString, Description: "退款申请关联设备的类型;资产不是设备或无关联设备时为空字符串", RequiredMapping: true},
{Code: constants.ApprovalFieldRefundDeviceModel, Name: "设备型号", ValueType: constants.ApprovalFieldValueTypeString, Description: "退款申请关联设备的型号;资产不是设备或无关联设备时为空字符串", RequiredMapping: true},
{Code: constants.ApprovalFieldRefundPackageUsedMB, Name: "当前退款套餐已用量MB", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "当前退款套餐已用量MB真实流量解析不到套餐时为 0", RequiredMapping: true},
{Code: constants.ApprovalFieldRefundPackageTotalMB, Name: "当前退款套餐总量MB", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "当前退款套餐总量MB真实流量解析不到套餐时为 0", RequiredMapping: true},
{Code: constants.ApprovalFieldRefundChannelTradeNo, Name: "原支付渠道交易流水号", ValueType: constants.ApprovalFieldValueTypeString, Description: "创建申请时冻结的原成功支付记录渠道交易流水号;线下订单为空字符串", RequiredMapping: true},
{Code: constants.ApprovalFieldRefundApplicantRemark, Name: "申请人备注", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次提交填写的申请人备注快照;未填写时为空字符串。与审批备注不是同一字段", RequiredMapping: true},
{Code: constants.ApprovalFieldRefundMethodCode, Name: "退款方式编码", ValueType: constants.ApprovalFieldValueTypeString, Description: "仅用于按本次审批快照计算条件材料,不要求配置模板控件"},
{Code: constants.ApprovalFieldRefundMethod, Name: "退款方式", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次审批尝试冻结的退款方式中文名称", RequiredMapping: true},
{Code: constants.ApprovalFieldCustomerAccountInfo, Name: "客户收款信息", ValueType: constants.ApprovalFieldValueTypeString, Description: "客户收款信息退款的多行收款文本;其它退款方式为空"},
{Code: constants.ApprovalFieldCustomerVoucherKey, Name: "客户收款凭证", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "客户收款信息退款的凭证列表;其它退款方式为空"},
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
}, true
case constants.ApprovalBusinessTypeEmployeeCollection:
return []dto.WeComBusinessFieldResponse{
{Code: constants.ApprovalFieldCollectionApplicationID, Name: "核销申请 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "员工代收款核销申请的系统 ID"},
{Code: constants.ApprovalFieldCollectionPaymentMethod, Name: "线下收款方式", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次外部付款使用的线下收款方式名称快照"},
{Code: constants.ApprovalFieldCollectionPaidAmount, Name: "付款金额", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的人工确认付款金额"},
{Code: constants.ApprovalFieldCollectionPaidAmountCent, Name: "付款金额(分)", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "以分为单位的人工确认付款金额整数"},
{Code: constants.ApprovalFieldCollectionPayerName, Name: "付款方", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次外部付款的付款方名称"},
{Code: constants.ApprovalFieldCollectionPaidAt, Name: "付款时间", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次外部付款时间RFC3339 格式"},
{Code: constants.ApprovalFieldCollectionExternalTransactionNo, Name: "外部交易流水号", ValueType: constants.ApprovalFieldValueTypeString, Description: "人工确认的第三方交易流水号,用于审批人核验"},
{Code: constants.ApprovalFieldPaymentVoucherKey, Name: "付款凭证", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "提交时上传到企微文件控件的付款凭证列表"},
{Code: constants.ApprovalFieldRemark, Name: "备注", ValueType: constants.ApprovalFieldValueTypeString, Description: "申请人填写的核销备注"},
{Code: constants.ApprovalFieldCollectionBillCount, Name: "分摊账单数量", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本次核销分摊的账单数量"},
{Code: constants.ApprovalFieldCollectionBillSummary, Name: "账单分摊摘要", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次各账单应收金额与分摊金额摘要"},
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
}, true
case constants.ApprovalBusinessTypeAgentDistribution:
return []dto.WeComBusinessFieldResponse{
{Code: constants.ApprovalFieldDistributionCode, Name: "分销码", ValueType: constants.ApprovalFieldValueTypeString, Description: "注册使用的上级店铺分销码脱敏值,仅用于审批人核对来源,不代表新建店铺的码"},
{Code: constants.ApprovalFieldDistributionParentShopID, Name: "上级店铺 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "分销码所属上级店铺的系统 ID"},
{Code: constants.ApprovalFieldDistributionParentShopName, Name: "上级店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "分销码所属上级店铺名称快照"},
{Code: constants.ApprovalFieldDistributionShopName, Name: "申请店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "扫码注册申请的店铺名称快照"},
{Code: constants.ApprovalFieldDistributionShopCode, Name: "申请店铺编号", ValueType: constants.ApprovalFieldValueTypeString, Description: "扫码注册申请的店铺编号快照,通过时按既有唯一约束校验"},
{Code: constants.ApprovalFieldDistributionUsername, Name: "代理账号用户名", ValueType: constants.ApprovalFieldValueTypeString, Description: "扫码注册申请的代理主账号用户名快照"},
{Code: constants.ApprovalFieldDistributionPhoneMasked, Name: "注册手机号", ValueType: constants.ApprovalFieldValueTypeString, Description: "脱敏后的注册手机号,禁止写入完整手机号"},
{Code: constants.ApprovalFieldDistributionContactName, Name: "联系人姓名", ValueType: constants.ApprovalFieldValueTypeString, Description: "扫码注册填写的联系人姓名"},
{Code: constants.ApprovalFieldDistributionRegion, Name: "注册地址摘要", ValueType: constants.ApprovalFieldValueTypeString, Description: "省市区与详细地址拼接的注册地址摘要"},
{Code: constants.ApprovalFieldDistributionBusinessOwner, Name: "业务员", ValueType: constants.ApprovalFieldValueTypeString, Description: "上级代理店铺当前启用业务员的名称快照,用于确认将同步给新代理的初始业务员;上级无业务员时为空字符串", RequiredMapping: true},
{Code: constants.ApprovalFieldDistributionBusinessOwnerAbsence, Name: "无业务员标记", ValueType: constants.ApprovalFieldValueTypeString, Description: "上级店铺当前无业务员时提交「无」有业务员时为空字符串MUST NOT 用提交人或上级主账号填充", RequiredMapping: true},
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
}, true
case constants.ApprovalBusinessTypeWithdrawalQualification:
return []dto.WeComBusinessFieldResponse{
{Code: constants.ApprovalFieldQualificationShopID, Name: "店铺 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "提现资料资格所属代理店铺的系统 ID"},
{Code: constants.ApprovalFieldQualificationShopName, Name: "店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "提现资料资格所属代理店铺名称快照"},
{Code: constants.ApprovalFieldQualificationSubjectType, Name: "签约主体类型", ValueType: constants.ApprovalFieldValueTypeString, Description: "签约主体类型中文名:企业或个人"},
{Code: constants.ApprovalFieldQualificationSubjectCodeMasked, Name: "签约主体代码", ValueType: constants.ApprovalFieldValueTypeString, Description: "脱敏后的统一社会信用代码或身份证号,禁止写入完整证件号"},
{Code: constants.ApprovalFieldQualificationLegalPersonMasked, Name: "法人身份证号", ValueType: constants.ApprovalFieldValueTypeString, Description: "脱敏后的法人身份证号,禁止写入完整证件号"},
{Code: constants.ApprovalFieldQualificationContractKey, Name: "合同附件", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "合同对象存储 Key 列表(单个对象)"},
{Code: constants.ApprovalFieldQualificationIDCardFrontKey, Name: "法人身份证正面", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "法人身份证正面对象存储 Key 列表(单个对象)"},
{Code: constants.ApprovalFieldQualificationIDCardBackKey, Name: "法人身份证反面", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "法人身份证反面对象存储 Key 列表(单个对象)"},
{Code: constants.ApprovalFieldQualificationBusinessLicenseKey, Name: "营业执照", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "营业执照对象存储 Key 列表(单个对象,可选)"},
{Code: constants.ApprovalFieldQualificationShopFrontKey, Name: "门头照", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "门头照对象存储 Key 列表(单个对象,可选)"},
{Code: constants.ApprovalFieldQualificationInvoiceKey, Name: "发票", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "发票对象存储 Key 列表(单个对象,仅企业可选)"},
{Code: constants.ApprovalFieldQualificationInvoiceTitle, Name: "发票抬头", ValueType: constants.ApprovalFieldValueTypeString, Description: "发票抬头,仅企业填写且必须与合同主体一致"},
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
}, true
case constants.ApprovalBusinessTypeCommissionWithdrawal:
return []dto.WeComBusinessFieldResponse{
{Code: constants.ApprovalFieldWithdrawalNo, Name: "提现单号", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次提现申请单号"},
{Code: constants.ApprovalFieldWithdrawalAttemptNo, Name: "提交次序", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本次为第几次提交,重提时递增"},
{Code: constants.ApprovalFieldWithdrawalShopID, Name: "店铺 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "发起提现的代理店铺系统 ID"},
{Code: constants.ApprovalFieldWithdrawalShopName, Name: "店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "发起提现的代理店铺名称快照"},
{Code: constants.ApprovalFieldWithdrawalAmount, Name: "提现金额", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的提现金额"},
{Code: constants.ApprovalFieldWithdrawalAmountCent, Name: "提现金额(分)", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "以分为单位的提现金额整数"},
{Code: constants.ApprovalFieldWithdrawalFee, Name: "手续费", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的本次手续费"},
{Code: constants.ApprovalFieldWithdrawalActualAmount, Name: "实际到账金额", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的实际到账金额"},
{Code: constants.ApprovalFieldWithdrawalMethod, Name: "收款方式", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次收款方式名称快照"},
{Code: constants.ApprovalFieldWithdrawalAccountName, Name: "收款人姓名", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次收款人姓名"},
{Code: constants.ApprovalFieldWithdrawalAccountNumber, Name: "收款账号", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次收款账号,供审批人核验打款"},
{Code: constants.ApprovalFieldWithdrawalInvoiceKey, Name: "申请级发票", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "本次申请级发票对象存储 Key 列表,无发票时为空数组"},
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
}, true
default:
return nil, false
}
}
func normalizeSceneMapping(mapping []dto.WeComControlMappingItem) []dto.WeComControlMappingItem {
result := make([]dto.WeComControlMappingItem, 0, len(mapping))
for _, item := range mapping {
item.BusinessField = strings.TrimSpace(item.BusinessField)
item.ControlID = strings.TrimSpace(item.ControlID)
item.ControlType = strings.TrimSpace(item.ControlType)
if item.OptionMapping == nil {
item.OptionMapping = map[string]string{}
}
result = append(result, item)
}
return result
}
func validApprovalBusinessType(businessType string) bool {
for _, candidate := range approvalBusinessTypes() {
if candidate == businessType {
return true
}
}
return false
}
func approvalBusinessTypeName(businessType string) string {
switch businessType {
case constants.ApprovalBusinessTypeRefund:
return "退款审批"
case constants.ApprovalBusinessTypeOfflineRecharge:
return "员工线下代充值审批"
case constants.ApprovalBusinessTypeEmployeeCollection:
return "员工代收款核销审批"
case constants.ApprovalBusinessTypeAgentDistribution:
return "代理扫码分销注册审批"
case constants.ApprovalBusinessTypeWithdrawalQualification:
return "提现资料资格审批"
case constants.ApprovalBusinessTypeCommissionWithdrawal:
return "佣金提现终审"
default:
return "未知审批业务类型"
}
}
func sceneAuditSnapshot(scene *model.WeComApprovalScene) map[string]any {
return map[string]any{
"business_type": scene.BusinessType, "application_id": scene.ApplicationID,
"template_id": scene.TemplateID, "status": scene.Status, "last_verified_at": scene.LastVerifiedAt,
"template_fingerprint": scene.TemplateFingerprint,
}
}
func sceneAuditIdentity(scene *model.WeComApprovalScene) map[string]any {
if scene == nil {
return nil
}
return map[string]any{
"id": scene.ID, "business_type": scene.BusinessType, "application_id": scene.ApplicationID,
"template_id": scene.TemplateID, "template_name": scene.TemplateName, "status": scene.Status,
}
}
func (s *SceneService) recordFailure(ctx context.Context, businessType string, request dto.SaveWeComApprovalSceneRequest, result string, code int, description string) {
recordConfigFailure(ctx, s.db, s.audit, systemconfigapp.ChangeAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: constants.AuditOperationWeComApprovalSceneSave,
Description: description, ConfigKey: "wecom.approval_scene." + businessType,
DisplayName: businessType, Identity: map[string]any{
"business_type": businessType, "application_id": request.ApplicationID,
"template_id": strings.TrimSpace(request.TemplateID), "status": request.Status,
},
Result: result, ErrorCode: fmt.Sprintf("%d", code), ErrorSummary: description,
})
}
func sceneResponse(scene model.WeComApprovalScene) (*dto.WeComApprovalSceneResponse, error) {
var mapping []dto.WeComControlMappingItem
if err := sonic.Unmarshal(scene.ControlMapping, &mapping); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "解析企业微信控件映射失败")
}
return &dto.WeComApprovalSceneResponse{
ID: scene.ID, BusinessType: scene.BusinessType, BusinessTypeName: approvalBusinessTypeName(scene.BusinessType),
ApplicationID: scene.ApplicationID, TemplateID: scene.TemplateID, TemplateName: scene.TemplateName,
TemplateFingerprint: scene.TemplateFingerprint,
ControlMapping: mapping, Status: scene.Status, StatusName: constants.GetStatusName(scene.Status),
LastVerifiedAt: scene.LastVerifiedAt, UpdatedAt: scene.UpdatedAt,
}, nil
}