diff --git a/.env.example b/.env.example index b5ef867..d383af0 100755 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/cmd/api/main.go b/cmd/api/main.go index cfecf59..f8cf246 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -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) { diff --git a/config/config.go b/config/config.go index ef36f17..f8e4d68 100755 --- a/config/config.go +++ b/config/config.go @@ -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 +} diff --git a/docs/remove_watermark/API.md b/docs/remove_watermark/API.md new file mode 100644 index 0000000..fa7058d --- /dev/null +++ b/docs/remove_watermark/API.md @@ -0,0 +1,83 @@ +# 去水印服务 API 说明 + +所有接口均位于 `/api/v1`,除登录外都需要在 Header 中携带 `Authorization: Bearer `,其中 `` 来自登录接口的响应。 + +## 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 ` | +| 请求体 | `{"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 ` | +| 请求体 | 空 | +| 响应 | `{"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`,部署数据库时执行即可。 diff --git a/docs/remove_watermark/README.md b/docs/remove_watermark/README.md index 752aa60..3944ece 100644 --- a/docs/remove_watermark/README.md +++ b/docs/remove_watermark/README.md @@ -90,3 +90,11 @@ Authorization: Bearer 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 ` 调用受保护的接口。 +- 成功解析后会将第三方原始响应以 JSON 形式直接写入 `video_parse_logs.third_party_payload` 字段,方便统一检索。 +- API 列表和请求/响应示例详见 `docs/remove_watermark/API.md`。 diff --git a/docs/sql/remove_watermark.sql b/docs/sql/remove_watermark.sql new file mode 100644 index 0000000..7abfe1d --- /dev/null +++ b/docs/sql/remove_watermark.sql @@ -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; diff --git a/internal/handler/video_handler.go b/internal/handler/video_handler.go new file mode 100644 index 0000000..a4bdc20 --- /dev/null +++ b/internal/handler/video_handler.go @@ -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 +} diff --git a/internal/middleware/auth_middleware.go b/internal/middleware/auth_middleware.go new file mode 100644 index 0000000..1e20d99 --- /dev/null +++ b/internal/middleware/auth_middleware.go @@ -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]) +} diff --git a/internal/model/video_parse.go b/internal/model/video_parse.go new file mode 100644 index 0000000..74d73a6 --- /dev/null +++ b/internal/model/video_parse.go @@ -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" +} diff --git a/internal/service/video_service.go b/internal/service/video_service.go new file mode 100644 index 0000000..5e28bbb --- /dev/null +++ b/internal/service/video_service.go @@ -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] +}