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 ( 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 { db *gorm.DB cfg config.ShortVideoConfig client *http.Client } type RemoveWatermarkResult 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 { 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) { 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 { 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) { 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) { 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) { 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] }