package service import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "regexp" "strings" "time" "gorm.io/gorm" "wx_service/config" "wx_service/internal/model" ) const removeWatermarkEndpoint = "https://api.23bt.cn/api/d1w/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") ) 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) } 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 *model.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 := model.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 *model.User) error { // “看广告解锁”的实现方式:在当天写一条 unlock 记录即可(存在则更新时间戳) startOfDay, _ := dayRange(time.Now()) var unlock model.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 := model.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 *model.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 model.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(&model.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] }