From 49c78506b29f82b5bba3e8a8c62f4563d6ea932b Mon Sep 17 00:00:00 2001 From: hxuanyu <2252193204@qq.com> Date: Fri, 30 Jan 2026 23:02:59 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E8=A1=A8=E9=87=8D?= =?UTF-8?q?=E6=96=B0=E8=AE=BE=E8=AE=A1=EF=BC=8C=E7=B2=BE=E7=AE=80=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=BB=93=E6=9E=84=E4=BB=A5=E5=8F=8A=E5=AD=98=E5=82=A8?= =?UTF-8?q?=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.example.yaml | 41 ++++--- docker-compose.yaml | 21 +--- internal/config/config.go | 4 +- internal/http/handlers/image.go | 148 ++++++++++++++++-------- internal/http/handlers/image_test.go | 42 +++++-- internal/model/models.go | 15 +-- internal/repo/db.go | 2 +- internal/repo/migration.go | 24 ++-- internal/service/fetcher/fetcher.go | 146 ++++++++++------------- internal/service/image/image_service.go | 134 +++++++++++---------- webapp/src/views/Home.vue | 101 +++++++++------- 11 files changed, 359 insertions(+), 319 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index ea340ef..ffab78e 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,7 +1,6 @@ server: port: 8080 base_url: "" - log: level: info filename: data/logs/app.log @@ -13,25 +12,20 @@ log: log_console: true show_db_log: false db_log_level: info - api: - mode: local # local | redirect - enable_mkt_fallback: true # 当请求的地区不存在时,是否回退到默认地区 - enable_on_demand_fetch: false # 是否开启按需抓取(当数据库中没有请求的地区图片时,实时从 Bing 抓取) - + mode: redirect + enable_mkt_fallback: false + enable_on_demand_fetch: false cron: enabled: true - daily_spec: "20 8-23/4 * * *" - + daily_spec: 20 8-23/4 * * * retention: days: 0 - db: - type: sqlite # sqlite | mysql | postgres + type: sqlite dsn: data/bing_paper.db - storage: - type: local # local | s3 | webdav + type: local local: root: data/picture s3: @@ -47,15 +41,28 @@ storage: username: "" password: "" public_url_prefix: "" - admin: - password_bcrypt: "$2a$10$fYHPeWHmwObephJvtlyH1O8DIgaLk5TINbi9BOezo2M8cSjmJchka" # 默认密码: admin123 - + password_bcrypt: $2a$10$fYHPeWHmwObephJvtlyH1O8DIgaLk5TINbi9BOezo2M8cSjmJchka token: default_ttl: 168h - feature: write_daily_files: true - web: path: web +fetcher: + regions: + - zh-CN + - en-US + - ja-JP + - en-AU + - en-GB + - de-DE + - en-NZ + - en-CA + - fr-FR + - it-IT + - es-ES + - pt-BR + - ko-KR + - en-IN + - ru-RU diff --git a/docker-compose.yaml b/docker-compose.yaml index 4d41b66..58aa6ef 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -14,23 +14,4 @@ services: environment: - TZ=${TZ:-Asia/Shanghai} - BINGPAPER_SERVER_PORT=${BINGPAPER_SERVER_PORT:-8080} - - BINGPAPER_LOG_LEVEL=${BINGPAPER_LOG_LEVEL:-info} - - BINGPAPER_API_MODE=${BINGPAPER_API_MODE:-local} - - BINGPAPER_CRON_ENABLED=${BINGPAPER_CRON_ENABLED:-true} - - BINGPAPER_DB_TYPE=${BINGPAPER_DB_TYPE:-sqlite} - - BINGPAPER_DB_DSN=${BINGPAPER_DB_DSN:-data/bing_paper.db} - - BINGPAPER_STORAGE_TYPE=${BINGPAPER_STORAGE_TYPE:-local} - - BINGPAPER_STORAGE_LOCAL_ROOT=${BINGPAPER_STORAGE_LOCAL_ROOT:-data/picture} - - BINGPAPER_RETENTION_DAYS=${BINGPAPER_RETENTION_DAYS:-30} - # S3 配置 (可选) - # - BINGPAPER_STORAGE_S3_ENDPOINT=${BINGPAPER_STORAGE_S3_ENDPOINT:-} - # - BINGPAPER_STORAGE_S3_REGION=${BINGPAPER_STORAGE_S3_REGION:-} - # - BINGPAPER_STORAGE_S3_BUCKET=${BINGPAPER_STORAGE_S3_BUCKET:-} - # - BINGPAPER_STORAGE_S3_ACCESS_KEY=${BINGPAPER_STORAGE_S3_ACCESS_KEY:-} - # - BINGPAPER_STORAGE_S3_SECRET_KEY=${BINGPAPER_STORAGE_S3_SECRET_KEY:-} - # - BINGPAPER_STORAGE_S3_PUBLIC_URL_PREFIX=${BINGPAPER_STORAGE_S3_PUBLIC_URL_PREFIX:-} - # WebDAV 配置 (可选) - # - BINGPAPER_STORAGE_WEBDAV_URL=${BINGPAPER_STORAGE_WEBDAV_URL:-} - # - BINGPAPER_STORAGE_WEBDAV_USERNAME=${BINGPAPER_STORAGE_WEBDAV_USERNAME:-} - # - BINGPAPER_STORAGE_WEBDAV_PASSWORD=${BINGPAPER_STORAGE_WEBDAV_PASSWORD:-} - # - BINGPAPER_STORAGE_WEBDAV_PUBLIC_URL_PREFIX=${BINGPAPER_STORAGE_WEBDAV_PUBLIC_URL_PREFIX:-} \ No newline at end of file + - BINGPAPER_LOG_LEVEL=${BINGPAPER_LOG_LEVEL:-info} \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index 38f2b24..3452782 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -330,8 +330,8 @@ func GetTokenTTL() time.Duration { return ttl } -// GetDefaultMkt 返回生效的默认地区编码 -func (c *Config) GetDefaultMkt() string { +// GetDefaultRegion 返回生效的默认地区编码 +func (c *Config) GetDefaultRegion() string { if len(c.Fetcher.Regions) > 0 { return c.Fetcher.Regions[0] } diff --git a/internal/http/handlers/image.go b/internal/http/handlers/image.go index 846e934..3b9068c 100644 --- a/internal/http/handlers/image.go +++ b/internal/http/handlers/image.go @@ -53,7 +53,7 @@ type ImageMetaResp struct { // @Router /image/today [get] func GetToday(c *gin.Context) { mkt := c.Query("mkt") - img, err := image.GetTodayImage(mkt) + imgRegion, err := image.GetTodayImage(mkt) if err == image.ErrFetchStarted { c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)}) return @@ -62,7 +62,7 @@ func GetToday(c *gin.Context) { sendImageNotFound(c, mkt) return } - handleImageResponse(c, img, 7200) // 2小时 + handleImageResponse(c, imgRegion, 7200) // 2小时 } // GetTodayMeta 获取今日图片元数据 @@ -77,7 +77,7 @@ func GetToday(c *gin.Context) { // @Router /image/today/meta [get] func GetTodayMeta(c *gin.Context) { mkt := c.Query("mkt") - img, err := image.GetTodayImage(mkt) + imgRegion, err := image.GetTodayImage(mkt) if err == image.ErrFetchStarted { c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)}) return @@ -87,7 +87,7 @@ func GetTodayMeta(c *gin.Context) { return } c.Header("Cache-Control", "public, max-age=7200") // 2小时 - c.JSON(http.StatusOK, formatMeta(img)) + c.JSON(http.StatusOK, formatMeta(imgRegion)) } // GetRandom 获取随机图片 @@ -102,9 +102,10 @@ func GetTodayMeta(c *gin.Context) { // @Success 202 {object} map[string]string "按需抓取任务已启动" // @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因" // @Router /image/random [get] +// GetRandom 获取随机图片 func GetRandom(c *gin.Context) { mkt := c.Query("mkt") - img, err := image.GetRandomImage(mkt) + imgRegion, err := image.GetRandomImage(mkt) if err == image.ErrFetchStarted { c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)}) return @@ -113,7 +114,7 @@ func GetRandom(c *gin.Context) { sendImageNotFound(c, mkt) return } - handleImageResponse(c, img, 0) // 禁用缓存 + handleImageResponse(c, imgRegion, 0) // 禁用缓存 } // GetRandomMeta 获取随机图片元数据 @@ -128,7 +129,7 @@ func GetRandom(c *gin.Context) { // @Router /image/random/meta [get] func GetRandomMeta(c *gin.Context) { mkt := c.Query("mkt") - img, err := image.GetRandomImage(mkt) + imgRegion, err := image.GetRandomImage(mkt) if err == image.ErrFetchStarted { c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)}) return @@ -138,7 +139,7 @@ func GetRandomMeta(c *gin.Context) { return } c.Header("Cache-Control", "no-cache, no-store, must-revalidate") - c.JSON(http.StatusOK, formatMeta(img)) + c.JSON(http.StatusOK, formatMeta(imgRegion)) } // GetByDate 获取指定日期图片 @@ -157,7 +158,7 @@ func GetRandomMeta(c *gin.Context) { func GetByDate(c *gin.Context) { date := c.Param("date") mkt := c.Query("mkt") - img, err := image.GetImageByDate(date, mkt) + imgRegion, err := image.GetImageByDate(date, mkt) if err == image.ErrFetchStarted { c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)}) return @@ -166,7 +167,7 @@ func GetByDate(c *gin.Context) { sendImageNotFound(c, mkt) return } - handleImageResponse(c, img, 604800) // 7天 + handleImageResponse(c, imgRegion, 604800) // 7天 } // GetByDateMeta 获取指定日期图片元数据 @@ -183,7 +184,7 @@ func GetByDate(c *gin.Context) { func GetByDateMeta(c *gin.Context) { date := c.Param("date") mkt := c.Query("mkt") - img, err := image.GetImageByDate(date, mkt) + imgRegion, err := image.GetImageByDate(date, mkt) if err == image.ErrFetchStarted { c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)}) return @@ -193,7 +194,7 @@ func GetByDateMeta(c *gin.Context) { return } c.Header("Cache-Control", "public, max-age=604800") // 7天 - c.JSON(http.StatusOK, formatMeta(img)) + c.JSON(http.StatusOK, formatMeta(imgRegion)) } // ListImages 获取图片列表 @@ -308,21 +309,21 @@ func sendImageNotFound(c *gin.Context, mkt string) { c.JSON(http.StatusNotFound, gin.H{"error": message}) } -func handleImageResponse(c *gin.Context, img *model.Image, maxAge int) { +func handleImageResponse(c *gin.Context, m *model.ImageRegion, maxAge int) { variant := c.DefaultQuery("variant", "UHD") format := c.DefaultQuery("format", "jpg") var selected *model.ImageVariant - for _, v := range img.Variants { + for _, v := range m.Variants { if v.Variant == variant && v.Format == format { selected = &v break } } - if selected == nil && len(img.Variants) > 0 { + if selected == nil && len(m.Variants) > 0 { // 回退逻辑 - selected = &img.Variants[0] + selected = &m.Variants[0] } if selected == nil { @@ -339,9 +340,9 @@ func handleImageResponse(c *gin.Context, img *model.Image, maxAge int) { c.Header("Cache-Control", "no-cache, no-store, must-revalidate") } c.Redirect(http.StatusFound, selected.PublicURL) - } else if img.URLBase != "" { + } else if m.URLBase != "" { // 兜底重定向到原始 Bing - bingURL := fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, selected.Variant) + bingURL := fmt.Sprintf("https://www.bing.com%s_%s.jpg", m.URLBase, selected.Variant) if maxAge > 0 { c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge)) } else { @@ -349,10 +350,10 @@ func handleImageResponse(c *gin.Context, img *model.Image, maxAge int) { } c.Redirect(http.StatusFound, bingURL) } else { - serveLocal(c, selected.StorageKey, img.Date, maxAge) + serveLocal(c, selected.StorageKey, m.Date, maxAge) } } else { - serveLocal(c, selected.StorageKey, img.Date, maxAge) + serveLocal(c, selected.StorageKey, m.Date, maxAge) } } @@ -385,25 +386,36 @@ func serveLocal(c *gin.Context, key string, etag string, maxAge int) { io.Copy(c.Writer, reader) } -func formatMetaSummary(img *model.Image) gin.H { +func formatMetaSummary(m *model.ImageRegion) gin.H { cfg := config.GetConfig() - // 找到最小的变体(Size 最小) + // 找到最小的变体 var smallest *model.ImageVariant - for i := range img.Variants { - v := &img.Variants[i] - if smallest == nil || v.Size < smallest.Size { + for i := range m.Variants { + v := &m.Variants[i] + if smallest == nil { smallest = v + continue + } + + // 如果当前变体 Size 更小且不为 0,或者 smallest 的 Size 为 0 + if v.Size > 0 && (smallest.Size == 0 || v.Size < smallest.Size) { + smallest = v + } else if v.Size == smallest.Size { + // 如果 Size 相同(包括都为 0),根据分辨率名称判断 + if compareResolution(v.Variant, smallest.Variant) < 0 { + smallest = v + } } } variants := []gin.H{} if smallest != nil { url := smallest.PublicURL - if url == "" && cfg.API.Mode == "redirect" && img.URLBase != "" { - url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, smallest.Variant) + if url == "" && cfg.API.Mode == "redirect" && m.URLBase != "" { + url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", m.URLBase, smallest.Variant) } else if cfg.API.Mode == "local" || url == "" { - url = fmt.Sprintf("%s/api/v1/image/date/%s?variant=%s&format=%s&mkt=%s", cfg.Server.BaseURL, img.Date, smallest.Variant, smallest.Format, img.Mkt) + url = fmt.Sprintf("%s/api/v1/image/date/%s?variant=%s&format=%s&mkt=%s", cfg.Server.BaseURL, m.Date, smallest.Variant, smallest.Format, m.Mkt) } variants = append(variants, gin.H{ "variant": smallest.Variant, @@ -415,28 +427,28 @@ func formatMetaSummary(img *model.Image) gin.H { } return gin.H{ - "date": img.Date, - "mkt": img.Mkt, - "title": img.Title, - "copyright": img.Copyright, - "copyrightlink": img.CopyrightLink, - "quiz": img.Quiz, - "startdate": img.StartDate, - "fullstartdate": img.FullStartDate, - "hsh": img.HSH, + "date": m.Date, + "mkt": m.Mkt, + "title": m.Title, + "copyright": m.Copyright, + "copyrightlink": m.CopyrightLink, + "quiz": m.Quiz, + "startdate": m.StartDate, + "fullstartdate": m.FullStartDate, + "hsh": m.HSH, "variants": variants, } } -func formatMeta(img *model.Image) gin.H { +func formatMeta(m *model.ImageRegion) gin.H { cfg := config.GetConfig() variants := []gin.H{} - for _, v := range img.Variants { + for _, v := range m.Variants { url := v.PublicURL - if url == "" && cfg.API.Mode == "redirect" && img.URLBase != "" { - url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, v.Variant) + if url == "" && cfg.API.Mode == "redirect" && m.URLBase != "" { + url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", m.URLBase, v.Variant) } else if cfg.API.Mode == "local" || url == "" { - url = fmt.Sprintf("%s/api/v1/image/date/%s?variant=%s&format=%s&mkt=%s", cfg.Server.BaseURL, img.Date, v.Variant, v.Format, img.Mkt) + url = fmt.Sprintf("%s/api/v1/image/date/%s?variant=%s&format=%s&mkt=%s", cfg.Server.BaseURL, m.Date, v.Variant, v.Format, m.Mkt) } variants = append(variants, gin.H{ "variant": v.Variant, @@ -448,15 +460,15 @@ func formatMeta(img *model.Image) gin.H { } return gin.H{ - "date": img.Date, - "mkt": img.Mkt, - "title": img.Title, - "copyright": img.Copyright, - "copyrightlink": img.CopyrightLink, - "quiz": img.Quiz, - "startdate": img.StartDate, - "fullstartdate": img.FullStartDate, - "hsh": img.HSH, + "date": m.Date, + "mkt": m.Mkt, + "title": m.Title, + "copyright": m.Copyright, + "copyrightlink": m.CopyrightLink, + "quiz": m.Quiz, + "startdate": m.StartDate, + "fullstartdate": m.FullStartDate, + "hsh": m.HSH, "variants": variants, } } @@ -520,3 +532,37 @@ func GetRegions(c *gin.Context) { c.JSON(http.StatusOK, result) } + +// compareResolution 比较两个分辨率变体的大小。 +// 返回 < 0 表示 v1 < v2,返回 > 0 表示 v1 > v2,返回 0 表示相等。 +func compareResolution(v1, v2 string) int { + resOrder := map[string]int{ + "320x240": 1, + "400x240": 2, + "480x360": 3, + "640x360": 4, + "640x480": 5, + "800x480": 6, + "800x600": 7, + "1024x768": 8, + "1280x720": 9, + "1366x768": 10, + "1920x1080": 11, + "UHD": 12, + } + + o1, ok1 := resOrder[v1] + o2, ok2 := resOrder[v2] + + if !ok1 && !ok2 { + return strings.Compare(v1, v2) + } + if !ok1 { + return 1 // 未知的分辨率认为比已知的大 + } + if !ok2 { + return -1 + } + + return o1 - o2 +} diff --git a/internal/http/handlers/image_test.go b/internal/http/handlers/image_test.go index 1617caa..d4324fb 100644 --- a/internal/http/handlers/image_test.go +++ b/internal/http/handlers/image_test.go @@ -21,11 +21,15 @@ func TestHandleImageResponseRedirect(t *testing.T) { config.GetConfig().API.Mode = "redirect" // Mock Image and Variant - img := &model.Image{ - Date: "2026-01-26", - URLBase: "/th?id=OHR.TestImage", + imgRegion := &model.ImageRegion{ + Date: "2026-01-26", + Mkt: "zh-CN", + HSH: "testhsh", + ImageName: "TestImage", + URLBase: "/th?id=OHR.TestImage", Variants: []model.ImageVariant{ { + ImageName: "TestImage", Variant: "UHD", Format: "jpg", PublicURL: "", // Empty for local storage simulation @@ -39,7 +43,7 @@ func TestHandleImageResponseRedirect(t *testing.T) { c, _ := gin.CreateTestContext(w) c.Request, _ = http.NewRequest("GET", "/api/v1/image/today?variant=UHD", nil) - handleImageResponse(c, img, 0) + handleImageResponse(c, imgRegion, 0) assert.Equal(t, http.StatusFound, w.Code) assert.Contains(t, w.Header().Get("Location"), "bing.com") @@ -48,7 +52,7 @@ func TestHandleImageResponseRedirect(t *testing.T) { t.Run("FormatMeta in redirect mode should return Bing URL if PublicURL is empty", func(t *testing.T) { config.GetConfig().API.Mode = "redirect" - meta := formatMeta(img) + meta := formatMeta(imgRegion) variants := meta["variants"].([]gin.H) assert.Equal(t, 1, len(variants)) @@ -59,7 +63,7 @@ func TestHandleImageResponseRedirect(t *testing.T) { t.Run("FormatMeta in local mode should return API URL", func(t *testing.T) { config.GetConfig().API.Mode = "local" config.GetConfig().Server.BaseURL = "http://myserver.com" - meta := formatMeta(img) + meta := formatMeta(imgRegion) variants := meta["variants"].([]gin.H) assert.Equal(t, 1, len(variants)) @@ -68,12 +72,13 @@ func TestHandleImageResponseRedirect(t *testing.T) { }) t.Run("FormatMetaSummary should only return the smallest variant", func(t *testing.T) { - imgWithMultipleVariants := &model.Image{ - Date: "2026-01-26", + imgWithMultipleVariants := &model.ImageRegion{ + Date: "2026-01-26", + ImageName: "TestImage2", Variants: []model.ImageVariant{ - {Variant: "UHD", Size: 1000, Format: "jpg"}, - {Variant: "640x480", Size: 200, Format: "jpg"}, - {Variant: "1920x1080", Size: 500, Format: "jpg"}, + {ImageName: "TestImage2", Variant: "UHD", Size: 1000, Format: "jpg"}, + {ImageName: "TestImage2", Variant: "640x480", Size: 200, Format: "jpg"}, + {ImageName: "TestImage2", Variant: "1920x1080", Size: 500, Format: "jpg"}, }, } meta := formatMetaSummary(imgWithMultipleVariants) @@ -81,6 +86,21 @@ func TestHandleImageResponseRedirect(t *testing.T) { assert.Equal(t, 1, len(variants)) assert.Equal(t, "640x480", variants[0]["variant"]) }) + + t.Run("FormatMetaSummary should handle zero size by following order if names suggest it", func(t *testing.T) { + imgWithZeroSize := &model.ImageRegion{ + Date: "2026-01-26", + ImageName: "TestImage3", + Variants: []model.ImageVariant{ + {ImageName: "TestImage3", Variant: "UHD", Size: 0, Format: "jpg"}, + {ImageName: "TestImage3", Variant: "320x240", Size: 0, Format: "jpg"}, + }, + } + meta := formatMetaSummary(imgWithZeroSize) + variants := meta["variants"].([]gin.H) + assert.Equal(t, 1, len(variants)) + assert.Equal(t, "320x240", variants[0]["variant"]) + }) } func TestGetRegions(t *testing.T) { diff --git a/internal/model/models.go b/internal/model/models.go index 2d46832..2dd0212 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -6,29 +6,30 @@ import ( "gorm.io/gorm" ) -type Image struct { +type ImageRegion struct { ID uint `gorm:"primaryKey" json:"id"` Date string `gorm:"uniqueIndex:idx_date_mkt;index:idx_mkt_date,priority:2;type:varchar(10)" json:"date"` // YYYY-MM-DD Mkt string `gorm:"uniqueIndex:idx_date_mkt;index:idx_mkt_date,priority:1;type:varchar(10)" json:"mkt"` // zh-CN, en-US etc. + HSH string `gorm:"type:varchar(64)" json:"hsh"` + URLBase string `json:"urlbase"` + ImageName string `gorm:"index" json:"image_name"` Title string `json:"title"` Copyright string `json:"copyright"` CopyrightLink string `json:"copyrightlink"` - URLBase string `json:"urlbase"` Quiz string `json:"quiz"` StartDate string `json:"startdate"` FullStartDate string `json:"fullstartdate"` - HSH string `json:"hsh"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - Variants []ImageVariant `gorm:"foreignKey:ImageID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;" json:"variants"` + Variants []ImageVariant `gorm:"foreignKey:ImageName;references:ImageName" json:"variants"` } type ImageVariant struct { ID uint `gorm:"primaryKey" json:"id"` - ImageID uint `gorm:"index;uniqueIndex:idx_image_variant_format" json:"image_id"` - Variant string `gorm:"uniqueIndex:idx_image_variant_format;type:varchar(20)" json:"variant"` // UHD, 1920x1080, etc. - Format string `gorm:"uniqueIndex:idx_image_variant_format;type:varchar(10)" json:"format"` // jpg, webp + ImageName string `gorm:"uniqueIndex:idx_name_variant_format;type:varchar(100)" json:"image_name"` + Variant string `gorm:"uniqueIndex:idx_name_variant_format;type:varchar(20)" json:"variant"` // UHD, 1920x1080, etc. + Format string `gorm:"uniqueIndex:idx_name_variant_format;type:varchar(10)" json:"format"` // jpg, webp StorageKey string `json:"storage_key"` PublicURL string `json:"public_url"` Size int64 `json:"size"` diff --git a/internal/repo/db.go b/internal/repo/db.go index 9f3abbc..aecfc0b 100644 --- a/internal/repo/db.go +++ b/internal/repo/db.go @@ -131,7 +131,7 @@ func InitDB() error { // 但此处假设 DSN 中指定的数据库已经存在。AutoMigrate 会负责创建表。 // 迁移 - if err := db.AutoMigrate(&model.Image{}, &model.ImageVariant{}, &model.Token{}); err != nil { + if err := db.AutoMigrate(&model.ImageRegion{}, &model.ImageVariant{}, &model.Token{}); err != nil { util.Logger.Error("Database migration failed", zap.Error(err)) return err } diff --git a/internal/repo/migration.go b/internal/repo/migration.go index 698dde2..5655f12 100644 --- a/internal/repo/migration.go +++ b/internal/repo/migration.go @@ -29,19 +29,17 @@ func MigrateDataToNewDB(oldDB *gorm.DB, newConfig *config.Config) error { } // 2. 自动迁移结构 - if err := newDB.AutoMigrate(&model.Image{}, &model.ImageVariant{}, &model.Token{}); err != nil { + if err := newDB.AutoMigrate(&model.ImageRegion{}, &model.ImageVariant{}, &model.Token{}); err != nil { return fmt.Errorf("failed to migrate schema in new DB: %w", err) } // 3. 清空新数据库中的现有数据(防止冲突) util.Logger.Info("Cleaning up destination database before migration") - // 备份或清空目标数据库。由于用户要求“可能需要清空或备份”, - // 这里我们选择在迁移前清空目标表,以确保迁移过来的数据是完整且不冲突的。 if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.ImageVariant{}).Error; err != nil { return fmt.Errorf("failed to clear ImageVariants: %w", err) } - if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.Image{}).Error; err != nil { - return fmt.Errorf("failed to clear Images: %w", err) + if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.ImageRegion{}).Error; err != nil { + return fmt.Errorf("failed to clear ImageRegions: %w", err) } if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.Token{}).Error; err != nil { return fmt.Errorf("failed to clear Tokens: %w", err) @@ -50,15 +48,15 @@ func MigrateDataToNewDB(oldDB *gorm.DB, newConfig *config.Config) error { // 4. 开始迁移数据 // 使用事务确保迁移的原子性 return newDB.Transaction(func(tx *gorm.DB) error { - // 迁移 Images - var images []model.Image - if err := oldDB.Find(&images).Error; err != nil { - return fmt.Errorf("failed to fetch images from old DB: %w", err) + // 迁移 ImageRegions + var regions []model.ImageRegion + if err := oldDB.Find(®ions).Error; err != nil { + return fmt.Errorf("failed to fetch image regions from old DB: %w", err) } - if len(images) > 0 { - util.Logger.Info("Migrating images", zap.Int("count", len(images))) - if err := tx.Create(&images).Error; err != nil { - return fmt.Errorf("failed to insert images into new DB: %w", err) + if len(regions) > 0 { + util.Logger.Info("Migrating image regions", zap.Int("count", len(regions))) + if err := tx.Create(®ions).Error; err != nil { + return fmt.Errorf("failed to insert image regions into new DB: %w", err) } } diff --git a/internal/service/fetcher/fetcher.go b/internal/service/fetcher/fetcher.go index baaefa8..b95aafe 100644 --- a/internal/service/fetcher/fetcher.go +++ b/internal/service/fetcher/fetcher.go @@ -58,7 +58,7 @@ func (f *Fetcher) Fetch(ctx context.Context, n int) error { util.Logger.Info("Starting fetch task", zap.Int("n", n)) regions := config.GetConfig().Fetcher.Regions if len(regions) == 0 { - regions = []string{config.GetConfig().GetDefaultMkt()} + regions = []string{config.GetConfig().GetDefaultRegion()} } for _, mkt := range regions { @@ -122,51 +122,17 @@ func (f *Fetcher) fetchByMkt(ctx context.Context, mkt string, idx int, n int) er func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage, mkt string) error { dateStr := fmt.Sprintf("%s-%s-%s", bingImg.Enddate[0:4], bingImg.Enddate[4:6], bingImg.Enddate[6:8]) - // 幂等检查 - var existing model.Image - if err := repo.DB.Where("date = ? AND mkt = ?", dateStr, mkt).First(&existing).Error; err == nil { - util.Logger.Debug("Image already exists in DB, skipping", zap.String("date", dateStr), zap.String("mkt", mkt)) + // 1. 地区关联幂等检查 + var existingRegion model.ImageRegion + if err := repo.DB.Where("date = ? AND mkt = ?", dateStr, mkt).First(&existingRegion).Error; err == nil { + util.Logger.Debug("ImageRegion record already exists, skipping", zap.String("date", dateStr), zap.String("mkt", mkt)) return nil } imageName := f.extractImageName(bingImg.URLBase, bingImg.HSH) - util.Logger.Info("Processing image", zap.String("date", dateStr), zap.String("mkt", mkt), zap.String("imageName", imageName)) - // 创建 DB 记录 - dbImg := model.Image{ - Date: dateStr, - Mkt: mkt, - Title: bingImg.Title, - Copyright: bingImg.Copyright, - CopyrightLink: bingImg.CopyrightLink, - URLBase: bingImg.URLBase, - Quiz: bingImg.Quiz, - StartDate: bingImg.Startdate, - FullStartDate: bingImg.Fullstartdate, - HSH: bingImg.HSH, - } - - if err := repo.DB.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "date"}, {Name: "mkt"}}, - DoNothing: true, - }).Create(&dbImg).Error; err != nil { - util.Logger.Error("Failed to create image record", zap.Error(err)) - return err - } - - if dbImg.ID == 0 { - var existing model.Image - if err := repo.DB.Where("date = ? AND mkt = ?", dateStr, mkt).First(&existing).Error; err != nil { - util.Logger.Error("Failed to query existing image record after conflict", zap.Error(err)) - return err - } - dbImg = existing - } - - // UHD 探测 + // 2. 处理变体 imgURL, variantName := f.probeUHD(bingImg.URLBase) - - // 保存各种分辨率 targetVariants := []struct { name string width int @@ -185,54 +151,34 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage, mkt strin {"320x240", 320, 240}, } - // 检查是否所有变体都已存在于存储中 - allExist := true - // 检查 UHD/原图 - uhdKey := f.generateKey(imageName, variantName, "jpg") - exists, _ := storage.GlobalStorage.Exists(ctx, uhdKey) - if !exists { - allExist = false - } else { - for _, v := range targetVariants { - if v.name == variantName { - continue - } - vKey := f.generateKey(imageName, v.name, "jpg") - exists, _ := storage.GlobalStorage.Exists(ctx, vKey) - if !exists { - allExist = false - break - } - } - } + // 检查变体是否已存在 (通过 ImageName) + var existingVariants []model.ImageVariant + repo.DB.Where("image_name = ?", imageName).Find(&existingVariants) - if allExist { - util.Logger.Debug("All image variants exist in storage, linking only", zap.String("imageName", imageName)) - // 只建立关联信息 - f.saveVariant(ctx, &dbImg, imageName, variantName, "jpg", nil) - for _, v := range targetVariants { - if v.name == variantName { - continue - } - f.saveVariant(ctx, &dbImg, imageName, v.name, "jpg", nil) - } + allVariantsExist := len(existingVariants) > 0 + + var srcImg image.Image + var imgData []byte + + if allVariantsExist { + util.Logger.Debug("Image variants already exist for name, linking only", zap.String("imageName", imageName)) } else { - // 需要下载并处理 - util.Logger.Debug("Downloading and processing image", zap.String("url", imgURL)) - imgData, err := f.downloadImage(imgURL) + util.Logger.Debug("Downloading and processing image", zap.String("url", imgURL), zap.String("imageName", imageName)) + var err error + imgData, err = f.downloadImage(imgURL) if err != nil { util.Logger.Error("Failed to download image", zap.String("url", imgURL), zap.Error(err)) return err } - srcImg, _, err := image.Decode(bytes.NewReader(imgData)) + srcImg, _, err = image.Decode(bytes.NewReader(imgData)) if err != nil { util.Logger.Error("Failed to decode image data", zap.Error(err)) return err } - // 保存原图 - if err := f.saveVariant(ctx, &dbImg, imageName, variantName, "jpg", imgData); err != nil { + // 保存原图变体 + if err := f.saveVariant(ctx, imageName, variantName, "jpg", imgData); err != nil { util.Logger.Error("Failed to save original variant", zap.String("variant", variantName), zap.Error(err)) } @@ -247,14 +193,39 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage, mkt strin continue } currentImgData := buf.Bytes() - if err := f.saveVariant(ctx, &dbImg, imageName, v.name, "jpg", currentImgData); err != nil { + if err := f.saveVariant(ctx, imageName, v.name, "jpg", currentImgData); err != nil { util.Logger.Error("Failed to save variant", zap.String("variant", v.name), zap.Error(err)) } } + } - // 保存今日额外文件 - today := time.Now().Format("2006-01-02") - if dateStr == today && config.GetConfig().Feature.WriteDailyFiles { + // 3. 创建 ImageRegion 记录 + regionRecord := model.ImageRegion{ + HSH: bingImg.HSH, + URLBase: bingImg.URLBase, + ImageName: imageName, + Date: dateStr, + Mkt: mkt, + Title: bingImg.Title, + Copyright: bingImg.Copyright, + CopyrightLink: bingImg.CopyrightLink, + Quiz: bingImg.Quiz, + StartDate: bingImg.Startdate, + FullStartDate: bingImg.Fullstartdate, + } + + if err := repo.DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "date"}, {Name: "mkt"}}, + UpdateAll: true, + }).Create(®ionRecord).Error; err != nil { + util.Logger.Error("Failed to create region record", zap.Error(err)) + return err + } + + // 4. 保存今日额外文件 + today := time.Now().Format("2006-01-02") + if dateStr == today && config.GetConfig().Feature.WriteDailyFiles { + if imgData != nil && srcImg != nil { f.saveDailyFiles(srcImg, imgData, mkt) } } @@ -306,7 +277,7 @@ func (f *Fetcher) generateKey(imageName, variant, format string) string { return fmt.Sprintf("%s/%s_%s.%s", imageName, imageName, variant, format) } -func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, imageName, variant, format string, data []byte) error { +func (f *Fetcher) saveVariant(ctx context.Context, imageName, variant, format string, data []byte) error { key := f.generateKey(imageName, variant, format) contentType := "image/jpeg" if format == "webp" { @@ -319,13 +290,12 @@ func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, imageName, exists, _ := storage.GlobalStorage.Exists(ctx, key) if exists { util.Logger.Debug("Variant already exists in storage, linking", zap.String("key", key)) - // 如果存在,我们需要获取它的大小和公共 URL (如果可能) - // 但目前的 Storage 接口没有 Stat,我们可以尝试 Get 或者干脆 size 为 0 - // 为了简单,我们只从存储中获取公共 URL + // 如果存在,尝试获取公共 URL if pURL, ok := storage.GlobalStorage.PublicURL(key); ok { publicURL = pURL } - // size 暂时设为 0 或者从 data 中取 (如果有的话) + + // 如果传入了数据,则使用数据大小 if data != nil { size = int64(len(data)) } @@ -342,7 +312,7 @@ func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, imageName, } vRecord := model.ImageVariant{ - ImageID: img.ID, + ImageName: imageName, Variant: variant, Format: format, StorageKey: key, @@ -351,7 +321,7 @@ func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, imageName, } return repo.DB.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "image_id"}, {Name: "variant"}, {Name: "format"}}, + Columns: []clause.Column{{Name: "image_name"}, {Name: "variant"}, {Name: "format"}}, DoNothing: true, }).Create(&vRecord).Error } @@ -387,7 +357,7 @@ func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte, mkt st // 同时也保留一份在根目录下(兼容旧逻辑,且作为默认地区图片) // 如果是默认地区或者是第一个抓取的地区,可以覆盖根目录的文件 - if mkt == config.GetConfig().GetDefaultMkt() { + if mkt == config.GetConfig().GetDefaultRegion() { jpegPathRoot := filepath.Join(localRoot, "daily.jpeg") fJpegRoot, err := os.Create(jpegPathRoot) if err == nil { diff --git a/internal/service/image/image_service.go b/internal/service/image/image_service.go index e29ad6c..ff7c6e3 100644 --- a/internal/service/image/image_service.go +++ b/internal/service/image/image_service.go @@ -15,6 +15,7 @@ import ( "BingPaper/internal/util" "go.uber.org/zap" + "gorm.io/gorm" ) var ErrFetchStarted = errors.New("on-demand fetch started") @@ -28,42 +29,53 @@ func CleanupOldImages(ctx context.Context) error { threshold := time.Now().AddDate(0, 0, -days).Format("2006-01-02") util.Logger.Info("Starting cleanup task", zap.Int("retention_days", days), zap.String("threshold", threshold)) - var images []model.Image - if err := repo.DB.Where("date < ?", threshold).Preload("Variants").Find(&images).Error; err != nil { - util.Logger.Error("Failed to query old images for cleanup", zap.Error(err)) + var regionRecords []model.ImageRegion + if err := repo.DB.Where("date < ?", threshold).Preload("Variants").Find(®ionRecords).Error; err != nil { + util.Logger.Error("Failed to query old image regions for cleanup", zap.Error(err)) return err } - for _, img := range images { - util.Logger.Info("Deleting old image", zap.String("date", img.Date)) - for _, v := range img.Variants { - if err := storage.GlobalStorage.Delete(ctx, v.StorageKey); err != nil { - util.Logger.Warn("Failed to delete storage object", zap.String("key", v.StorageKey), zap.Error(err)) + for _, m := range regionRecords { + util.Logger.Info("Deleting old image region record", zap.String("date", m.Date), zap.String("mkt", m.Mkt)) + + // 检查该图片名是否还有其他地区或日期在使用 + var count int64 + repo.DB.Model(&model.ImageRegion{}).Where("image_name = ? AND id != ?", m.ImageName, m.ID).Count(&count) + + if count == 0 { + util.Logger.Info("Image content no longer referenced, deleting files and variants", zap.String("image_name", m.ImageName)) + for _, v := range m.Variants { + if err := storage.GlobalStorage.Delete(ctx, v.StorageKey); err != nil { + util.Logger.Warn("Failed to delete storage object", zap.String("key", v.StorageKey), zap.Error(err)) + } + } + // 删除变体记录 + if err := repo.DB.Where("image_name = ?", m.ImageName).Delete(&model.ImageVariant{}).Error; err != nil { + util.Logger.Error("Failed to delete variants", zap.String("image_name", m.ImageName), zap.Error(err)) } } - // 删除关联记录(逻辑外键控制) - if err := repo.DB.Where("image_id = ?", img.ID).Delete(&model.ImageVariant{}).Error; err != nil { - util.Logger.Error("Failed to delete variants", zap.Uint("image_id", img.ID), zap.Error(err)) - } - // 删除主表记录 - if err := repo.DB.Delete(&img).Error; err != nil { - util.Logger.Error("Failed to delete image", zap.Uint("id", img.ID), zap.Error(err)) + + // 删除地区记录 + if err := repo.DB.Delete(&m).Error; err != nil { + util.Logger.Error("Failed to delete image region record", zap.Uint("id", m.ID), zap.Error(err)) } } - util.Logger.Info("Cleanup task completed", zap.Int("deleted_count", len(images))) + util.Logger.Info("Cleanup task completed", zap.Int("deleted_count", len(regionRecords))) return nil } -func GetTodayImage(mkt string) (*model.Image, error) { +func GetTodayImage(mkt string) (*model.ImageRegion, error) { today := time.Now().Format("2006-01-02") util.Logger.Debug("Getting today image", zap.String("mkt", mkt), zap.String("today", today)) - var img model.Image + var imgRegion model.ImageRegion tx := repo.DB.Where("date = ?", today) if mkt != "" { tx = tx.Where("mkt = ?", mkt) } - err := tx.Preload("Variants").First(&img).Error + err := tx.Preload("Variants", func(db *gorm.DB) *gorm.DB { + return db.Order("size asc") + }).First(&imgRegion).Error if err != nil && mkt != "" && config.GetConfig().API.EnableOnDemandFetch && util.IsValidRegion(mkt) { // 如果没找到,尝试异步按需抓取该地区 util.Logger.Info("Image not found in DB, starting asynchronous on-demand fetch", zap.String("mkt", mkt)) @@ -81,13 +93,15 @@ func GetTodayImage(mkt string) (*model.Image, error) { if mkt != "" { tx = tx.Where("mkt = ?", mkt) } - err = tx.Preload("Variants").First(&img).Error + err = tx.Preload("Variants", func(db *gorm.DB) *gorm.DB { + return db.Order("size asc") + }).First(&imgRegion).Error } - // 兜底逻辑:如果指定地区没找到,且开启了兜底开关,则尝试获取默认地区的图片 + // 兜底逻辑 if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback { - defaultMkt := config.GetConfig().GetDefaultMkt() - util.Logger.Debug("Image not found, trying fallback to default market", zap.String("mkt", mkt), zap.String("defaultMkt", defaultMkt)) + defaultMkt := config.GetConfig().GetDefaultRegion() + util.Logger.Debug("Image not found, trying fallback to default region", zap.String("mkt", mkt), zap.String("defaultMkt", defaultMkt)) if mkt != defaultMkt { return GetTodayImage(defaultMkt) } @@ -95,18 +109,18 @@ func GetTodayImage(mkt string) (*model.Image, error) { } if err == nil { - util.Logger.Debug("Found image", zap.String("date", img.Date), zap.String("mkt", img.Mkt)) + util.Logger.Debug("Found image region record", zap.String("date", imgRegion.Date), zap.String("mkt", imgRegion.Mkt)) } - return &img, err + return &imgRegion, err } -func GetAllRegionsTodayImages() ([]model.Image, error) { +func GetAllRegionsTodayImages() ([]model.ImageRegion, error) { regions := config.GetConfig().Fetcher.Regions if len(regions) == 0 { - regions = []string{config.GetConfig().GetDefaultMkt()} + regions = []string{config.GetConfig().GetDefaultRegion()} } - var images []model.Image + var images []model.ImageRegion for _, mkt := range regions { img, err := GetTodayImage(mkt) if err == nil { @@ -116,19 +130,16 @@ func GetAllRegionsTodayImages() ([]model.Image, error) { return images, nil } -func GetRandomImage(mkt string) (*model.Image, error) { +func GetRandomImage(mkt string) (*model.ImageRegion, error) { util.Logger.Debug("Getting random image", zap.String("mkt", mkt)) - var img model.Image - // SQLite 使用 RANDOM(), MySQL/Postgres 使用 RANDOM() 或 RAND() - // 简单起见,先查总数再 Offset + var imgRegion model.ImageRegion var count int64 - tx := repo.DB.Model(&model.Image{}) + tx := repo.DB.Model(&model.ImageRegion{}) if mkt != "" { tx = tx.Where("mkt = ?", mkt) } tx.Count(&count) if count == 0 && mkt != "" && config.GetConfig().API.EnableOnDemandFetch && util.IsValidRegion(mkt) { - // 如果没找到,尝试异步按需抓取该地区 util.Logger.Info("No images found in DB for region, starting asynchronous on-demand fetch", zap.String("mkt", mkt)) f := fetcher.NewFetcher() go func() { @@ -141,15 +152,14 @@ func GetRandomImage(mkt string) (*model.Image, error) { return nil, fmt.Errorf("no images found") } - // 优化随机查询:使用 Offset 代替 ORDER BY RANDOM() - // 注意:tx 包含了前面的 Where 条件 offset := rand.Intn(int(count)) util.Logger.Debug("Random image selection", zap.Int64("total", count), zap.Int("offset", offset)) - err := tx.Preload("Variants").Offset(offset).Limit(1).Find(&img).Error + err := tx.Preload("Variants", func(db *gorm.DB) *gorm.DB { + return db.Order("size asc") + }).Offset(offset).Limit(1).Find(&imgRegion).Error - // 兜底逻辑 - if (err != nil || img.ID == 0) && mkt != "" && config.GetConfig().API.EnableMktFallback { - defaultMkt := config.GetConfig().GetDefaultMkt() + if (err != nil || imgRegion.ID == 0) && mkt != "" && config.GetConfig().API.EnableMktFallback { + defaultMkt := config.GetConfig().GetDefaultRegion() util.Logger.Debug("Random image not found, trying fallback", zap.String("mkt", mkt), zap.String("defaultMkt", defaultMkt)) if mkt != defaultMkt { return GetRandomImage(defaultMkt) @@ -157,27 +167,24 @@ func GetRandomImage(mkt string) (*model.Image, error) { return GetRandomImage("") } - if err == nil && img.ID == 0 { + if err == nil && imgRegion.ID == 0 { return nil, fmt.Errorf("no images found") } - if err == nil { - util.Logger.Debug("Found random image", zap.String("date", img.Date), zap.String("mkt", img.Mkt)) - } - - return &img, err + return &imgRegion, err } -func GetImageByDate(date string, mkt string) (*model.Image, error) { +func GetImageByDate(date string, mkt string) (*model.ImageRegion, error) { util.Logger.Debug("Getting image by date", zap.String("date", date), zap.String("mkt", mkt)) - var img model.Image + var imgRegion model.ImageRegion tx := repo.DB.Where("date = ?", date) if mkt != "" { tx = tx.Where("mkt = ?", mkt) } - err := tx.Preload("Variants").First(&img).Error + err := tx.Preload("Variants", func(db *gorm.DB) *gorm.DB { + return db.Order("size asc") + }).First(&imgRegion).Error if err != nil && mkt != "" && config.GetConfig().API.EnableOnDemandFetch && util.IsValidRegion(mkt) { - // 如果没找到,尝试异步按需抓取该地区 util.Logger.Info("Image not found in DB for date, starting asynchronous on-demand fetch", zap.String("mkt", mkt), zap.String("date", date)) f := fetcher.NewFetcher() go func() { @@ -186,39 +193,31 @@ func GetImageByDate(date string, mkt string) (*model.Image, error) { return nil, ErrFetchStarted } - // 兜底逻辑 if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback { - defaultMkt := config.GetConfig().GetDefaultMkt() - util.Logger.Debug("Image by date not found, trying fallback", zap.String("date", date), zap.String("mkt", mkt), zap.String("defaultMkt", defaultMkt)) + defaultMkt := config.GetConfig().GetDefaultRegion() if mkt != defaultMkt { return GetImageByDate(date, defaultMkt) } return GetImageByDate(date, "") } - if err == nil { - util.Logger.Debug("Found image by date", zap.String("date", img.Date), zap.String("mkt", img.Mkt)) - } - return &img, err + return &imgRegion, err } -func GetImageList(limit int, offset int, month string, mkt string) ([]model.Image, error) { - util.Logger.Debug("Getting image list", zap.Int("limit", limit), zap.Int("offset", offset), zap.String("month", month), zap.String("mkt", mkt)) - var images []model.Image - tx := repo.DB.Model(&model.Image{}) +func GetImageList(limit int, offset int, month string, mkt string) ([]model.ImageRegion, error) { + var images []model.ImageRegion + tx := repo.DB.Model(&model.ImageRegion{}) if month != "" { - // 增强过滤:确保只处理 YYYY-MM 格式,防止注入或非法字符 - // 这里简单处理:只要不为空就增加 LIKE 过滤 - util.Logger.Debug("Filtering images by month", zap.String("month", month)) tx = tx.Where("date LIKE ?", month+"%") } - if mkt != "" { tx = tx.Where("mkt = ?", mkt) } - tx = tx.Order("date desc").Preload("Variants") + tx = tx.Order("date desc").Preload("Variants", func(db *gorm.DB) *gorm.DB { + return db.Order("size asc") + }) if limit > 0 { tx = tx.Limit(limit) @@ -228,8 +227,5 @@ func GetImageList(limit int, offset int, month string, mkt string) ([]model.Imag } err := tx.Find(&images).Error - if err != nil { - util.Logger.Error("Failed to get image list", zap.Error(err), zap.String("month", month)) - } return images, err } diff --git a/webapp/src/views/Home.vue b/webapp/src/views/Home.vue index 4df6d83..ef0fe1d 100644 --- a/webapp/src/views/Home.vue +++ b/webapp/src/views/Home.vue @@ -83,16 +83,18 @@