97cadb033e
- Updated .env.example to include SHORT_VIDEO_API_KEY, SHORT_VIDEO_FREE_QUOTA, and SHORT_VIDEO_TIMEOUT_SECONDS. - Enhanced main.go to auto-migrate new VideoParseLog and VideoParseUnlock models. - Introduced VideoService and VideoHandler for handling video-related operations. - Added protected API routes for removing watermarks and unlocking video features. - Updated config.go to support short video configuration settings. - Expanded documentation to reflect new features and configuration options.
234 lines
5.8 KiB
Go
234 lines
5.8 KiB
Go
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]
|
|
}
|