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) attempt, err := u.integration.Start(ctx, integrationlog.Attempt{ Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound, Operation: constants.IntegrationOperationWeComAttachmentUpload, ResourceType: constants.WeComApprovalInstanceResourceType, ResourceID: &resourceID, 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 }