package wecom import ( "context" "fmt" "strings" "github.com/bytedance/sonic" wecomapp "github.com/break/junhong_cmp_fiber/internal/application/wecom" "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" ) type approvalAttachmentPort interface { Upload(ctx context.Context, applicationID, instanceID uint, reference ApprovalFileReference) (string, error) } type approvalFormBuildResult struct { Contents []map[string]any } type attachmentPreparationError struct { err error } func (e *attachmentPreparationError) Error() string { return e.err.Error() } func (e *attachmentPreparationError) Unwrap() error { return e.err } // buildApprovalForm 按后台已校验映射将渠道无关业务快照组装为企微控件值。 func buildApprovalForm(ctx context.Context, businessType string, applicationID, instanceID uint, mappingJSON, templateJSON, snapshotJSON []byte, attachments approvalAttachmentPort) (approvalFormBuildResult, error) { var mappings []dto.WeComControlMappingItem if err := sonic.Unmarshal(mappingJSON, &mappings); err != nil || len(mappings) == 0 { return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "企业微信审批控件映射无效") } var snapshot map[string]any if err := sonic.Unmarshal(snapshotJSON, &snapshot); err != nil || snapshot == nil { return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照无效") } var template wecomapp.TemplateDefinition if err := sonic.Unmarshal(templateJSON, &template); err != nil { return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "企业微信审批模板快照无效") } requiredControls := make(map[string]bool, len(template.Controls)) for _, control := range template.Controls { requiredControls[control.ID] = control.Required } // 本变更新增字段必须配置控件映射:场景里没有该字段的映射条目时,下面的遍历永远不会看到它, // 该字段会被静默丢弃。因此在组装前先针对本次提交使用的映射逐一确认必须字段有条目, // 缺失即明确失败并指出缺哪个字段/控件;本判定与场景映射校验共用同一份 RequiredSceneFields。 // 既有未标注必须映射的可选字段不在其中,缺失映射时保持既有静默跳过行为。 mappingFields := make(map[string]struct{}, len(mappings)) for _, item := range mappings { mappingFields[strings.TrimSpace(item.BusinessField)] = struct{}{} } for _, field := range wecomapp.RequiredSceneFields(businessType) { if _, exists := mappingFields[field.Code]; !exists { return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "企业微信审批场景缺少必须配置的控件映射: "+field.Name+"(业务字段 "+field.Code+"),请先配置该控件映射后再提交") } } // 本变更新增字段缺失快照值时同样明确失败:映射已配置却拿不到值,静默跳过会让审批材料 // 与本地事实不一致。该校验必须在循环之前完成——循环内的既有文件类控件会经 // attachments.Upload 触达外部系统,若把校验留在循环内,失败可能晚于外部调用(ENG-TX-001 // 要求事务内不得持有不可回滚的长外部 I/O,同理失败必须发生在任何外呼之前)。 // 既有未映射(或映射到既有可选字段)的控件保持既有静默跳过行为。 requiredFields := make(map[string]string) for _, field := range wecomapp.RequiredSceneFields(businessType) { requiredFields[field.Code] = field.Name if _, exists := lookupSnapshotValue(snapshot, field.Code); !exists { return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少必须提交的字段: "+field.Name+"(业务字段 "+field.Code+"),请确认已配置对应控件与业务字段映射") } } contents := make([]map[string]any, 0, len(mappings)) attachmentCount := 0 for _, mapping := range mappings { raw, exists := lookupSnapshotValue(snapshot, mapping.BusinessField) if !exists || raw == nil { if requiredControls[mapping.ControlID] { return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少模板必填字段: "+mapping.BusinessField) } if name, required := requiredFields[strings.TrimSpace(mapping.BusinessField)]; required { return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少必须提交的字段: "+name+",请确认已配置对应控件与业务字段映射") } continue } value, count, err := buildControlValue(ctx, applicationID, instanceID, mapping, raw, attachments) if err != nil { return approvalFormBuildResult{}, err } attachmentCount += count if attachmentCount > constants.WeComApprovalMaxAttachmentCount { return approvalFormBuildResult{}, errors.New(errors.CodeInvalidParam, "单张企业微信审批单最多支持 6 个附件") } contents = append(contents, map[string]any{ "control": mapping.ControlType, "id": mapping.ControlID, "value": value, }) } return approvalFormBuildResult{Contents: contents}, nil } // lookupSnapshotValue 支持用点分路径读取嵌套业务快照,避免模板映射绑定具体 DTO。 func lookupSnapshotValue(snapshot map[string]any, path string) (any, bool) { segments := strings.Split(strings.TrimSpace(path), ".") var current any = snapshot for _, segment := range segments { object, ok := current.(map[string]any) if !ok { return nil, false } current, ok = object[segment] if !ok { return nil, false } } return current, true } // buildControlValue 按企微控件类型生成官方要求的 value 结构,复杂结构允许业务快照直接传入对象。 func buildControlValue(ctx context.Context, applicationID, instanceID uint, mapping dto.WeComControlMappingItem, raw any, attachments approvalAttachmentPort) (map[string]any, int, error) { if object, ok := raw.(map[string]any); ok && !strings.EqualFold(mapping.ControlType, "File") { return object, 0, nil } switch strings.ToLower(strings.TrimSpace(mapping.ControlType)) { case "text", "textarea": return map[string]any{"text": fmt.Sprint(raw)}, 0, nil case "number": return map[string]any{"new_number": fmt.Sprint(raw)}, 0, nil case "money": return map[string]any{"new_money": fmt.Sprint(raw)}, 0, nil case "date": return map[string]any{"date": map[string]any{"type": "day", "s_timestamp": fmt.Sprint(raw)}}, 0, nil case "selector": businessValue := fmt.Sprint(raw) key := mapping.OptionMapping[businessValue] if key == "" { return nil, 0, errors.New(errors.CodeInvalidStatus, "审批业务枚举值没有企微选项映射: "+mapping.BusinessField) } return map[string]any{"selector": map[string]any{"type": "single", "options": []map[string]string{{"key": key}}}}, 0, nil case "contact": return map[string]any{"members": []map[string]string{{"userid": fmt.Sprint(raw)}}}, 0, nil case "department": return map[string]any{"departments": []map[string]string{{"openapi_id": fmt.Sprint(raw)}}}, 0, nil case "file": if attachments == nil { return nil, 0, errors.New(errors.CodeServiceUnavailable, "企业微信审批附件上传服务未配置") } references, err := parseApprovalFileReferences(raw) if err != nil { return nil, 0, err } files := make([]map[string]string, 0, len(references)) for _, reference := range references { mediaID, err := attachments.Upload(ctx, applicationID, instanceID, reference) if err != nil { return nil, 0, &attachmentPreparationError{err: err} } files = append(files, map[string]string{"file_id": mediaID}) } return map[string]any{"files": files}, len(files), nil default: return nil, 0, errors.New(errors.CodeInvalidStatus, "暂不支持的企业微信审批控件类型: "+mapping.ControlType) } } // parseApprovalFileReferences 兼容现有退款和线下充值的字符串 Key 列表,以及带文件名的结构化引用。 func parseApprovalFileReferences(raw any) ([]ApprovalFileReference, error) { if storageKey, ok := raw.(string); ok && strings.TrimSpace(storageKey) != "" { return []ApprovalFileReference{{StorageKey: strings.TrimSpace(storageKey)}}, nil } bytes, err := sonic.Marshal(raw) if err != nil { return nil, errors.Wrap(errors.CodeInvalidParam, err, "编码审批附件引用失败") } var references []ApprovalFileReference if err := sonic.Unmarshal(bytes, &references); err != nil { var storageKeys []string if keyErr := sonic.Unmarshal(bytes, &storageKeys); keyErr == nil && len(storageKeys) > 0 { references = make([]ApprovalFileReference, 0, len(storageKeys)) for _, storageKey := range storageKeys { if strings.TrimSpace(storageKey) != "" { references = append(references, ApprovalFileReference{StorageKey: strings.TrimSpace(storageKey)}) } } if len(references) > 0 { return references, nil } } var single ApprovalFileReference if singleErr := sonic.Unmarshal(bytes, &single); singleErr != nil { return nil, errors.New(errors.CodeInvalidParam, "审批附件必须提供对象存储引用") } references = []ApprovalFileReference{single} } if len(references) == 0 { return nil, errors.New(errors.CodeInvalidParam, "审批附件不能为空") } return references, nil }