fix cdn download logic

This commit is contained in:
Huaaudio 2026-03-28 06:49:29 +01:00
parent 607037472d
commit d952e391ec
2 changed files with 228 additions and 40 deletions

View file

@ -34,6 +34,8 @@ const (
weixinMediaMaxBytes = 100 << 20 weixinMediaMaxBytes = 100 << 20
weixinTypingKeepAlive = 5 * time.Second weixinTypingKeepAlive = 5 * time.Second
weixinUploadRetryMax = 3 weixinUploadRetryMax = 3
weixinDownloadRetryMax = 2
weixinDownloadRetryDelay = 300 * time.Millisecond
weixinVoiceTranscodeTimeout = 15 * time.Second weixinVoiceTranscodeTimeout = 15 * time.Second
) )
@ -163,49 +165,99 @@ func buildCDNDownloadURL(base, encryptedQueryParam string) string {
"/download?encrypted_query_param=" + url.QueryEscape(encryptedQueryParam) "/download?encrypted_query_param=" + url.QueryEscape(encryptedQueryParam)
} }
func shouldRetryCDNDownload(statusCode int) bool {
// statusCode=0 represents transport/build errors from the HTTP client.
return statusCode == 0 || statusCode >= 500 || statusCode == http.StatusTooManyRequests
}
func buildCDNUploadURL(base, uploadParam, filekey string) string { func buildCDNUploadURL(base, uploadParam, filekey string) string {
return strings.TrimRight(base, "/") + return strings.TrimRight(base, "/") +
"/upload?encrypted_query_param=" + url.QueryEscape(uploadParam) + "/upload?encrypted_query_param=" + url.QueryEscape(uploadParam) +
"&filekey=" + url.QueryEscape(filekey) "&filekey=" + url.QueryEscape(filekey)
} }
func uniqCDNURLs(urls []string) []string {
seen := make(map[string]struct{}, len(urls))
out := make([]string, 0, len(urls))
for _, raw := range urls {
u := strings.TrimSpace(raw)
if u == "" {
continue
}
if _, ok := seen[u]; ok {
continue
}
seen[u] = struct{}{}
out = append(out, u)
}
return out
}
func (c *WeixinChannel) downloadCDNBufferOnce(ctx context.Context, downloadURL string) ([]byte, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, 0, err
}
resp, err := c.api.HttpClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, resp.StatusCode, fmt.Errorf("cdn download HTTP %d: %s", resp.StatusCode, string(body))
}
data, err := io.ReadAll(io.LimitReader(resp.Body, weixinMediaMaxBytes+1))
if err != nil {
return nil, resp.StatusCode, err
}
if len(data) > weixinMediaMaxBytes {
return nil, resp.StatusCode, fmt.Errorf("cdn media too large: %d bytes", len(data))
}
return data, resp.StatusCode, nil
}
func (c *WeixinChannel) downloadCDNBuffer( func (c *WeixinChannel) downloadCDNBuffer(
ctx context.Context, ctx context.Context,
encryptedQueryParam, encryptedQueryParam,
fullURL string, fullURL string,
) ([]byte, error) { ) ([]byte, error) {
downloadURL := strings.TrimSpace(fullURL) candidates := uniqCDNURLs([]string{
if downloadURL == "" { strings.TrimSpace(fullURL),
downloadURL = buildCDNDownloadURL(c.cdnBaseURL(), encryptedQueryParam) func() string {
} if strings.TrimSpace(encryptedQueryParam) == "" {
req, err := http.NewRequestWithContext( return ""
ctx, }
http.MethodGet, return buildCDNDownloadURL(c.cdnBaseURL(), encryptedQueryParam)
downloadURL, }(),
nil, })
) if len(candidates) == 0 {
if err != nil { return nil, fmt.Errorf("missing CDN download URL")
return nil, err
}
resp, err := c.api.HttpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("cdn download HTTP %d: %s", resp.StatusCode, string(body))
} }
data, err := io.ReadAll(io.LimitReader(resp.Body, weixinMediaMaxBytes+1)) var lastErr error
if err != nil { for _, downloadURL := range candidates {
return nil, err for attempt := 1; attempt <= weixinDownloadRetryMax; attempt++ {
data, statusCode, err := c.downloadCDNBufferOnce(ctx, downloadURL)
if err == nil {
return data, nil
}
lastErr = fmt.Errorf("%w (attempt=%d url=%s)", err, attempt, downloadURL)
if !shouldRetryCDNDownload(statusCode) {
break
}
if attempt < weixinDownloadRetryMax {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(weixinDownloadRetryDelay):
}
}
}
} }
if len(data) > weixinMediaMaxBytes { return nil, lastErr
return nil, fmt.Errorf("cdn media too large: %d bytes", len(data))
}
return data, nil
} }
func (c *WeixinChannel) downloadAndDecryptCDNBuffer( func (c *WeixinChannel) downloadAndDecryptCDNBuffer(
@ -224,6 +276,33 @@ func (c *WeixinChannel) downloadAndDecryptCDNBuffer(
return decryptAESECB(data, key) return decryptAESECB(data, key)
} }
func (c *WeixinChannel) downloadImageBuffer(
ctx context.Context,
img *ImageItem,
key []byte,
) ([]byte, error) {
if img == nil {
return nil, fmt.Errorf("image item is nil")
}
if img.Media != nil {
data, err := c.downloadAndDecryptCDNBuffer(ctx, img.Media.EncryptQueryParam, img.Media.FullURL, key)
if err == nil {
return data, nil
}
if img.ThumbMedia == nil {
return nil, fmt.Errorf("image download failed: %w", err)
}
}
if img.ThumbMedia != nil {
data, err := c.downloadAndDecryptCDNBuffer(ctx, img.ThumbMedia.EncryptQueryParam, img.ThumbMedia.FullURL, key)
if err == nil {
return data, nil
}
return nil, fmt.Errorf("image download failed: %w", err)
}
return nil, fmt.Errorf("image media is nil")
}
func detectMediaMetadata(data []byte, fallbackName, fallbackContentType string) (string, string) { func detectMediaMetadata(data []byte, fallbackName, fallbackContentType string) (string, string) {
contentType := strings.TrimSpace(fallbackContentType) contentType := strings.TrimSpace(fallbackContentType)
ext := filepath.Ext(fallbackName) ext := filepath.Ext(fallbackName)
@ -446,21 +525,20 @@ func (c *WeixinChannel) downloadMediaFromItem(
switch item.Type { switch item.Type {
case MessageItemTypeImage: case MessageItemTypeImage:
if item.ImageItem == nil {
return "", fmt.Errorf("image media is nil")
}
key, ok, err := imageAESKey(item.ImageItem) key, ok, err := imageAESKey(item.ImageItem)
if err != nil { if err != nil {
return "", err return "", err
} }
data, err := c.downloadAndDecryptCDNBuffer( decryptKey := func() []byte {
ctx, if ok {
item.ImageItem.Media.EncryptQueryParam, return key
item.ImageItem.Media.FullURL, }
func() []byte { return nil
if ok { }()
return key data, err := c.downloadImageBuffer(ctx, item.ImageItem, decryptKey)
}
return nil
}(),
)
if err != nil { if err != nil {
return "", err return "", err
} }

View file

@ -81,6 +81,116 @@ func TestDownloadAndDecryptCDNBuffer(t *testing.T) {
} }
} }
func TestDownloadAndDecryptCDNBufferUsesFullURLWhenProvided(t *testing.T) {
key := []byte("1234567890abcdef")
plaintext := []byte("hello weixin")
ciphertext, err := encryptAESECB(plaintext, key)
if err != nil {
t.Fatalf("encryptAESECB() error = %v", err)
}
fullURLAttempts := 0
ch := &WeixinChannel{
api: &ApiClient{
HttpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.String() == "https://full.example.com/download" {
fullURLAttempts++
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(ciphertext)),
Header: make(http.Header),
}, nil
}
t.Fatalf("unexpected fallback request: %s", r.URL.String())
return nil, nil
})},
},
config: config.WeixinConfig{
CDNBaseURL: "https://cdn.example.com",
},
typingCache: make(map[string]typingTicketCacheEntry),
}
got, err := ch.downloadAndDecryptCDNBuffer(context.Background(), "token", "https://full.example.com/download", key)
if err != nil {
t.Fatalf("downloadAndDecryptCDNBuffer() error = %v", err)
}
if !bytes.Equal(got, plaintext) {
t.Fatalf("downloadAndDecryptCDNBuffer() = %q, want %q", got, plaintext)
}
if fullURLAttempts == 0 {
t.Fatalf("fullURLAttempts = %d, want > 0", fullURLAttempts)
}
}
func TestDownloadAndDecryptCDNBufferFallsBackToConstructedURLWhenFullURLFails(t *testing.T) {
key := []byte("1234567890abcdef")
plaintext := []byte("hello weixin")
ciphertext, err := encryptAESECB(plaintext, key)
if err != nil {
t.Fatalf("encryptAESECB() error = %v", err)
}
fullURLAttempts := 0
constructedAttempts := 0
ch := &WeixinChannel{
api: &ApiClient{
HttpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.String() == "https://full.example.com/download?encrypted_query_param=token&taskid=123" {
fullURLAttempts++
return &http.Response{
StatusCode: http.StatusInternalServerError,
Body: io.NopCloser(bytes.NewReader(nil)),
Header: make(http.Header),
}, nil
}
if r.URL.String() != "https://cdn.example.com/download?encrypted_query_param=token" {
t.Fatalf("unexpected fallback request: %s", r.URL.String())
}
constructedAttempts++
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(ciphertext)),
Header: make(http.Header),
}, nil
})},
},
config: config.WeixinConfig{
CDNBaseURL: "https://cdn.example.com",
},
typingCache: make(map[string]typingTicketCacheEntry),
}
got, err := ch.downloadAndDecryptCDNBuffer(
context.Background(),
"token",
"https://full.example.com/download?encrypted_query_param=token&taskid=123",
key,
)
if err != nil {
t.Fatalf("downloadAndDecryptCDNBuffer() error = %v", err)
}
if !bytes.Equal(got, plaintext) {
t.Fatalf("downloadAndDecryptCDNBuffer() = %q, want %q", got, plaintext)
}
if fullURLAttempts == 0 {
t.Fatalf("fullURLAttempts = %d, want > 0", fullURLAttempts)
}
if constructedAttempts == 0 {
t.Fatalf("constructedAttempts = %d, want > 0", constructedAttempts)
}
}
func TestBuildCDNDownloadURLEscapesOpaqueToken(t *testing.T) {
token := "MFcCAQAESzBJAgEAAgSieMV9AgM9CcwCBEoKPqICBGnHZB0EJDk4OWY5YWU0LTc4OGItNGQ5Ni1iMjZhLWU4YjhlMmEwOWVkZgIEIR0IAgIBAAQFAExUPQA%3D"
got := buildCDNDownloadURL("https://cdn.example.com", token)
if got != "https://cdn.example.com/download?encrypted_query_param=MFcCAQAESzBJAgEAAgSieMV9AgM9CcwCBEoKPqICBGnHZB0EJDk4OWY5YWU0LTc4OGItNGQ5Ni1iMjZhLWU4YjhlMmEwOWVkZgIEIR0IAgIBAAQFAExUPQA%253D" {
t.Fatalf("buildCDNDownloadURL() = %q", got)
}
}
func TestUploadBufferToCDN(t *testing.T) { func TestUploadBufferToCDN(t *testing.T) {
key := []byte("1234567890abcdef") key := []byte("1234567890abcdef")
plaintext := []byte("upload me") plaintext := []byte("upload me")