七月迭代短暂完结,还有很多后端的关键东西没有弄,这是一版赶时间做的东西
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s

This commit is contained in:
2026-07-25 17:06:58 +08:00
parent ad9f613dd6
commit 73f5125d3d
249 changed files with 17137 additions and 877 deletions

View 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
}