diff --git a/internal/admin/handler/watermark_handler.go b/internal/admin/handler/watermark_handler.go new file mode 100644 index 0000000..8548d26 --- /dev/null +++ b/internal/admin/handler/watermark_handler.go @@ -0,0 +1,211 @@ +package handler + +import ( + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + + adminservice "wx_service/internal/admin/service" + "wx_service/internal/model" +) + +func (h *Handler) ListVideoParseLogs(c *gin.Context) { + page, err := parsePage(c.DefaultQuery("page", "1")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid page")) + return + } + pageSize, err := parsePageSize(c.DefaultQuery("page_size", "20")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid page_size")) + return + } + miniProgramID, err := parseOptionalUint(c.Query("mini_program_id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid mini_program_id")) + return + } + userID, err := parseOptionalUint(c.Query("user_id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid user_id")) + return + } + freeQuotaUsed, err := parseOptionalBool(c.Query("free_quota_used")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid free_quota_used")) + return + } + dateFrom, err := parseOptionalDateBoundary(c.Query("date_from"), false) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid date_from, expected YYYY-MM-DD")) + return + } + dateTo, err := parseOptionalDateBoundary(c.Query("date_to"), true) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid date_to, expected YYYY-MM-DD")) + return + } + + data, err := h.svc.ListVideoParseLogs(c.Request.Context(), adminservice.ListVideoParseLogsQuery{ + Page: page, + PageSize: pageSize, + MiniProgramID: miniProgramID, + UserID: userID, + FreeQuotaUsed: freeQuotaUsed, + Keyword: c.Query("keyword"), + DateFrom: dateFrom, + DateTo: dateTo, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load video parse logs failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) ListVideoParseUnlocks(c *gin.Context) { + page, err := parsePage(c.DefaultQuery("page", "1")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid page")) + return + } + pageSize, err := parsePageSize(c.DefaultQuery("page_size", "20")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid page_size")) + return + } + miniProgramID, err := parseOptionalUint(c.Query("mini_program_id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid mini_program_id")) + return + } + userID, err := parseOptionalUint(c.Query("user_id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid user_id")) + return + } + dateFrom, err := parseOptionalDateBoundary(c.Query("date_from"), false) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid date_from, expected YYYY-MM-DD")) + return + } + dateTo, err := parseOptionalDateBoundary(c.Query("date_to"), true) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid date_to, expected YYYY-MM-DD")) + return + } + + data, err := h.svc.ListVideoParseUnlocks(c.Request.Context(), adminservice.ListVideoParseUnlocksQuery{ + Page: page, + PageSize: pageSize, + MiniProgramID: miniProgramID, + UserID: userID, + DateFrom: dateFrom, + DateTo: dateTo, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load video parse unlocks failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) ListVideoDownloadFailures(c *gin.Context) { + page, err := parsePage(c.DefaultQuery("page", "1")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid page")) + return + } + pageSize, err := parsePageSize(c.DefaultQuery("page_size", "20")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid page_size")) + return + } + dateFrom, err := parseOptionalDateBoundary(c.Query("date_from"), false) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid date_from, expected YYYY-MM-DD")) + return + } + dateTo, err := parseOptionalDateBoundary(c.Query("date_to"), true) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid date_to, expected YYYY-MM-DD")) + return + } + + data, err := h.svc.ListVideoDownloadFailures(c.Request.Context(), adminservice.ListVideoDownloadFailuresQuery{ + Page: page, + PageSize: pageSize, + Domain: c.Query("domain"), + Keyword: c.Query("keyword"), + DateFrom: dateFrom, + DateTo: dateTo, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load video download failures failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func parsePage(raw string) (int, error) { + value, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || value < 1 { + return 0, fmt.Errorf("invalid page") + } + return value, nil +} + +func parsePageSize(raw string) (int, error) { + value, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || value < 1 { + return 0, fmt.Errorf("invalid page_size") + } + return value, nil +} + +func parseOptionalUint(raw string) (uint, error) { + text := strings.TrimSpace(raw) + if text == "" { + return 0, nil + } + value, err := strconv.ParseUint(text, 10, 64) + if err != nil { + return 0, err + } + return uint(value), nil +} + +func parseOptionalBool(raw string) (*bool, error) { + text := strings.TrimSpace(strings.ToLower(raw)) + if text == "" { + return nil, nil + } + if text == "1" || text == "true" { + v := true + return &v, nil + } + if text == "0" || text == "false" { + v := false + return &v, nil + } + return nil, fmt.Errorf("invalid bool") +} + +func parseOptionalDateBoundary(raw string, endOfDay bool) (*time.Time, error) { + text := strings.TrimSpace(raw) + if text == "" { + return nil, nil + } + parsed, err := time.ParseInLocation("2006-01-02", text, time.Local) + if err != nil { + return nil, err + } + if endOfDay { + parsed = parsed.Add(23*time.Hour + 59*time.Minute + 59*time.Second) + } + return &parsed, nil +} diff --git a/internal/admin/service/watermark_service.go b/internal/admin/service/watermark_service.go new file mode 100644 index 0000000..93ff40c --- /dev/null +++ b/internal/admin/service/watermark_service.go @@ -0,0 +1,347 @@ +package service + +import ( + "context" + "strings" + "time" + + "wx_service/internal/model" + rmmodel "wx_service/internal/remove_watermark/model" +) + +type ListVideoParseLogsQuery struct { + Page int + PageSize int + MiniProgramID uint + UserID uint + FreeQuotaUsed *bool + Keyword string + DateFrom *time.Time + DateTo *time.Time +} + +type VideoParseLogItem struct { + ID uint `json:"id"` + MiniProgramID uint `json:"mini_program_id"` + MiniProgramName string `json:"mini_program_name"` + UserID uint `json:"user_id"` + RequestContent string `json:"request_content"` + ParsedURL string `json:"parsed_url"` + ThirdPartyStatus int `json:"third_party_status"` + FreeQuotaUsed bool `json:"free_quota_used"` + DurationMs int `json:"duration_ms"` + ErrorMessage string `json:"error_message"` + CreatedAt time.Time `json:"created_at"` + ThirdPartyPayload string `json:"third_party_payload"` +} + +type ListVideoParseLogsResult struct { + List []VideoParseLogItem `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type ListVideoParseUnlocksQuery struct { + Page int + PageSize int + MiniProgramID uint + UserID uint + DateFrom *time.Time + DateTo *time.Time +} + +type VideoParseUnlockItem struct { + ID uint `json:"id"` + MiniProgramID uint `json:"mini_program_id"` + MiniProgramName string `json:"mini_program_name"` + UserID uint `json:"user_id"` + UnlockDate time.Time `json:"unlock_date"` + AdWatchedAt time.Time `json:"ad_watched_at"` + CreatedAt time.Time `json:"created_at"` +} + +type ListVideoParseUnlocksResult struct { + List []VideoParseUnlockItem `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type ListVideoDownloadFailuresQuery struct { + Page int + PageSize int + Domain string + Keyword string + DateFrom *time.Time + DateTo *time.Time +} + +type VideoDownloadFailureItem struct { + ID uint `json:"id"` + Domain string `json:"domain"` + FailedURL string `json:"failed_url"` + ErrorMessage string `json:"error_message"` + ReportedAt time.Time `json:"reported_at"` + UserAgent string `json:"user_agent"` + ClientIP string `json:"client_ip"` + CreatedAt time.Time `json:"created_at"` +} + +type ListVideoDownloadFailuresResult struct { + List []VideoDownloadFailureItem `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +func (s *Service) ListVideoParseLogs(ctx context.Context, query ListVideoParseLogsQuery) (*ListVideoParseLogsResult, error) { + query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) + query.Keyword = strings.TrimSpace(query.Keyword) + + dbQuery := s.db.WithContext(ctx).Model(&rmmodel.VideoParseLog{}) + if query.MiniProgramID > 0 { + dbQuery = dbQuery.Where("mini_program_id = ?", query.MiniProgramID) + } + if query.UserID > 0 { + dbQuery = dbQuery.Where("user_id = ?", query.UserID) + } + if query.FreeQuotaUsed != nil { + dbQuery = dbQuery.Where("free_quota_used = ?", *query.FreeQuotaUsed) + } + if query.Keyword != "" { + likeKeyword := "%" + query.Keyword + "%" + dbQuery = dbQuery.Where("request_content LIKE ? OR parsed_url LIKE ? OR error_message LIKE ?", likeKeyword, likeKeyword, likeKeyword) + } + if query.DateFrom != nil { + dbQuery = dbQuery.Where("created_at >= ?", *query.DateFrom) + } + if query.DateTo != nil { + dbQuery = dbQuery.Where("created_at <= ?", *query.DateTo) + } + + var total int64 + if err := dbQuery.Count(&total).Error; err != nil { + return nil, err + } + + rows := make([]rmmodel.VideoParseLog, 0) + if total > 0 { + if err := dbQuery.Order("id DESC"). + Limit(query.PageSize). + Offset((query.Page - 1) * query.PageSize). + Find(&rows).Error; err != nil { + return nil, err + } + } + + miniProgramNameMap, err := s.loadMiniProgramNameMap(ctx, extractMiniProgramIDsFromLogs(rows)) + if err != nil { + return nil, err + } + + items := make([]VideoParseLogItem, 0, len(rows)) + for _, row := range rows { + items = append(items, VideoParseLogItem{ + ID: row.ID, + MiniProgramID: row.MiniProgramID, + MiniProgramName: miniProgramNameMap[row.MiniProgramID], + UserID: row.UserID, + RequestContent: row.RequestContent, + ParsedURL: row.ParsedURL, + ThirdPartyStatus: row.ThirdPartyStatus, + FreeQuotaUsed: row.FreeQuotaUsed, + DurationMs: row.DurationMs, + ErrorMessage: row.ErrorMessage, + CreatedAt: row.CreatedAt, + ThirdPartyPayload: string(row.ThirdPartyPayload), + }) + } + + return &ListVideoParseLogsResult{ + List: items, + Total: total, + Page: query.Page, + PageSize: query.PageSize, + }, nil +} + +func (s *Service) ListVideoParseUnlocks(ctx context.Context, query ListVideoParseUnlocksQuery) (*ListVideoParseUnlocksResult, error) { + query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) + + dbQuery := s.db.WithContext(ctx).Model(&rmmodel.VideoParseUnlock{}) + if query.MiniProgramID > 0 { + dbQuery = dbQuery.Where("mini_program_id = ?", query.MiniProgramID) + } + if query.UserID > 0 { + dbQuery = dbQuery.Where("user_id = ?", query.UserID) + } + if query.DateFrom != nil { + dbQuery = dbQuery.Where("unlock_date >= ?", query.DateFrom.Format("2006-01-02")) + } + if query.DateTo != nil { + dbQuery = dbQuery.Where("unlock_date <= ?", query.DateTo.Format("2006-01-02")) + } + + var total int64 + if err := dbQuery.Count(&total).Error; err != nil { + return nil, err + } + + rows := make([]rmmodel.VideoParseUnlock, 0) + if total > 0 { + if err := dbQuery.Order("id DESC"). + Limit(query.PageSize). + Offset((query.Page - 1) * query.PageSize). + Find(&rows).Error; err != nil { + return nil, err + } + } + + miniProgramNameMap, err := s.loadMiniProgramNameMap(ctx, extractMiniProgramIDsFromUnlocks(rows)) + if err != nil { + return nil, err + } + + items := make([]VideoParseUnlockItem, 0, len(rows)) + for _, row := range rows { + items = append(items, VideoParseUnlockItem{ + ID: row.ID, + MiniProgramID: row.MiniProgramID, + MiniProgramName: miniProgramNameMap[row.MiniProgramID], + UserID: row.UserID, + UnlockDate: row.UnlockDate, + AdWatchedAt: row.AdWatchedAt, + CreatedAt: row.CreatedAt, + }) + } + + return &ListVideoParseUnlocksResult{ + List: items, + Total: total, + Page: query.Page, + PageSize: query.PageSize, + }, nil +} + +func (s *Service) ListVideoDownloadFailures(ctx context.Context, query ListVideoDownloadFailuresQuery) (*ListVideoDownloadFailuresResult, error) { + query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) + query.Domain = strings.TrimSpace(query.Domain) + query.Keyword = strings.TrimSpace(query.Keyword) + + dbQuery := s.db.WithContext(ctx).Model(&rmmodel.VideoDownloadFailure{}) + if query.Domain != "" { + dbQuery = dbQuery.Where("domain = ?", query.Domain) + } + if query.Keyword != "" { + likeKeyword := "%" + query.Keyword + "%" + dbQuery = dbQuery.Where("failed_url LIKE ? OR error_message LIKE ? OR user_agent LIKE ?", likeKeyword, likeKeyword, likeKeyword) + } + if query.DateFrom != nil { + dbQuery = dbQuery.Where("reported_at >= ?", *query.DateFrom) + } + if query.DateTo != nil { + dbQuery = dbQuery.Where("reported_at <= ?", *query.DateTo) + } + + var total int64 + if err := dbQuery.Count(&total).Error; err != nil { + return nil, err + } + + rows := make([]rmmodel.VideoDownloadFailure, 0) + if total > 0 { + if err := dbQuery.Order("id DESC"). + Limit(query.PageSize). + Offset((query.Page - 1) * query.PageSize). + Find(&rows).Error; err != nil { + return nil, err + } + } + + items := make([]VideoDownloadFailureItem, 0, len(rows)) + for _, row := range rows { + items = append(items, VideoDownloadFailureItem{ + ID: row.ID, + Domain: row.Domain, + FailedURL: row.FailedURL, + ErrorMessage: row.ErrorMessage, + ReportedAt: row.ReportedAt, + UserAgent: row.UserAgent, + ClientIP: row.ClientIP, + CreatedAt: row.CreatedAt, + }) + } + + return &ListVideoDownloadFailuresResult{ + List: items, + Total: total, + Page: query.Page, + PageSize: query.PageSize, + }, nil +} + +func (s *Service) loadMiniProgramNameMap(ctx context.Context, ids []uint) (map[uint]string, error) { + nameMap := map[uint]string{} + if len(ids) == 0 { + return nameMap, nil + } + + rows := make([]model.MiniProgram, 0) + if err := s.db.WithContext(ctx). + Select("id", "name"). + Where("id IN ?", ids). + Find(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + nameMap[row.ID] = row.Name + } + return nameMap, nil +} + +func extractMiniProgramIDsFromLogs(rows []rmmodel.VideoParseLog) []uint { + set := map[uint]struct{}{} + ids := make([]uint, 0, len(rows)) + for _, row := range rows { + if row.MiniProgramID == 0 { + continue + } + if _, ok := set[row.MiniProgramID]; ok { + continue + } + set[row.MiniProgramID] = struct{}{} + ids = append(ids, row.MiniProgramID) + } + return ids +} + +func extractMiniProgramIDsFromUnlocks(rows []rmmodel.VideoParseUnlock) []uint { + set := map[uint]struct{}{} + ids := make([]uint, 0, len(rows)) + for _, row := range rows { + if row.MiniProgramID == 0 { + continue + } + if _, ok := set[row.MiniProgramID]; ok { + continue + } + set[row.MiniProgramID] = struct{}{} + ids = append(ids, row.MiniProgramID) + } + return ids +} + +func normalizePage(page, pageSize int) (int, int) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + return page, pageSize +} diff --git a/internal/routes/admin_routes.go b/internal/routes/admin_routes.go index f1366f6..02e4875 100644 --- a/internal/routes/admin_routes.go +++ b/internal/routes/admin_routes.go @@ -48,6 +48,10 @@ func registerAdminRoutes( protected.PUT("/settings/system", handler.UpdateSystemConfig) protected.PUT("/settings/password", handler.UpdatePassword) + protected.GET("/watermark/video-parse-logs", handler.ListVideoParseLogs) + protected.GET("/watermark/video-parse-unlocks", handler.ListVideoParseUnlocks) + protected.GET("/watermark/video-download-failures", handler.ListVideoDownloadFailures) + protected.GET("/memberships/overview", handler.MembershipOverview) protected.GET("/memberships/redeem-codes", handler.ListMembershipRedeemCodes) protected.POST("/memberships/redeem-codes", handler.CreateMembershipRedeemCodes)