Add media proxy feature for resource downloading

- Introduced a new API endpoint `GET /api/v1/video/proxy` to facilitate media resource downloads, allowing users to bypass domain restrictions imposed by WeChat.
- Updated configuration to include proxy settings such as `SHORT_VIDEO_PROXY_ENABLED`, `SHORT_VIDEO_PROXY_ALLOWED_DOMAINS`, `SHORT_VIDEO_PROXY_MAX_SIZE_MB`, and `SHORT_VIDEO_PROXY_TIMEOUT_SECONDS`.
- Enhanced the `ShortVideoConfig` struct to accommodate new proxy-related fields.
- Improved error handling for proxy requests, including checks for allowed domains and file size limits.
- Updated documentation to reflect the new proxy functionality and its configuration options, ensuring clarity for users and developers.
This commit is contained in:
nepiedg
2026-02-06 11:28:02 +00:00
parent 9200600b1c
commit 1b8ff310eb
7 changed files with 1049 additions and 3 deletions
@@ -27,6 +27,10 @@ var (
ErrURLNotFound = errors.New("no valid url found in content")
ErrShortVideoAPIKey = errors.New("short video api key is not configured")
ErrDailyQuotaExceeded = errors.New("daily free quota exceeded, please watch an ad to continue")
ErrProxyDisabled = errors.New("media proxy is disabled")
ErrProxyDomainBlocked = errors.New("domain is not in the allowed list")
ErrProxyFileTooLarge = errors.New("file size exceeds the limit")
ErrProxyInvalidURL = errors.New("invalid proxy url")
)
type VideoService struct {
@@ -292,3 +296,131 @@ func (s *VideoService) ReportDownloadFailure(ctx context.Context, report Downloa
}
return nil
}
// ProxyMediaResult 代理媒体请求的结果
type ProxyMediaResult struct {
Body io.ReadCloser
ContentType string
ContentLength int64
StatusCode int
}
// ProxyMedia 代理媒体资源下载,用于绕过微信小程序的域名限制
// 该方法会验证目标URL是否在允许的域名白名单内,并流式转发响应
func (s *VideoService) ProxyMedia(ctx context.Context, targetURL string) (*ProxyMediaResult, error) {
// 检查是否启用代理功能
if !s.cfg.ProxyEnabled {
return nil, ErrProxyDisabled
}
// 验证并解析 URL
parsed, err := url.Parse(targetURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, ErrProxyInvalidURL
}
// 只允许 http/https 协议
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, ErrProxyInvalidURL
}
// 检查域名白名单(如果配置了的话)
if len(s.cfg.ProxyAllowedDomains) > 0 {
allowed := false
for _, domain := range s.cfg.ProxyAllowedDomains {
if strings.HasSuffix(parsed.Host, domain) || parsed.Host == domain {
allowed = true
break
}
}
if !allowed {
return nil, ErrProxyDomainBlocked
}
}
// 创建带超时的 HTTP 客户端(代理请求可能需要更长时间)
proxyTimeout := s.cfg.ProxyTimeout
if proxyTimeout <= 0 {
proxyTimeout = 60 * time.Second
}
proxyClient := &http.Client{
Timeout: proxyTimeout,
}
// 构建代理请求
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
if err != nil {
return nil, fmt.Errorf("build proxy request: %w", err)
}
// 设置常见的请求头,模拟正常浏览器行为
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "*/*")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Referer", fmt.Sprintf("%s://%s/", parsed.Scheme, parsed.Host))
// 发起请求
resp, err := proxyClient.Do(req)
if err != nil {
return nil, fmt.Errorf("proxy request failed: %w", err)
}
// 检查响应状态
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
resp.Body.Close()
return nil, &ThirdPartyError{
StatusCode: resp.StatusCode,
Message: fmt.Sprintf("upstream returned status %d", resp.StatusCode),
}
}
// 检查文件大小限制
if s.cfg.ProxyMaxSize > 0 && resp.ContentLength > s.cfg.ProxyMaxSize {
resp.Body.Close()
return nil, ErrProxyFileTooLarge
}
// 获取 Content-Type
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
return &ProxyMediaResult{
Body: resp.Body,
ContentType: contentType,
ContentLength: resp.ContentLength,
StatusCode: resp.StatusCode,
}, nil
}
// ValidateProxyURL 验证代理URL是否有效(不实际请求,只做格式和白名单检查)
func (s *VideoService) ValidateProxyURL(targetURL string) error {
if !s.cfg.ProxyEnabled {
return ErrProxyDisabled
}
parsed, err := url.Parse(targetURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return ErrProxyInvalidURL
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return ErrProxyInvalidURL
}
if len(s.cfg.ProxyAllowedDomains) > 0 {
allowed := false
for _, domain := range s.cfg.ProxyAllowedDomains {
if strings.HasSuffix(parsed.Host, domain) || parsed.Host == domain {
allowed = true
break
}
}
if !allowed {
return ErrProxyDomainBlocked
}
}
return nil
}