All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s
167 lines
7.1 KiB
Go
167 lines
7.1 KiB
Go
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
|
||
}
|