Files
junhong_cmp_fiber/internal/infrastructure/wecom/approval_form.go
邹进 9e45097aa2
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m20s
fix: 富友订单支持退款,0 元退款不强制客户收款材料映射
- decideRefundMethods 将 fuiou 与 wechat、alipay 按线上支付处理,修复富友订单报"该订单的支付方式不支持退款"
- 客户收款信息退款仅在金额大于 0 时强制映射并提交客户收款信息与凭证;0 元退款允许客户收款信息为空

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:00:36 +09:00

252 lines
11 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"
"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
}
mappingFields := make(map[string]dto.WeComControlMappingItem, len(mappings))
for _, item := range mappings {
mappingFields[strings.TrimSpace(item.BusinessField)] = item
}
requiredFields := wecomapp.RequiredSceneFields(businessType)
if businessType == constants.ApprovalBusinessTypeRefund {
requiredFields = append(requiredFields, wecomapp.RequiredRefundFields(snapshot)...)
}
for _, field := range requiredFields {
mapping, exists := mappingFields[field.Code]
if !exists {
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "企业微信审批场景缺少必须配置的控件映射: "+field.Name+"(业务字段 "+field.Code+")")
}
if _, exists := lookupSnapshotValue(snapshot, field.Code); !exists {
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少必须提交的字段: "+field.Name)
}
if businessType == constants.ApprovalBusinessTypeRefund && field.Code == constants.ApprovalFieldRefundMethodCode {
methodCode, _ := snapshot[field.Code].(string)
methodCode = strings.TrimSpace(methodCode)
if methodCode == "" || (methodCode != constants.RefundMethodOriginalRoute && methodCode != constants.RefundMethodCustomerAccount && methodCode != constants.RefundMethodAssetWallet && methodCode != constants.RefundMethodAgentWallet) {
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少有效退款方式编码")
}
}
if field.Code == constants.ApprovalFieldCustomerAccountInfo {
value, _ := snapshot[field.Code].(string)
if strings.TrimSpace(value) == "" {
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少非空客户收款信息")
}
if !strings.EqualFold(mapping.ControlType, "textarea") {
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "客户收款信息必须映射到多行文本控件")
}
}
if field.Code == constants.ApprovalFieldCustomerVoucherKey {
refs, err := parseApprovalFileReferences(snapshot[field.Code])
if err != nil || len(refs) == 0 {
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少客户收款凭证")
}
if !strings.EqualFold(mapping.ControlType, "file") {
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "客户收款凭证必须映射到附件控件")
}
}
}
fieldNames := make(map[string]string, len(requiredFields))
for _, field := range requiredFields {
fieldNames[field.Code] = field.Name
}
contents := make([]map[string]any, 0, len(mappings))
attachmentCount := 0
for _, mapping := range mappings {
raw, exists := lookupSnapshotValue(snapshot, mapping.BusinessField)
if !exists || raw == nil || isEmptyFileValue(mapping, raw) || isEmptyCustomerAccountInfo(mapping, raw) {
if requiredControls[mapping.ControlID] {
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少模板必填字段: "+mapping.BusinessField)
}
if name, required := fieldNames[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
}
// isEmptyFileValue 判断附件控件的快照值是否为空列表;空列表按未填写处理,由模板必填与业务必填规则决定是否拦截。
func isEmptyFileValue(mapping dto.WeComControlMappingItem, raw any) bool {
if !strings.EqualFold(strings.TrimSpace(mapping.ControlType), "file") {
return false
}
switch value := raw.(type) {
case []any:
return len(value) == 0
case []string:
return len(value) == 0
case string:
return strings.TrimSpace(value) == ""
default:
return false
}
}
// isEmptyCustomerAccountInfo 判断客户收款信息是否为空文本;只有 0 元退款可能为空,按未填写处理,由模板必填规则决定是否拦截。
func isEmptyCustomerAccountInfo(mapping dto.WeComControlMappingItem, raw any) bool {
if strings.TrimSpace(mapping.BusinessField) != constants.ApprovalFieldCustomerAccountInfo {
return false
}
value, ok := raw.(string)
return ok && strings.TrimSpace(value) == ""
}
// 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
}