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:
@@ -11,3 +11,8 @@ DB_NAME=wx_service
|
||||
|
||||
# JWT配置
|
||||
JWT_SECRET=your-secret-key-change-in-production
|
||||
|
||||
# 短视频解析配置
|
||||
SHORT_VIDEO_API_KEY=replace-with-real-key
|
||||
SHORT_VIDEO_FREE_QUOTA=20
|
||||
SHORT_VIDEO_TIMEOUT_SECONDS=5
|
||||
|
||||
+13
-1
@@ -9,6 +9,7 @@ import (
|
||||
"wx_service/config"
|
||||
"wx_service/internal/database"
|
||||
"wx_service/internal/handler"
|
||||
"wx_service/internal/middleware"
|
||||
"wx_service/internal/model"
|
||||
"wx_service/internal/service"
|
||||
)
|
||||
@@ -19,7 +20,7 @@ func main() {
|
||||
if err := database.InitDB(); err != nil {
|
||||
log.Fatalf("init database failed: %v", err)
|
||||
}
|
||||
if err := database.AutoMigrate(&model.MiniProgram{}, &model.User{}); err != nil {
|
||||
if err := database.AutoMigrate(&model.MiniProgram{}, &model.User{}, &model.VideoParseLog{}, &model.VideoParseUnlock{}); err != nil {
|
||||
log.Fatalf("auto migrate failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -29,10 +30,21 @@ func main() {
|
||||
miniProgramService := service.NewMiniProgramService(database.DB)
|
||||
authService := service.NewAuthService(database.DB, miniProgramService)
|
||||
authHandler := handler.NewAuthHandler(authService)
|
||||
videoService, err := service.NewVideoService(database.DB, config.AppConfig.ShortVideo)
|
||||
if err != nil {
|
||||
log.Fatalf("init video service failed: %v", err)
|
||||
}
|
||||
videoHandler := handler.NewVideoHandler(videoService)
|
||||
|
||||
api := router.Group("/api/v1")
|
||||
{
|
||||
api.POST("/auth/login", authHandler.LoginWithWeChat)
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.AuthMiddleware(database.DB))
|
||||
{
|
||||
protected.POST("/video/remove_watermark", videoHandler.RemoveWatermark)
|
||||
protected.POST("/video/remove_watermark/unlock", videoHandler.UnlockQuota)
|
||||
}
|
||||
}
|
||||
|
||||
router.GET("/healthz", func(c *gin.Context) {
|
||||
|
||||
+26
-3
@@ -3,14 +3,17 @@ package config
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
JWT JWTConfig
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
JWT JWTConfig
|
||||
ShortVideo ShortVideoConfig
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
@@ -31,6 +34,12 @@ type JWTConfig struct {
|
||||
Expire int
|
||||
}
|
||||
|
||||
type ShortVideoConfig struct {
|
||||
APIKey string
|
||||
FreeDailyQuota int
|
||||
RequestTimeout time.Duration
|
||||
}
|
||||
|
||||
var AppConfig *Config
|
||||
|
||||
func LoadConfig() {
|
||||
@@ -55,6 +64,11 @@ func LoadConfig() {
|
||||
Secret: getEnv("JWT_SECRET", "your-secret-key"),
|
||||
Expire: 86400, // 24小时
|
||||
},
|
||||
ShortVideo: ShortVideoConfig{
|
||||
APIKey: getEnv("SHORT_VIDEO_API_KEY", ""),
|
||||
FreeDailyQuota: getEnvAsInt("SHORT_VIDEO_FREE_QUOTA", 20),
|
||||
RequestTimeout: time.Duration(getEnvAsInt("SHORT_VIDEO_TIMEOUT_SECONDS", 5)) * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,3 +78,12 @@ func getEnv(key, defaultValue string) string {
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvAsInt(key string, defaultValue int) int {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
if v, err := strconv.Atoi(value); err == nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# 去水印服务 API 说明
|
||||
|
||||
所有接口均位于 `/api/v1`,除登录外都需要在 Header 中携带 `Authorization: Bearer <session_key>`,其中 `<session_key>` 来自登录接口的响应。
|
||||
|
||||
## 1. 登录(复用已有接口)
|
||||
|
||||
`POST /api/v1/auth/login`
|
||||
|
||||
## 接口
|
||||
|
||||
### `POST /api/v1/auth/login`
|
||||
|
||||
- **说明**:接收小程序端 `wx.login` 返回的 `code`,向微信 `jscode2session` 请求 `openid` / `session_key`。若 `open_id` 不存在则创建用户,存在则更新资料并返回用户信息。
|
||||
- **请求体**
|
||||
|
||||
```json
|
||||
{
|
||||
"mini_program_id": 1,
|
||||
"code": "wx.login返回的code",
|
||||
"nickname": "可选",
|
||||
"avatar_url": "可选",
|
||||
"gender": 1,
|
||||
"phone": "110"
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 解析短视频去水印
|
||||
|
||||
`POST /api/v1/video/remove_watermark`
|
||||
|
||||
| 项目 | 说明 |
|
||||
| --- | --- |
|
||||
| Header | `Authorization: Bearer <session_key>` |
|
||||
| 请求体 | `{"content":"帮我解析 https://v.douyin.com/xxxx/"}` |
|
||||
| 必填校验 | `content` 必须包含一个合法的 http/https 链接 |
|
||||
| 响应 | `provider` 固定为 `23bt`,`raw` 为第三方原始 JSON,`free_quota_used` 表示是否占用免费额度 |
|
||||
|
||||
**成功示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"provider": "23bt",
|
||||
"raw": {
|
||||
"code": 200,
|
||||
"msg": "解析成功",
|
||||
"data": { "...第三方原始字段..." }
|
||||
},
|
||||
"free_quota_used": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**错误返回**
|
||||
|
||||
| HTTP 码 | code | message | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 400 | 400 | `content must contain a valid url` | 未找到链接 |
|
||||
| 401 | 401 | `unauthorized` | Token 缺失/失效 |
|
||||
| 403 | 403 | `daily free quota exceeded, please watch an ad to continue` | 超过每日免费额度,需要走广告解锁 |
|
||||
| 502 | 502 | `third-party api error: ...` | 第三方返回异常 |
|
||||
| 503 | 503 | `short video api key missing` | 未配置 `SHORT_VIDEO_API_KEY` |
|
||||
| 500 | 500 | `remove watermark failed` | 其他内部错误 |
|
||||
|
||||
## 3. 完成广告解锁
|
||||
|
||||
`POST /api/v1/video/remove_watermark/unlock`
|
||||
|
||||
| 项目 | 说明 |
|
||||
| --- | --- |
|
||||
| Header | `Authorization: Bearer <session_key>` |
|
||||
| 请求体 | 空 |
|
||||
| 响应 | `{"code":200,"message":"success","data":{"unlocked":true}}` |
|
||||
|
||||
说明:调用该接口表示用户当天已经观看完广告,服务端会写入 `video_parse_unlocks`,当天余下时间不再校验免费额度。
|
||||
|
||||
## 4. 数据落地
|
||||
|
||||
- 每次解析调用都会写入 `video_parse_logs`,其中包含 `request_content`、`parsed_url`、第三方响应、调用耗时等字段,便于审计和配额统计。
|
||||
- 第三方返回的 JSON 直接保存在 `video_parse_logs.third_party_payload` 字段,可通过 SQL 查询和脱敏。
|
||||
- SQL DDL 位于 `docs/sql/remove_watermark.sql`,部署数据库时执行即可。
|
||||
@@ -90,3 +90,11 @@ Authorization: Bearer <token>
|
||||
4. **其他注意事项**:
|
||||
- 可在日志中存储第三方返回摘要(如视频标题、作者),便于后续分析。
|
||||
- 可配置单独的付费/会员策略,覆盖默认 20 次逻辑。
|
||||
|
||||
## 实现概览
|
||||
|
||||
- 项目已将 `SHORT_VIDEO_API_KEY`、`SHORT_VIDEO_FREE_QUOTA`、`SHORT_VIDEO_TIMEOUT_SECONDS` 等变量加入配置,可通过 `.env` 控制。
|
||||
- 新增 `video_parse_logs` / `video_parse_unlocks` 表(DDL 见 `docs/sql/remove_watermark.sql`):分别记录每次解析详情和“观看广告解锁”状态。
|
||||
- 用户在登录后可使用登录接口返回的 `session_key` 作为 `Authorization: Bearer <session_key>` 调用受保护的接口。
|
||||
- 成功解析后会将第三方原始响应以 JSON 形式直接写入 `video_parse_logs.third_party_payload` 字段,方便统一检索。
|
||||
- API 列表和请求/响应示例详见 `docs/remove_watermark/API.md`。
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
-- 短视频去水印调用日志
|
||||
CREATE TABLE IF NOT EXISTS `video_parse_logs` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted_at` datetime DEFAULT NULL,
|
||||
`mini_program_id` bigint unsigned NOT NULL,
|
||||
`user_id` bigint unsigned NOT NULL,
|
||||
`request_content` text,
|
||||
`parsed_url` varchar(500) DEFAULT NULL,
|
||||
`third_party_status` int DEFAULT NULL,
|
||||
`third_party_payload` json DEFAULT NULL,
|
||||
`free_quota_used` tinyint(1) NOT NULL DEFAULT '1',
|
||||
`duration_ms` int DEFAULT NULL,
|
||||
`error_message` varchar(500) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_video_parse_user_date` (`mini_program_id`,`user_id`,`created_at`),
|
||||
KEY `idx_video_parse_free` (`mini_program_id`,`user_id`,`free_quota_used`,`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 每日广告解锁记录
|
||||
CREATE TABLE IF NOT EXISTS `video_parse_unlocks` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted_at` datetime DEFAULT NULL,
|
||||
`mini_program_id` bigint unsigned NOT NULL,
|
||||
`user_id` bigint unsigned NOT NULL,
|
||||
`unlock_date` date NOT NULL,
|
||||
`ad_watched_at` datetime NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_video_unlock_date` (`mini_program_id`,`user_id`,`unlock_date`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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,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]
|
||||
}
|
||||
Reference in New Issue
Block a user