Files
wx_service/internal/remove_watermark/service/video_service.go
T
nepiedg 1b8ff310eb 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.
2026-02-06 11:28:02 +00:00

427 lines
12 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"gorm.io/gorm"
"wx_service/config"
usermodel "wx_service/internal/model"
rmmodel "wx_service/internal/remove_watermark/model"
)
const removeWatermarkEndpoint = "https://api.23bt.cn/api/dsp/index"
var (
// 从用户输入的文本里“抓取 URL”的简单正则(找到第一个 http/https 链接)
urlPattern = regexp.MustCompile(`https?://[^\s]+`)
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 {
// VideoService 封装“去水印”相关业务:
// - 限额(每天免费次数)
// - 调用第三方解析接口
// - 记录解析日志(便于排查问题/统计)
db *gorm.DB
cfg config.ShortVideoConfig
client *http.Client
}
type RemoveWatermarkResult struct {
// json.RawMessage 表示“原始 JSON 字节”,不强制把第三方返回结构定义成 Go struct。
// 对接不稳定/字段多的第三方接口时,这种做法更灵活。
Provider string `json:"provider"`
Raw json.RawMessage `json:"raw"`
FreeQuotaUsed bool `json:"free_quota_used"`
}
type ThirdPartyError struct {
StatusCode int
Message string
}
func (e *ThirdPartyError) Error() string {
return fmt.Sprintf("third-party api error: status=%d message=%s", e.StatusCode, e.Message)
}
type DownloadFailureReport struct {
Domain string
FailedURL string
ErrorMessage string
ReportedAt time.Time
UserAgent string
ClientIP string
}
func NewVideoService(db *gorm.DB, cfg config.ShortVideoConfig) (*VideoService, error) {
timeout := cfg.RequestTimeout
if timeout <= 0 {
// 不配置就给一个默认超时,避免请求卡住占用 goroutine
timeout = 5 * time.Second
}
client := &http.Client{
Timeout: timeout,
}
return &VideoService{
db: db,
cfg: cfg,
client: client,
}, nil
}
func (s *VideoService) RemoveWatermark(ctx context.Context, user *usermodel.User, content string) (*RemoveWatermarkResult, error) {
// RemoveWatermark 的整体流程:
// 1) 从 content 里提取链接
// 2) 检查每日免费额度(或是否已解锁)
// 3) 调用第三方接口解析
// 4) 写入解析日志(无论成功/失败都记一条,方便定位问题)
link, err := extractFirstURL(content)
if err != nil {
return nil, ErrURLNotFound
}
freeQuotaUsed, err := s.ensureQuota(ctx, user)
if err != nil {
return nil, err
}
now := time.Now()
var (
statusCode int
body []byte
requestErr error
)
statusCode, body, requestErr = s.callThirdParty(ctx, link)
duration := int(time.Since(now).Milliseconds())
logEntry := rmmodel.VideoParseLog{
MiniProgramID: user.MiniProgramID,
UserID: user.ID,
RequestContent: content,
ParsedURL: link,
ThirdPartyStatus: statusCode,
FreeQuotaUsed: freeQuotaUsed,
DurationMs: duration,
}
if requestErr == nil {
logEntry.ThirdPartyPayload = body
} else {
logEntry.ErrorMessage = requestErr.Error()
}
if err := s.db.WithContext(ctx).Create(&logEntry).Error; err != nil {
return nil, fmt.Errorf("save parse log: %w", err)
}
if requestErr != nil {
return nil, requestErr
}
return &RemoveWatermarkResult{
Provider: "23bt",
Raw: json.RawMessage(body),
FreeQuotaUsed: freeQuotaUsed,
}, nil
}
func (s *VideoService) UnlockForToday(ctx context.Context, user *usermodel.User) error {
// “看广告解锁”的实现方式:在当天写一条 unlock 记录即可(存在则更新时间戳)
startOfDay, _ := dayRange(time.Now())
var unlock rmmodel.VideoParseUnlock
tx := s.db.WithContext(ctx)
err := tx.Where("user_id = ? AND mini_program_id = ? AND unlock_date = ?", user.ID, user.MiniProgramID, startOfDay).First(&unlock).Error
if err == nil {
return tx.Model(&unlock).Updates(map[string]interface{}{
"ad_watched_at": time.Now(),
}).Error
}
if err != nil && err != gorm.ErrRecordNotFound {
return fmt.Errorf("load unlock record: %w", err)
}
record := rmmodel.VideoParseUnlock{
MiniProgramID: user.MiniProgramID,
UserID: user.ID,
UnlockDate: startOfDay,
AdWatchedAt: time.Now(),
}
if err := tx.Create(&record).Error; err != nil {
return fmt.Errorf("create unlock record: %w", err)
}
return nil
}
func (s *VideoService) ensureQuota(ctx context.Context, user *usermodel.User) (bool, error) {
// ensureQuota 返回值 freeQuotaUsed 的含义:
// - true:这次调用会消耗一次“免费额度”
// - false:不消耗(例如今日已解锁或未启用限额)
if s.cfg.FreeDailyQuota <= 0 {
return false, nil
}
startOfDay, endOfDay := dayRange(time.Now())
tx := s.db.WithContext(ctx)
var unlock rmmodel.VideoParseUnlock
if err := tx.Where("user_id = ? AND mini_program_id = ? AND unlock_date = ?", user.ID, user.MiniProgramID, startOfDay).First(&unlock).Error; err == nil {
return false, nil
} else if err != gorm.ErrRecordNotFound {
return false, fmt.Errorf("check unlock record: %w", err)
}
var count int64
if err := tx.Model(&rmmodel.VideoParseLog{}).
Where("user_id = ? AND mini_program_id = ? AND free_quota_used = ? AND created_at >= ? AND created_at < ?",
user.ID, user.MiniProgramID, true, startOfDay, endOfDay).
Count(&count).Error; err != nil {
return false, fmt.Errorf("count user quota: %w", err)
}
if count >= int64(s.cfg.FreeDailyQuota) {
return false, ErrDailyQuotaExceeded
}
return true, nil
}
func (s *VideoService) callThirdParty(ctx context.Context, link string) (int, []byte, error) {
if s.cfg.APIKey == "" {
return 0, nil, ErrShortVideoAPIKey
}
params := url.Values{}
params.Set("key", s.cfg.APIKey)
params.Set("url", link)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s?%s", removeWatermarkEndpoint, params.Encode()), nil)
if err != nil {
return 0, nil, fmt.Errorf("build third-party request: %w", err)
}
resp, err := s.client.Do(req)
if err != nil {
return 0, nil, fmt.Errorf("call third-party api: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return resp.StatusCode, nil, fmt.Errorf("read third-party response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return resp.StatusCode, nil, &ThirdPartyError{
StatusCode: resp.StatusCode,
Message: truncateString(string(body), 256),
}
}
return resp.StatusCode, body, nil
}
func extractFirstURL(content string) (string, error) {
// 从文本里提取第一个 URL,并做简单 trim,把末尾可能粘上的标点去掉。
if content == "" {
return "", ErrURLNotFound
}
match := urlPattern.FindString(content)
if match == "" {
return "", ErrURLNotFound
}
return strings.Trim(match, " \t\n\r,.;!\"'()[]{}<>"), nil
}
func dayRange(ts time.Time) (time.Time, time.Time) {
// 计算“当天的起止时间”:[00:00, 次日00:00)
// 用本地时区 loc,避免跨时区导致的日期偏移。
loc := ts.Location()
start := time.Date(ts.Year(), ts.Month(), ts.Day(), 0, 0, 0, 0, loc)
end := start.Add(24 * time.Hour)
return start, end
}
func truncateString(input string, max int) string {
// 截断字符串,避免把第三方错误响应原样写入导致日志过大
if len(input) <= max {
return input
}
return input[:max]
}
func (s *VideoService) ReportDownloadFailure(ctx context.Context, report DownloadFailureReport) error {
if report.ReportedAt.IsZero() {
report.ReportedAt = time.Now()
}
if report.Domain == "" && report.FailedURL != "" {
if parsed, err := url.Parse(report.FailedURL); err == nil {
report.Domain = parsed.Host
}
}
entry := rmmodel.VideoDownloadFailure{
Domain: report.Domain,
FailedURL: report.FailedURL,
ErrorMessage: truncateString(report.ErrorMessage, 2000),
ReportedAt: report.ReportedAt,
UserAgent: truncateString(report.UserAgent, 255),
ClientIP: truncateString(report.ClientIP, 64),
}
if entry.FailedURL == "" {
return errors.New("failed_url is required")
}
if err := s.db.WithContext(ctx).Create(&entry).Error; err != nil {
return fmt.Errorf("save download failure report: %w", err)
}
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
}