package wecom import ( "context" "crypto/sha256" "encoding/hex" stdErrors "errors" "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} } // 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 { 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 { return nil, err } if err := validateSceneMapping(businessType, request.ControlMapping, definition.Controls); err != nil { 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 } if s.audit != nil { requestID := "" if value := middleware.GetRequestIDFromContext(ctx); value != nil { requestID = *value } if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{ OperatorID: operatorID, OperationType: "wecom_approval_scene_save", Description: "保存企业微信审批模板映射", ConfigKey: "wecom.approval_scene." + businessType, BeforeData: before, AfterData: sceneAuditSnapshot(existing), RequestID: requestID, CorrelationID: requestID, }); err != nil { return err } } saved = existing return nil }) if err != nil { 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 { allowed := map[string]struct{}{} switch businessType { case constants.ApprovalBusinessTypeOfflineRecharge: allowed = map[string]struct{}{ constants.ApprovalFieldRechargeNo: {}, constants.ApprovalFieldShopID: {}, constants.ApprovalFieldShopName: {}, constants.ApprovalFieldAmount: {}, constants.ApprovalFieldAmountCent: {}, constants.ApprovalFieldPaymentVoucherKey: {}, constants.ApprovalFieldRemark: {}, constants.ApprovalFieldSubmitterID: {}, constants.ApprovalFieldSubmitterName: {}, } case constants.ApprovalBusinessTypeRefund: allowed = map[string]struct{}{ constants.ApprovalFieldRefundNo: {}, constants.ApprovalFieldOrderID: {}, constants.ApprovalFieldOrderNo: {}, constants.ApprovalFieldAssetIdentifier: {}, constants.ApprovalFieldAssetType: {}, constants.ApprovalFieldActualReceivedAmount: {}, constants.ApprovalFieldRequestedRefundAmount: {}, constants.ApprovalFieldRefundVoucherKey: {}, constants.ApprovalFieldRefundReason: {}, constants.ApprovalFieldPackageUsageID: {}, constants.ApprovalFieldSubmitterID: {}, constants.ApprovalFieldSubmitterName: {}, } default: return false } _, exists := allowed[businessField] return exists } 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 { return businessType == constants.ApprovalBusinessTypeRefund || businessType == constants.ApprovalBusinessTypeOfflineRecharge } 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 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, "解析企业微信控件映射失败") } name := "员工线下代充值审批" if scene.BusinessType == constants.ApprovalBusinessTypeRefund { name = "退款审批" } return &dto.WeComApprovalSceneResponse{ ID: scene.ID, BusinessType: scene.BusinessType, BusinessTypeName: name, 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 }