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 } 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) } } } 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.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 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 { switch businessType { case constants.ApprovalBusinessTypeRefund, constants.ApprovalBusinessTypeOfflineRecharge, constants.ApprovalBusinessTypeEmployeeCollection: return true default: return false } } func approvalBusinessTypeName(businessType string) string { switch businessType { case constants.ApprovalBusinessTypeRefund: return "退款审批" case constants.ApprovalBusinessTypeOfflineRecharge: return "员工线下代充值审批" case constants.ApprovalBusinessTypeEmployeeCollection: 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 }