Add short video configuration and related API endpoints

- 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.
This commit is contained in:
nepiedg
2025-12-30 00:31:41 +00:00
parent a1eaaab39f
commit 97cadb033e
10 changed files with 590 additions and 4 deletions
+95
View File
@@ -0,0 +1,95 @@
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"wx_service/internal/middleware"
"wx_service/internal/model"
"wx_service/internal/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, "invalid request payload"))
return
}
user, ok := getCurrentUser(c)
if !ok {
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "unauthorized"))
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, "content must contain a valid url"))
return
case errors.Is(err, service.ErrDailyQuotaExceeded):
c.JSON(http.StatusForbidden, model.Error(http.StatusForbidden, err.Error()))
return
case errors.Is(err, service.ErrShortVideoAPIKey):
c.JSON(http.StatusServiceUnavailable, model.Error(http.StatusServiceUnavailable, "short video api key missing"))
return
default:
var thirdPartyErr *service.ThirdPartyError
if errors.As(err, &thirdPartyErr) {
c.JSON(http.StatusBadGateway, model.Error(http.StatusBadGateway, thirdPartyErr.Error()))
return
}
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "remove watermark failed"))
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 := getCurrentUser(c)
if !ok {
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "unauthorized"))
return
}
if err := h.videoService.UnlockForToday(c.Request.Context(), user); err != nil {
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "unlock failed"))
return
}
c.JSON(http.StatusOK, model.Success(gin.H{
"unlocked": true,
}))
}
func getCurrentUser(c *gin.Context) (*model.User, bool) {
userVal, exists := c.Get(middleware.ContextCurrentUserKey)
if !exists {
return nil, false
}
user, ok := userVal.(*model.User)
return user, ok
}
+50
View File
@@ -0,0 +1,50 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"wx_service/internal/model"
)
const ContextCurrentUserKey = "currentUser"
func AuthMiddleware(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
token := extractToken(c.GetHeader("Authorization"))
if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "missing authorization header"))
return
}
var user model.User
if err := db.WithContext(c.Request.Context()).Where("session_key = ?", token).First(&user).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.AbortWithStatusJSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "invalid token"))
return
}
c.AbortWithStatusJSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load user failed"))
return
}
c.Set(ContextCurrentUserKey, &user)
c.Next()
}
}
func extractToken(authHeader string) string {
if authHeader == "" {
return ""
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 {
return ""
}
if !strings.EqualFold(parts[0], "Bearer") {
return ""
}
return strings.TrimSpace(parts[1])
}
+44
View File
@@ -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"
}
+233
View File
@@ -0,0 +1,233 @@
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]
}