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
@@ -2,8 +2,10 @@ package handler
import (
"errors"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -36,6 +38,10 @@ type reportDownloadFailureRequest struct {
UserAgent string `json:"userAgent"`
}
type proxyMediaRequest struct {
URL string `form:"url" binding:"required"`
}
func (h *VideoHandler) RemoveWatermark(c *gin.Context) {
var req removeWatermarkRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -136,3 +142,65 @@ func (h *VideoHandler) ReportDownloadFailure(c *gin.Context) {
"reported": true,
}))
}
// ProxyMedia 代理媒体资源下载
// 该接口用于中转视频/图片等媒体资源,使小程序可以通过当前域名下载,避免微信域名限制
func (h *VideoHandler) ProxyMedia(c *gin.Context) {
var req proxyMediaRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误,缺少 url 参数"))
return
}
targetURL := strings.TrimSpace(req.URL)
if targetURL == "" {
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "url 参数不能为空"))
return
}
// 调用 service 层进行代理
result, err := h.videoService.ProxyMedia(c.Request.Context(), targetURL)
if err != nil {
switch {
case errors.Is(err, service.ErrProxyDisabled):
c.JSON(http.StatusServiceUnavailable, model.Error(http.StatusServiceUnavailable, "代理服务未启用"))
return
case errors.Is(err, service.ErrProxyInvalidURL):
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "无效的代理地址"))
return
case errors.Is(err, service.ErrProxyDomainBlocked):
c.JSON(http.StatusForbidden, model.Error(http.StatusForbidden, "该域名不在允许列表中"))
return
case errors.Is(err, service.ErrProxyFileTooLarge):
c.JSON(http.StatusRequestEntityTooLarge, model.Error(http.StatusRequestEntityTooLarge, "文件过大,超出限制"))
return
default:
var thirdPartyErr *service.ThirdPartyError
if errors.As(err, &thirdPartyErr) {
c.JSON(http.StatusBadGateway, model.Error(http.StatusBadGateway, "上游服务返回错误"))
return
}
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "代理请求失败"))
return
}
}
// 确保关闭响应体
defer result.Body.Close()
// 设置响应头
c.Header("Content-Type", result.ContentType)
if result.ContentLength > 0 {
c.Header("Content-Length", strconv.FormatInt(result.ContentLength, 10))
}
// 允许跨域(如果需要)
c.Header("Access-Control-Allow-Origin", "*")
// 设置缓存头,减少重复请求
c.Header("Cache-Control", "public, max-age=86400")
// 流式输出响应体
c.Status(result.StatusCode)
// 使用 io.Copy 进行流式传输,避免一次性加载到内存
_, _ = io.Copy(c.Writer, result.Body)
}
@@ -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
}