Refactor video handling and integrate new services
- Removed legacy video handling code and models to streamline the codebase. - Updated main.go to include new services for removing watermarks and smoke logging. - Enhanced route registration to accommodate new handlers for watermark removal and smoke logging. - Improved database migration to include new models for watermark processing.
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"wx_service/internal/middleware"
|
||||
"wx_service/internal/model"
|
||||
"wx_service/internal/remove_watermark/service"
|
||||
)
|
||||
|
||||
type VideoHandler struct {
|
||||
videoService *service.VideoService
|
||||
}
|
||||
|
||||
func NewVideoHandler(videoService *service.VideoService) *VideoHandler {
|
||||
return &VideoHandler{
|
||||
videoService: videoService,
|
||||
}
|
||||
}
|
||||
|
||||
type removeWatermarkRequest struct {
|
||||
Content string `json:"content" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *VideoHandler) RemoveWatermark(c *gin.Context) {
|
||||
var req removeWatermarkRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := middleware.CurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.videoService.RemoveWatermark(c.Request.Context(), user, req.Content)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrURLNotFound):
|
||||
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请检查分享链接是否正确"))
|
||||
return
|
||||
case errors.Is(err, service.ErrDailyQuotaExceeded):
|
||||
c.JSON(http.StatusForbidden, model.Error(http.StatusForbidden, "今日免费次数已用完,观看广告后今天可无限制使用"))
|
||||
return
|
||||
case errors.Is(err, service.ErrShortVideoAPIKey):
|
||||
c.JSON(http.StatusServiceUnavailable, model.Error(http.StatusServiceUnavailable, "服务暂不可用,请联系管理员"))
|
||||
return
|
||||
default:
|
||||
var thirdPartyErr *service.ThirdPartyError
|
||||
if errors.As(err, &thirdPartyErr) {
|
||||
c.JSON(http.StatusBadGateway, model.Error(http.StatusBadGateway, "解析服务异常,请稍后重试"))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "去水印失败,请稍后重试"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.Success(gin.H{
|
||||
"provider": result.Provider,
|
||||
"raw": result.Raw,
|
||||
"free_quota_used": result.FreeQuotaUsed,
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *VideoHandler) UnlockQuota(c *gin.Context) {
|
||||
user, ok := middleware.CurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.videoService.UnlockForToday(c.Request.Context(), user); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "解锁失败,请稍后重试"))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.Success(gin.H{
|
||||
"unlocked": true,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type VideoParseLog struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
MiniProgramID uint `gorm:"index:idx_video_parse_user_date,priority:1" json:"mini_program_id"`
|
||||
UserID uint `gorm:"index:idx_video_parse_user_date,priority:2" json:"user_id"`
|
||||
RequestContent string `gorm:"type:text" json:"request_content"`
|
||||
ParsedURL string `gorm:"size:500" json:"parsed_url"`
|
||||
ThirdPartyStatus int `json:"third_party_status"`
|
||||
ThirdPartyPayload []byte `gorm:"type:json" json:"third_party_payload"`
|
||||
FreeQuotaUsed bool `gorm:"default:1" json:"free_quota_used"`
|
||||
DurationMs int `json:"duration_ms"`
|
||||
ErrorMessage string `gorm:"size:500" json:"error_message"`
|
||||
}
|
||||
|
||||
func (VideoParseLog) TableName() string {
|
||||
return "video_parse_logs"
|
||||
}
|
||||
|
||||
type VideoParseUnlock struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
MiniProgramID uint `gorm:"index:idx_video_unlock_user_date,priority:1" json:"mini_program_id"`
|
||||
UserID uint `gorm:"index:idx_video_unlock_user_date,priority:2" json:"user_id"`
|
||||
UnlockDate time.Time `gorm:"type:date;index:idx_video_unlock_user_date,priority:3" json:"unlock_date"`
|
||||
AdWatchedAt time.Time `json:"ad_watched_at"`
|
||||
}
|
||||
|
||||
func (VideoParseUnlock) TableName() string {
|
||||
return "video_parse_unlocks"
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
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/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 *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]
|
||||
}
|
||||
Reference in New Issue
Block a user