Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕 - 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败 - 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影 - 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序 - 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列 - 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名 - 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
207 lines
9.2 KiB
Go
207 lines
9.2 KiB
Go
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
|
||
}
|