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 }