七月迭代短暂完结,还有很多后端的关键东西没有弄,这是一版赶时间做的东西
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s
This commit is contained in:
166
internal/infrastructure/wecom/approval_attachment_uploader.go
Normal file
166
internal/infrastructure/wecom/approval_attachment_uploader.go
Normal file
@@ -0,0 +1,166 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user