Files
junhong_cmp_fiber/internal/infrastructure/wecom/approval_attachment_uploader.go
break b3499adfca 固化七月迭代审计治理进展以隔离线上热修
Constraint: 切换 main 前必须保存当前七月分支全部项目进展,套餐生效提案仅属于 Iteration/7-11。

Rejected: 将七月套餐修复直接移植到 main | 两个分支的可靠投递架构不同。

Confidence: medium

Scope-risk: broad

Directive: 不得将本提交整体 cherry-pick 到 main;main 套餐热修必须基于其纯 Asynq 代码独立实施。

Tested: git diff --check;openspec validate fix-package-activation-starvation --strict。

Not-tested: 按用户要求未运行自动化测试;go build ./... 因当前审计改造中的 Enterprise 模型字面量和 role.recordFailure 参数类型错误未通过。
2026-08-03 09:47:22 +08:00

170 lines
7.3 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 (
"bytes"
"context"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/storage"
)
// ApprovalFileReference 是业务快照中可上传到企微的对象存储引用。
type ApprovalFileReference struct {
StorageKey string `json:"storage_key"`
FileName string `json:"file_name"`
}
// ApprovalAttachmentUploader 将对象存储文件上传为企微临时素材。
type ApprovalAttachmentUploader struct {
tokens DirectoryTokenProvider
integration TokenIntegrationLog
storage *storage.Service
httpClient *http.Client
baseURL string
now func() time.Time
}
// NewApprovalAttachmentUploader 创建企业微信审批附件上传客户端。
func NewApprovalAttachmentUploader(tokens DirectoryTokenProvider, integration TokenIntegrationLog, storageService *storage.Service, baseURL string, timeout time.Duration) *ApprovalAttachmentUploader {
if timeout <= 0 {
timeout = constants.WeComDefaultHTTPTimeout
}
return &ApprovalAttachmentUploader{
tokens: tokens, integration: integration, storage: storageService,
httpClient: &http.Client{Timeout: timeout}, baseURL: strings.TrimRight(baseURL, "/"), now: time.Now,
}
}
// Upload 下载对象存储文件并上传为企微临时素材Integration Log 不记录文件正文或 media_id。
func (u *ApprovalAttachmentUploader) Upload(ctx context.Context, applicationID, instanceID uint, reference ApprovalFileReference) (string, error) {
if u == nil || u.tokens == nil || u.integration == nil || u.storage == nil || u.httpClient == nil {
return "", errors.New(errors.CodeServiceUnavailable, "企业微信审批附件上传服务未配置")
}
reference.StorageKey = strings.TrimSpace(reference.StorageKey)
if reference.StorageKey == "" {
return "", errors.New(errors.CodeInvalidParam, "审批附件对象存储 Key 不能为空")
}
localPath, cleanup, err := u.storage.DownloadToTemp(ctx, reference.StorageKey)
if err != nil {
return "", errors.Wrap(errors.CodeServiceUnavailable, err, "下载审批附件失败")
}
defer cleanup()
file, err := os.Open(localPath)
if err != nil {
return "", errors.Wrap(errors.CodeServiceUnavailable, err, "打开审批附件失败")
}
defer file.Close()
info, err := file.Stat()
if err != nil || info.Size() <= 5 || info.Size() > constants.WeComApprovalMaxAttachmentBytes {
return "", errors.New(errors.CodeInvalidParam, "审批附件大小必须大于 5 字节且不超过 20MB")
}
fileName := filepath.Base(strings.TrimSpace(reference.FileName))
if fileName == "." || fileName == "" {
fileName = filepath.Base(reference.StorageKey)
}
token, err := u.tokens.GetAccessToken(ctx, applicationID)
if err != nil {
return "", err
}
endpoint, err := url.Parse(u.baseURL + "/cgi-bin/media/upload")
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
return "", errors.New(errors.CodeWeComCredentialInvalid, "企业微信 API 地址配置无效")
}
query := endpoint.Query()
query.Set("access_token", token)
query.Set("type", "file")
endpoint.RawQuery = query.Encode()
request, err := newAttachmentUploadRequest(ctx, endpoint.String(), fileName, file)
if err != nil {
return "", err
}
resourceID := strconv.FormatUint(uint64(instanceID), 10)
integrationID, triggerSeries, correlationID := singleIntegrationLinkage(nil)
attempt, err := u.integration.Start(ctx, integrationlog.Attempt{
IntegrationID: integrationID,
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComAttachmentUpload, ResourceType: constants.WeComApprovalInstanceResourceType,
ResourceID: &resourceID, TriggerSeries: triggerSeries, CorrelationID: correlationID,
RequestSummary: map[string]any{
"application_id": applicationID, "file_name": fileName, "file_bytes": info.Size(),
},
})
if err != nil {
return "", err
}
startedAt := u.now()
response, requestErr := u.httpClient.Do(request)
if requestErr != nil {
message := "企业微信审批附件上传失败"
_, _ = u.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, ProviderCode: "request_failed", ProviderMessage: message,
ResponseSummary: map[string]any{"success": false}, DurationMS: u.now().Sub(startedAt).Milliseconds(),
})
return "", errors.Wrap(errors.CodeServiceUnavailable, requestErr, message)
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, constants.WeComMaxResponseBodyBytes+1))
var result struct {
ErrCode int64 `json:"errcode"`
ErrMsg string `json:"errmsg"`
MediaID string `json:"media_id"`
}
if err != nil || int64(len(body)) > constants.WeComMaxResponseBodyBytes || sonic.Unmarshal(body, &result) != nil ||
response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || result.ErrCode != 0 || strings.TrimSpace(result.MediaID) == "" {
message := strings.TrimSpace(result.ErrMsg)
if message == "" {
message = "企业微信审批附件上传响应无效"
}
_, _ = u.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, HTTPStatus: response.StatusCode,
ProviderCode: strconv.FormatInt(result.ErrCode, 10), ProviderMessage: message,
ResponseSummary: map[string]any{"success": false, "errcode": result.ErrCode}, DurationMS: u.now().Sub(startedAt).Milliseconds(),
})
return "", errors.New(errors.CodeServiceUnavailable, "上传企业微信审批附件失败")
}
_, err = u.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, HTTPStatus: response.StatusCode,
ProviderCode: strconv.FormatInt(result.ErrCode, 10), ProviderMessage: result.ErrMsg,
ResponseSummary: map[string]any{"success": true, "media_id_set": true}, DurationMS: u.now().Sub(startedAt).Milliseconds(),
})
if err != nil {
return "", err
}
return strings.TrimSpace(result.MediaID), nil
}
// newAttachmentUploadRequest 在内存上限受文件大小校验约束的前提下组装 multipart 请求,避免流式写入 goroutine 泄漏。
func newAttachmentUploadRequest(ctx context.Context, endpoint, fileName string, file *os.File) (*http.Request, error) {
var body bytes.Buffer
multipartWriter := multipart.NewWriter(&body)
part, err := multipartWriter.CreateFormFile("media", fileName)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信附件表单失败")
}
if _, err := io.Copy(part, file); err != nil {
return nil, errors.Wrap(errors.CodeServiceUnavailable, err, "读取审批附件失败")
}
if err := multipartWriter.Close(); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "完成企业微信附件表单失败")
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body.Bytes()))
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信附件上传请求失败")
}
request.Header.Set("Content-Type", multipartWriter.FormDataContentType())
return request, nil
}