7 Commits

16 changed files with 560 additions and 338 deletions

View File

@@ -1,7 +1,6 @@
server: server:
port: 8080 port: 8080
base_url: "" base_url: ""
log: log:
level: info level: info
filename: data/logs/app.log filename: data/logs/app.log
@@ -13,25 +12,20 @@ log:
log_console: true log_console: true
show_db_log: false show_db_log: false
db_log_level: info db_log_level: info
api: api:
mode: local # local | redirect mode: redirect
enable_mkt_fallback: true # 当请求的地区不存在时,是否回退到默认地区 enable_mkt_fallback: false
enable_on_demand_fetch: false # 是否开启按需抓取(当数据库中没有请求的地区图片时,实时从 Bing 抓取) enable_on_demand_fetch: false
cron: cron:
enabled: true enabled: true
daily_spec: "20 8-23/4 * * *" daily_spec: 20 8-23/4 * * *
retention: retention:
days: 0 days: 0
db: db:
type: sqlite # sqlite | mysql | postgres type: sqlite
dsn: data/bing_paper.db dsn: data/bing_paper.db
storage: storage:
type: local # local | s3 | webdav type: local
local: local:
root: data/picture root: data/picture
s3: s3:
@@ -47,15 +41,28 @@ storage:
username: "" username: ""
password: "" password: ""
public_url_prefix: "" public_url_prefix: ""
admin: admin:
password_bcrypt: "$2a$10$fYHPeWHmwObephJvtlyH1O8DIgaLk5TINbi9BOezo2M8cSjmJchka" # 默认密码: admin123 password_bcrypt: $2a$10$fYHPeWHmwObephJvtlyH1O8DIgaLk5TINbi9BOezo2M8cSjmJchka
token: token:
default_ttl: 168h default_ttl: 168h
feature: feature:
write_daily_files: true write_daily_files: true
web: web:
path: 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

View File

@@ -14,23 +14,4 @@ services:
environment: environment:
- TZ=${TZ:-Asia/Shanghai} - TZ=${TZ:-Asia/Shanghai}
- BINGPAPER_SERVER_PORT=${BINGPAPER_SERVER_PORT:-8080} - BINGPAPER_SERVER_PORT=${BINGPAPER_SERVER_PORT:-8080}
- BINGPAPER_LOG_LEVEL=${BINGPAPER_LOG_LEVEL:-info} - 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:-}

View File

@@ -330,8 +330,8 @@ func GetTokenTTL() time.Duration {
return ttl return ttl
} }
// GetDefaultMkt 返回生效的默认地区编码 // GetDefaultRegion 返回生效的默认地区编码
func (c *Config) GetDefaultMkt() string { func (c *Config) GetDefaultRegion() string {
if len(c.Fetcher.Regions) > 0 { if len(c.Fetcher.Regions) > 0 {
return c.Fetcher.Regions[0] return c.Fetcher.Regions[0]
} }

View File

@@ -53,7 +53,7 @@ type ImageMetaResp struct {
// @Router /image/today [get] // @Router /image/today [get]
func GetToday(c *gin.Context) { func GetToday(c *gin.Context) {
mkt := c.Query("mkt") mkt := c.Query("mkt")
img, err := image.GetTodayImage(mkt) imgRegion, err := image.GetTodayImage(mkt)
if err == image.ErrFetchStarted { 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)}) c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
return return
@@ -62,7 +62,7 @@ func GetToday(c *gin.Context) {
sendImageNotFound(c, mkt) sendImageNotFound(c, mkt)
return return
} }
handleImageResponse(c, img, 7200) // 2小时 handleImageResponse(c, imgRegion, 7200) // 2小时
} }
// GetTodayMeta 获取今日图片元数据 // GetTodayMeta 获取今日图片元数据
@@ -77,7 +77,7 @@ func GetToday(c *gin.Context) {
// @Router /image/today/meta [get] // @Router /image/today/meta [get]
func GetTodayMeta(c *gin.Context) { func GetTodayMeta(c *gin.Context) {
mkt := c.Query("mkt") mkt := c.Query("mkt")
img, err := image.GetTodayImage(mkt) imgRegion, err := image.GetTodayImage(mkt)
if err == image.ErrFetchStarted { 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)}) c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
return return
@@ -87,7 +87,7 @@ func GetTodayMeta(c *gin.Context) {
return return
} }
c.Header("Cache-Control", "public, max-age=7200") // 2小时 c.Header("Cache-Control", "public, max-age=7200") // 2小时
c.JSON(http.StatusOK, formatMeta(img)) c.JSON(http.StatusOK, formatMeta(imgRegion))
} }
// GetRandom 获取随机图片 // GetRandom 获取随机图片
@@ -102,9 +102,10 @@ func GetTodayMeta(c *gin.Context) {
// @Success 202 {object} map[string]string "按需抓取任务已启动" // @Success 202 {object} map[string]string "按需抓取任务已启动"
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因" // @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/random [get] // @Router /image/random [get]
// GetRandom 获取随机图片
func GetRandom(c *gin.Context) { func GetRandom(c *gin.Context) {
mkt := c.Query("mkt") mkt := c.Query("mkt")
img, err := image.GetRandomImage(mkt) imgRegion, err := image.GetRandomImage(mkt)
if err == image.ErrFetchStarted { 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)}) c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
return return
@@ -113,7 +114,7 @@ func GetRandom(c *gin.Context) {
sendImageNotFound(c, mkt) sendImageNotFound(c, mkt)
return return
} }
handleImageResponse(c, img, 0) // 禁用缓存 handleImageResponse(c, imgRegion, 0) // 禁用缓存
} }
// GetRandomMeta 获取随机图片元数据 // GetRandomMeta 获取随机图片元数据
@@ -128,7 +129,7 @@ func GetRandom(c *gin.Context) {
// @Router /image/random/meta [get] // @Router /image/random/meta [get]
func GetRandomMeta(c *gin.Context) { func GetRandomMeta(c *gin.Context) {
mkt := c.Query("mkt") mkt := c.Query("mkt")
img, err := image.GetRandomImage(mkt) imgRegion, err := image.GetRandomImage(mkt)
if err == image.ErrFetchStarted { 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)}) c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
return return
@@ -138,7 +139,7 @@ func GetRandomMeta(c *gin.Context) {
return return
} }
c.Header("Cache-Control", "no-cache, no-store, must-revalidate") c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.JSON(http.StatusOK, formatMeta(img)) c.JSON(http.StatusOK, formatMeta(imgRegion))
} }
// GetByDate 获取指定日期图片 // GetByDate 获取指定日期图片
@@ -157,7 +158,7 @@ func GetRandomMeta(c *gin.Context) {
func GetByDate(c *gin.Context) { func GetByDate(c *gin.Context) {
date := c.Param("date") date := c.Param("date")
mkt := c.Query("mkt") mkt := c.Query("mkt")
img, err := image.GetImageByDate(date, mkt) imgRegion, err := image.GetImageByDate(date, mkt)
if err == image.ErrFetchStarted { 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)}) c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
return return
@@ -166,7 +167,7 @@ func GetByDate(c *gin.Context) {
sendImageNotFound(c, mkt) sendImageNotFound(c, mkt)
return return
} }
handleImageResponse(c, img, 604800) // 7天 handleImageResponse(c, imgRegion, 604800) // 7天
} }
// GetByDateMeta 获取指定日期图片元数据 // GetByDateMeta 获取指定日期图片元数据
@@ -183,7 +184,7 @@ func GetByDate(c *gin.Context) {
func GetByDateMeta(c *gin.Context) { func GetByDateMeta(c *gin.Context) {
date := c.Param("date") date := c.Param("date")
mkt := c.Query("mkt") mkt := c.Query("mkt")
img, err := image.GetImageByDate(date, mkt) imgRegion, err := image.GetImageByDate(date, mkt)
if err == image.ErrFetchStarted { 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)}) c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
return return
@@ -193,7 +194,7 @@ func GetByDateMeta(c *gin.Context) {
return return
} }
c.Header("Cache-Control", "public, max-age=604800") // 7天 c.Header("Cache-Control", "public, max-age=604800") // 7天
c.JSON(http.StatusOK, formatMeta(img)) c.JSON(http.StatusOK, formatMeta(imgRegion))
} }
// ListImages 获取图片列表 // ListImages 获取图片列表
@@ -308,21 +309,21 @@ func sendImageNotFound(c *gin.Context, mkt string) {
c.JSON(http.StatusNotFound, gin.H{"error": message}) 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") variant := c.DefaultQuery("variant", "UHD")
format := c.DefaultQuery("format", "jpg") format := c.DefaultQuery("format", "jpg")
var selected *model.ImageVariant var selected *model.ImageVariant
for _, v := range img.Variants { for _, v := range m.Variants {
if v.Variant == variant && v.Format == format { if v.Variant == variant && v.Format == format {
selected = &v selected = &v
break 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 { 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.Header("Cache-Control", "no-cache, no-store, must-revalidate")
} }
c.Redirect(http.StatusFound, selected.PublicURL) c.Redirect(http.StatusFound, selected.PublicURL)
} else if img.URLBase != "" { } else if m.URLBase != "" {
// 兜底重定向到原始 Bing // 兜底重定向到原始 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 { if maxAge > 0 {
c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge)) c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
} else { } else {
@@ -349,10 +350,10 @@ func handleImageResponse(c *gin.Context, img *model.Image, maxAge int) {
} }
c.Redirect(http.StatusFound, bingURL) c.Redirect(http.StatusFound, bingURL)
} else { } else {
serveLocal(c, selected.StorageKey, img.Date, maxAge) serveLocal(c, selected.StorageKey, m.Date, maxAge)
} }
} else { } 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) io.Copy(c.Writer, reader)
} }
func formatMetaSummary(img *model.Image) gin.H { func formatMetaSummary(m *model.ImageRegion) gin.H {
cfg := config.GetConfig() cfg := config.GetConfig()
// 找到最小的变体Size 最小) // 找到最小的变体
var smallest *model.ImageVariant var smallest *model.ImageVariant
for i := range img.Variants { for i := range m.Variants {
v := &img.Variants[i] v := &m.Variants[i]
if smallest == nil || v.Size < smallest.Size { if smallest == nil {
smallest = v 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{} variants := []gin.H{}
if smallest != nil { if smallest != nil {
url := smallest.PublicURL url := smallest.PublicURL
if url == "" && cfg.API.Mode == "redirect" && img.URLBase != "" { if url == "" && cfg.API.Mode == "redirect" && m.URLBase != "" {
url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, smallest.Variant) url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", m.URLBase, smallest.Variant)
} else if cfg.API.Mode == "local" || url == "" { } 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{ variants = append(variants, gin.H{
"variant": smallest.Variant, "variant": smallest.Variant,
@@ -415,28 +427,28 @@ func formatMetaSummary(img *model.Image) gin.H {
} }
return gin.H{ return gin.H{
"date": img.Date, "date": m.Date,
"mkt": img.Mkt, "mkt": m.Mkt,
"title": img.Title, "title": m.Title,
"copyright": img.Copyright, "copyright": m.Copyright,
"copyrightlink": img.CopyrightLink, "copyrightlink": m.CopyrightLink,
"quiz": img.Quiz, "quiz": m.Quiz,
"startdate": img.StartDate, "startdate": m.StartDate,
"fullstartdate": img.FullStartDate, "fullstartdate": m.FullStartDate,
"hsh": img.HSH, "hsh": m.HSH,
"variants": variants, "variants": variants,
} }
} }
func formatMeta(img *model.Image) gin.H { func formatMeta(m *model.ImageRegion) gin.H {
cfg := config.GetConfig() cfg := config.GetConfig()
variants := []gin.H{} variants := []gin.H{}
for _, v := range img.Variants { for _, v := range m.Variants {
url := v.PublicURL url := v.PublicURL
if url == "" && cfg.API.Mode == "redirect" && img.URLBase != "" { if url == "" && cfg.API.Mode == "redirect" && m.URLBase != "" {
url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, v.Variant) url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", m.URLBase, v.Variant)
} else if cfg.API.Mode == "local" || url == "" { } 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{ variants = append(variants, gin.H{
"variant": v.Variant, "variant": v.Variant,
@@ -448,15 +460,15 @@ func formatMeta(img *model.Image) gin.H {
} }
return gin.H{ return gin.H{
"date": img.Date, "date": m.Date,
"mkt": img.Mkt, "mkt": m.Mkt,
"title": img.Title, "title": m.Title,
"copyright": img.Copyright, "copyright": m.Copyright,
"copyrightlink": img.CopyrightLink, "copyrightlink": m.CopyrightLink,
"quiz": img.Quiz, "quiz": m.Quiz,
"startdate": img.StartDate, "startdate": m.StartDate,
"fullstartdate": img.FullStartDate, "fullstartdate": m.FullStartDate,
"hsh": img.HSH, "hsh": m.HSH,
"variants": variants, "variants": variants,
} }
} }
@@ -520,3 +532,37 @@ func GetRegions(c *gin.Context) {
c.JSON(http.StatusOK, result) 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
}

View File

@@ -21,11 +21,15 @@ func TestHandleImageResponseRedirect(t *testing.T) {
config.GetConfig().API.Mode = "redirect" config.GetConfig().API.Mode = "redirect"
// Mock Image and Variant // Mock Image and Variant
img := &model.Image{ imgRegion := &model.ImageRegion{
Date: "2026-01-26", Date: "2026-01-26",
URLBase: "/th?id=OHR.TestImage", Mkt: "zh-CN",
HSH: "testhsh",
ImageName: "TestImage",
URLBase: "/th?id=OHR.TestImage",
Variants: []model.ImageVariant{ Variants: []model.ImageVariant{
{ {
ImageName: "TestImage",
Variant: "UHD", Variant: "UHD",
Format: "jpg", Format: "jpg",
PublicURL: "", // Empty for local storage simulation PublicURL: "", // Empty for local storage simulation
@@ -39,7 +43,7 @@ func TestHandleImageResponseRedirect(t *testing.T) {
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
c.Request, _ = http.NewRequest("GET", "/api/v1/image/today?variant=UHD", nil) 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.Equal(t, http.StatusFound, w.Code)
assert.Contains(t, w.Header().Get("Location"), "bing.com") 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) { t.Run("FormatMeta in redirect mode should return Bing URL if PublicURL is empty", func(t *testing.T) {
config.GetConfig().API.Mode = "redirect" config.GetConfig().API.Mode = "redirect"
meta := formatMeta(img) meta := formatMeta(imgRegion)
variants := meta["variants"].([]gin.H) variants := meta["variants"].([]gin.H)
assert.Equal(t, 1, len(variants)) 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) { t.Run("FormatMeta in local mode should return API URL", func(t *testing.T) {
config.GetConfig().API.Mode = "local" config.GetConfig().API.Mode = "local"
config.GetConfig().Server.BaseURL = "http://myserver.com" config.GetConfig().Server.BaseURL = "http://myserver.com"
meta := formatMeta(img) meta := formatMeta(imgRegion)
variants := meta["variants"].([]gin.H) variants := meta["variants"].([]gin.H)
assert.Equal(t, 1, len(variants)) 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) { t.Run("FormatMetaSummary should only return the smallest variant", func(t *testing.T) {
imgWithMultipleVariants := &model.Image{ imgWithMultipleVariants := &model.ImageRegion{
Date: "2026-01-26", Date: "2026-01-26",
ImageName: "TestImage2",
Variants: []model.ImageVariant{ Variants: []model.ImageVariant{
{Variant: "UHD", Size: 1000, Format: "jpg"}, {ImageName: "TestImage2", Variant: "UHD", Size: 1000, Format: "jpg"},
{Variant: "640x480", Size: 200, Format: "jpg"}, {ImageName: "TestImage2", Variant: "640x480", Size: 200, Format: "jpg"},
{Variant: "1920x1080", Size: 500, Format: "jpg"}, {ImageName: "TestImage2", Variant: "1920x1080", Size: 500, Format: "jpg"},
}, },
} }
meta := formatMetaSummary(imgWithMultipleVariants) meta := formatMetaSummary(imgWithMultipleVariants)
@@ -81,6 +86,21 @@ func TestHandleImageResponseRedirect(t *testing.T) {
assert.Equal(t, 1, len(variants)) assert.Equal(t, 1, len(variants))
assert.Equal(t, "640x480", variants[0]["variant"]) 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) { func TestGetRegions(t *testing.T) {

View File

@@ -6,29 +6,30 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
type Image struct { type ImageRegion struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
Date string `gorm:"uniqueIndex:idx_date_mkt;type:varchar(10)" json:"date"` // YYYY-MM-DD 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;type:varchar(10)" json:"mkt"` // zh-CN, en-US etc. 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"` Title string `json:"title"`
Copyright string `json:"copyright"` Copyright string `json:"copyright"`
CopyrightLink string `json:"copyrightlink"` CopyrightLink string `json:"copyrightlink"`
URLBase string `json:"urlbase"`
Quiz string `json:"quiz"` Quiz string `json:"quiz"`
StartDate string `json:"startdate"` StartDate string `json:"startdate"`
FullStartDate string `json:"fullstartdate"` FullStartDate string `json:"fullstartdate"`
HSH string `json:"hsh"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` 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 { type ImageVariant struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
ImageID uint `gorm:"index;uniqueIndex:idx_image_variant_format" json:"image_id"` ImageName string `gorm:"uniqueIndex:idx_name_variant_format;type:varchar(100)" json:"image_name"`
Variant string `gorm:"uniqueIndex:idx_image_variant_format;type:varchar(20)" json:"variant"` // UHD, 1920x1080, etc. Variant string `gorm:"uniqueIndex:idx_name_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 Format string `gorm:"uniqueIndex:idx_name_variant_format;type:varchar(10)" json:"format"` // jpg, webp
StorageKey string `json:"storage_key"` StorageKey string `json:"storage_key"`
PublicURL string `json:"public_url"` PublicURL string `json:"public_url"`
Size int64 `json:"size"` Size int64 `json:"size"`

View File

@@ -131,7 +131,7 @@ func InitDB() error {
// 但此处假设 DSN 中指定的数据库已经存在。AutoMigrate 会负责创建表。 // 但此处假设 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)) util.Logger.Error("Database migration failed", zap.Error(err))
return err return err
} }

View File

@@ -29,19 +29,17 @@ func MigrateDataToNewDB(oldDB *gorm.DB, newConfig *config.Config) error {
} }
// 2. 自动迁移结构 // 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) return fmt.Errorf("failed to migrate schema in new DB: %w", err)
} }
// 3. 清空新数据库中的现有数据(防止冲突) // 3. 清空新数据库中的现有数据(防止冲突)
util.Logger.Info("Cleaning up destination database before migration") util.Logger.Info("Cleaning up destination database before migration")
// 备份或清空目标数据库。由于用户要求“可能需要清空或备份”,
// 这里我们选择在迁移前清空目标表,以确保迁移过来的数据是完整且不冲突的。
if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.ImageVariant{}).Error; err != nil { if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.ImageVariant{}).Error; err != nil {
return fmt.Errorf("failed to clear ImageVariants: %w", err) return fmt.Errorf("failed to clear ImageVariants: %w", err)
} }
if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.Image{}).Error; err != nil { if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.ImageRegion{}).Error; err != nil {
return fmt.Errorf("failed to clear Images: %w", err) return fmt.Errorf("failed to clear ImageRegions: %w", err)
} }
if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.Token{}).Error; err != nil { if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.Token{}).Error; err != nil {
return fmt.Errorf("failed to clear Tokens: %w", err) return fmt.Errorf("failed to clear Tokens: %w", err)
@@ -50,15 +48,15 @@ func MigrateDataToNewDB(oldDB *gorm.DB, newConfig *config.Config) error {
// 4. 开始迁移数据 // 4. 开始迁移数据
// 使用事务确保迁移的原子性 // 使用事务确保迁移的原子性
return newDB.Transaction(func(tx *gorm.DB) error { return newDB.Transaction(func(tx *gorm.DB) error {
// 迁移 Images // 迁移 ImageRegions
var images []model.Image var regions []model.ImageRegion
if err := oldDB.Find(&images).Error; err != nil { if err := oldDB.Find(&regions).Error; err != nil {
return fmt.Errorf("failed to fetch images from old DB: %w", err) return fmt.Errorf("failed to fetch image regions from old DB: %w", err)
} }
if len(images) > 0 { if len(regions) > 0 {
util.Logger.Info("Migrating images", zap.Int("count", len(images))) util.Logger.Info("Migrating image regions", zap.Int("count", len(regions)))
if err := tx.Create(&images).Error; err != nil { if err := tx.Create(&regions).Error; err != nil {
return fmt.Errorf("failed to insert images into new DB: %w", err) return fmt.Errorf("failed to insert image regions into new DB: %w", err)
} }
} }

View File

@@ -11,6 +11,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"BingPaper/internal/config" "BingPaper/internal/config"
@@ -57,7 +58,7 @@ func (f *Fetcher) Fetch(ctx context.Context, n int) error {
util.Logger.Info("Starting fetch task", zap.Int("n", n)) util.Logger.Info("Starting fetch task", zap.Int("n", n))
regions := config.GetConfig().Fetcher.Regions regions := config.GetConfig().Fetcher.Regions
if len(regions) == 0 { if len(regions) == 0 {
regions = []string{config.GetConfig().GetDefaultMkt()} regions = []string{config.GetConfig().GetDefaultRegion()}
} }
for _, mkt := range regions { for _, mkt := range regions {
@@ -92,15 +93,29 @@ func (f *Fetcher) FetchRegion(ctx context.Context, mkt string) error {
} }
func (f *Fetcher) fetchByMkt(ctx context.Context, mkt string, idx int, n int) error { func (f *Fetcher) fetchByMkt(ctx context.Context, mkt string, idx int, n int) error {
url := fmt.Sprintf("%s?format=js&idx=%d&n=%d&uhd=1&mkt=%s", config.BingAPIBase, idx, n, mkt) lang := strings.Split(mkt, "-")[0]
util.Logger.Debug("Requesting Bing API", zap.String("url", url)) url := fmt.Sprintf("%s?format=js&idx=%d&n=%d&uhd=1&mkt=%s&setlang=%s", config.BingAPIBase, idx, n, mkt, lang)
resp, err := f.httpClient.Get(url) util.Logger.Info("Requesting Bing API", zap.String("url", url))
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
util.Logger.Error("Failed to create Bing API request", zap.Error(err))
return err
}
// 添加请求头以增强地区/语言识别
req.Header.Set("Accept-Language", fmt.Sprintf("%s,%s;q=0.9", mkt, lang))
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
resp, err := f.httpClient.Do(req)
if err != nil { if err != nil {
util.Logger.Error("Failed to request Bing API", zap.Error(err)) util.Logger.Error("Failed to request Bing API", zap.Error(err))
return err return err
} }
defer resp.Body.Close() defer resp.Body.Close()
util.Logger.Info("Received response from Bing API", zap.String("mkt", mkt), zap.Int("status", resp.StatusCode))
var bingResp BingResponse var bingResp BingResponse
if err := json.NewDecoder(resp.Body).Decode(&bingResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&bingResp); err != nil {
util.Logger.Error("Failed to decode Bing API response", zap.Error(err)) util.Logger.Error("Failed to decode Bing API response", zap.Error(err))
@@ -110,6 +125,12 @@ func (f *Fetcher) fetchByMkt(ctx context.Context, mkt string, idx int, n int) er
util.Logger.Info("Fetched images from Bing", zap.String("mkt", mkt), zap.Int("count", len(bingResp.Images))) util.Logger.Info("Fetched images from Bing", zap.String("mkt", mkt), zap.Int("count", len(bingResp.Images)))
for _, bingImg := range bingResp.Images { for _, bingImg := range bingResp.Images {
util.Logger.Info("Bing image metadata",
zap.String("mkt", mkt),
zap.String("date", bingImg.Enddate),
zap.String("title", bingImg.Title),
zap.String("hsh", bingImg.HSH))
if err := f.processImage(ctx, bingImg, mkt); err != nil { if err := f.processImage(ctx, bingImg, mkt); err != nil {
util.Logger.Error("Failed to process image", zap.String("date", bingImg.Enddate), zap.String("mkt", mkt), zap.Error(err)) util.Logger.Error("Failed to process image", zap.String("date", bingImg.Enddate), zap.String("mkt", mkt), zap.Error(err))
} }
@@ -121,64 +142,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 { 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]) dateStr := fmt.Sprintf("%s-%s-%s", bingImg.Enddate[0:4], bingImg.Enddate[4:6], bingImg.Enddate[6:8])
// 幂等检查 // 1. 地区关联幂等检查
var existing model.Image var existingRegion model.ImageRegion
if err := repo.DB.Where("date = ? AND mkt = ?", dateStr, mkt).First(&existing).Error; err == nil { if err := repo.DB.Where("date = ? AND mkt = ?", dateStr, mkt).First(&existingRegion).Error; err == nil {
util.Logger.Info("Image already exists, skipping", zap.String("date", dateStr), zap.String("mkt", mkt)) util.Logger.Info("ImageRegion record already exists, skipping", zap.String("date", dateStr), zap.String("mkt", mkt), zap.String("title", bingImg.Title))
return nil return nil
} }
util.Logger.Info("Processing new image", zap.String("date", dateStr), zap.String("mkt", mkt), zap.String("title", bingImg.Title)) imageName := f.extractImageName(bingImg.URLBase, bingImg.HSH)
// UHD 探测 // 2. 处理变体
imgURL, variantName := f.probeUHD(bingImg.URLBase) imgURL, variantName := f.probeUHD(ctx, bingImg.URLBase)
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))
if err != nil {
util.Logger.Error("Failed to decode image data", zap.Error(err))
return err
}
// 创建 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
}
// 再次检查 dbImg.ID 是否被填充,如果没有被填充(说明由于冲突未插入),则需要查询出已有的 ID
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
}
// 保存各种分辨率
targetVariants := []struct { targetVariants := []struct {
name string name string
width int width int
@@ -197,51 +171,138 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage, mkt strin
{"320x240", 320, 240}, {"320x240", 320, 240},
} }
// 首先保存原图 (UHD 或 1080p) // 检查变体是否已存在 (通过 ImageName)
if err := f.saveVariant(ctx, &dbImg, variantName, "jpg", imgData); err != nil { var existingVariants []model.ImageVariant
util.Logger.Error("Failed to save original variant", zap.String("variant", variantName), zap.Error(err)) repo.DB.Where("image_name = ?", imageName).Find(&existingVariants)
}
for _, v := range targetVariants { allVariantsExist := len(existingVariants) > 0
// 如果目标分辨率就是原图分辨率,则跳过(已经保存过了)
if v.name == variantName { var srcImg image.Image
continue 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), zap.String("imageName", imageName))
var err error
imgData, err = f.downloadImage(ctx, imgURL)
if err != nil {
util.Logger.Error("Failed to download image", zap.String("url", imgURL), zap.Error(err))
return err
} }
resized := imaging.Fill(srcImg, v.width, v.height, imaging.Center, imaging.Lanczos) srcImg, _, err = image.Decode(bytes.NewReader(imgData))
buf := new(bytes.Buffer) if err != nil {
if err := jpeg.Encode(buf, resized, &jpeg.Options{Quality: 100}); err != nil { util.Logger.Error("Failed to decode image data", zap.Error(err))
util.Logger.Warn("Failed to encode jpeg", zap.String("variant", v.name), zap.Error(err)) return err
continue
} }
currentImgData := buf.Bytes()
// 保存 JPG // 保存原图变体
if err := f.saveVariant(ctx, &dbImg, v.name, "jpg", currentImgData); err != nil { if err := f.saveVariant(ctx, imageName, variantName, "jpg", imgData); err != nil {
util.Logger.Error("Failed to save variant", zap.String("variant", v.name), zap.Error(err)) util.Logger.Error("Failed to save original variant", zap.String("variant", variantName), zap.Error(err))
}
for _, v := range targetVariants {
if v.name == variantName {
continue
}
resized := imaging.Fill(srcImg, v.width, v.height, imaging.Center, imaging.Lanczos)
buf := new(bytes.Buffer)
if err := jpeg.Encode(buf, resized, &jpeg.Options{Quality: 100}); err != nil {
util.Logger.Warn("Failed to encode jpeg", zap.String("variant", v.name), zap.Error(err))
continue
}
currentImgData := buf.Bytes()
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))
}
} }
} }
// 保存今日额外文件 // 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(&regionRecord).Error; err != nil {
util.Logger.Error("Failed to create region record", zap.Error(err))
return err
}
util.Logger.Info("Successfully saved/updated ImageRegion record to database",
zap.String("date", dateStr),
zap.String("mkt", mkt),
zap.String("title", regionRecord.Title))
// 4. 保存今日额外文件
today := time.Now().Format("2006-01-02") today := time.Now().Format("2006-01-02")
if dateStr == today && config.GetConfig().Feature.WriteDailyFiles { if dateStr == today && config.GetConfig().Feature.WriteDailyFiles {
f.saveDailyFiles(srcImg, imgData, mkt) if imgData != nil && srcImg != nil {
f.saveDailyFiles(srcImg, imgData, mkt)
}
} }
return nil return nil
} }
func (f *Fetcher) probeUHD(urlBase string) (string, string) { func (f *Fetcher) extractImageName(urlBase, hsh string) string {
// 示例: /th?id=OHR.MilwaukeeHall_ROW0871854348
start := 0
if idx := strings.Index(urlBase, "OHR."); idx != -1 {
start = idx + 4
} else if idx := strings.Index(urlBase, "id="); idx != -1 {
start = idx + 3
}
rem := urlBase[start:]
end := strings.Index(rem, "_")
if end == -1 {
end = len(rem)
}
name := rem[:end]
if name == "" {
return hsh
}
return name
}
func (f *Fetcher) probeUHD(ctx context.Context, urlBase string) (string, string) {
uhdURL := fmt.Sprintf("https://www.bing.com%s_UHD.jpg", urlBase) uhdURL := fmt.Sprintf("https://www.bing.com%s_UHD.jpg", urlBase)
resp, err := f.httpClient.Head(uhdURL) req, err := http.NewRequestWithContext(ctx, "HEAD", uhdURL, nil)
if err != nil {
return fmt.Sprintf("https://www.bing.com%s_1920x1080.jpg", urlBase), "1920x1080"
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
resp, err := f.httpClient.Do(req)
if err == nil && resp.StatusCode == http.StatusOK { if err == nil && resp.StatusCode == http.StatusOK {
return uhdURL, "UHD" return uhdURL, "UHD"
} }
return fmt.Sprintf("https://www.bing.com%s_1920x1080.jpg", urlBase), "1920x1080" return fmt.Sprintf("https://www.bing.com%s_1920x1080.jpg", urlBase), "1920x1080"
} }
func (f *Fetcher) downloadImage(url string) ([]byte, error) { func (f *Fetcher) downloadImage(ctx context.Context, url string) ([]byte, error) {
resp, err := f.httpClient.Get(url) req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
resp, err := f.httpClient.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -249,31 +310,67 @@ func (f *Fetcher) downloadImage(url string) ([]byte, error) {
return io.ReadAll(resp.Body) return io.ReadAll(resp.Body)
} }
func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, variant, format string, data []byte) error { func (f *Fetcher) generateKey(imageName, variant, format string) string {
key := fmt.Sprintf("%s/%s/%s_%s.%s", img.Mkt, img.Date, img.Date, variant, format) return fmt.Sprintf("%s/%s_%s.%s", imageName, imageName, variant, format)
}
func (f *Fetcher) saveVariant(ctx context.Context, imageName, variant, format string, data []byte) error {
key := f.generateKey(imageName, variant, format)
contentType := "image/jpeg" contentType := "image/jpeg"
if format == "webp" { if format == "webp" {
contentType = "image/webp" contentType = "image/webp"
} }
stored, err := storage.GlobalStorage.Put(ctx, key, bytes.NewReader(data), contentType) var size int64
var publicURL string
exists, _ := storage.GlobalStorage.Exists(ctx, key)
if exists {
util.Logger.Debug("Variant already exists in storage, linking", zap.String("key", key))
// 如果存在,尝试获取公共 URL
if pURL, ok := storage.GlobalStorage.PublicURL(key); ok {
publicURL = pURL
}
// 如果传入了数据,则使用数据大小
if data != nil {
size = int64(len(data))
}
} else if data != nil {
util.Logger.Debug("Saving variant to storage", zap.String("key", key))
stored, err := storage.GlobalStorage.Put(ctx, key, bytes.NewReader(data), contentType)
if err != nil {
return err
}
publicURL = stored.PublicURL
size = stored.Size
} else {
return fmt.Errorf("variant %s does not exist and no data provided", key)
}
vRecord := model.ImageVariant{
ImageName: imageName,
Variant: variant,
Format: format,
StorageKey: key,
PublicURL: publicURL,
Size: size,
}
err := repo.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "image_name"}, {Name: "variant"}, {Name: "format"}},
DoNothing: true,
}).Create(&vRecord).Error
if err != nil { if err != nil {
return err return err
} }
vRecord := model.ImageVariant{ util.Logger.Info("Successfully saved ImageVariant record to database",
ImageID: img.ID, zap.String("image_name", imageName),
Variant: variant, zap.String("variant", variant),
Format: format, zap.String("format", format))
StorageKey: stored.Key,
PublicURL: stored.PublicURL,
Size: int64(len(data)),
}
return repo.DB.Clauses(clause.OnConflict{ return nil
Columns: []clause.Column{{Name: "image_id"}, {Name: "variant"}, {Name: "format"}},
DoNothing: true,
}).Create(&vRecord).Error
} }
func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte, mkt string) { func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte, mkt string) {
@@ -307,7 +404,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") jpegPathRoot := filepath.Join(localRoot, "daily.jpeg")
fJpegRoot, err := os.Create(jpegPathRoot) fJpegRoot, err := os.Create(jpegPathRoot)
if err == nil { if err == nil {

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"math/rand"
"time" "time"
"BingPaper/internal/config" "BingPaper/internal/config"
@@ -14,6 +15,7 @@ import (
"BingPaper/internal/util" "BingPaper/internal/util"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm"
) )
var ErrFetchStarted = errors.New("on-demand fetch started") var ErrFetchStarted = errors.New("on-demand fetch started")
@@ -27,41 +29,53 @@ func CleanupOldImages(ctx context.Context) error {
threshold := time.Now().AddDate(0, 0, -days).Format("2006-01-02") 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)) util.Logger.Info("Starting cleanup task", zap.Int("retention_days", days), zap.String("threshold", threshold))
var images []model.Image var regionRecords []model.ImageRegion
if err := repo.DB.Where("date < ?", threshold).Preload("Variants").Find(&images).Error; err != nil { if err := repo.DB.Where("date < ?", threshold).Preload("Variants").Find(&regionRecords).Error; err != nil {
util.Logger.Error("Failed to query old images for cleanup", zap.Error(err)) util.Logger.Error("Failed to query old image regions for cleanup", zap.Error(err))
return err return err
} }
for _, img := range images { for _, m := range regionRecords {
util.Logger.Info("Deleting old image", zap.String("date", img.Date)) util.Logger.Info("Deleting old image region record", zap.String("date", m.Date), zap.String("mkt", m.Mkt))
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)) 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(&m).Error; err != nil {
} util.Logger.Error("Failed to delete image region record", zap.Uint("id", m.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))
} }
} }
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 return nil
} }
func GetTodayImage(mkt string) (*model.Image, error) { func GetTodayImage(mkt string) (*model.ImageRegion, error) {
today := time.Now().Format("2006-01-02") today := time.Now().Format("2006-01-02")
var img model.Image util.Logger.Debug("Getting today image", zap.String("mkt", mkt), zap.String("today", today))
var imgRegion model.ImageRegion
tx := repo.DB.Where("date = ?", today) tx := repo.DB.Where("date = ?", today)
if mkt != "" { if mkt != "" {
tx = tx.Where("mkt = ?", 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) { 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)) util.Logger.Info("Image not found in DB, starting asynchronous on-demand fetch", zap.String("mkt", mkt))
@@ -73,54 +87,59 @@ func GetTodayImage(mkt string) (*model.Image, error) {
} }
if err != nil { if err != nil {
util.Logger.Debug("Today image not found, trying latest image", zap.String("mkt", mkt))
// 如果今天还是没有,尝试获取最近的一张 // 如果今天还是没有,尝试获取最近的一张
tx = repo.DB.Order("date desc") tx = repo.DB.Order("date desc")
if mkt != "" { if mkt != "" {
tx = tx.Where("mkt = ?", 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 { if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback {
defaultMkt := config.GetConfig().GetDefaultMkt() 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 { if mkt != defaultMkt {
return GetTodayImage(defaultMkt) return GetTodayImage(defaultMkt)
} }
return GetTodayImage("") return GetTodayImage("")
} }
return &img, err if err == nil {
util.Logger.Debug("Found image region record", zap.String("date", imgRegion.Date), zap.String("mkt", imgRegion.Mkt))
}
return &imgRegion, err
} }
func GetAllRegionsTodayImages() ([]model.Image, error) { func GetAllRegionsTodayImages() ([]model.ImageRegion, error) {
today := time.Now().Format("2006-01-02")
regions := config.GetConfig().Fetcher.Regions regions := config.GetConfig().Fetcher.Regions
if len(regions) == 0 { 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 { err := repo.DB.Where("date = ? AND mkt IN ?", today, regions).
img, err := GetTodayImage(mkt) Preload("Variants", func(db *gorm.DB) *gorm.DB {
if err == nil { return db.Order("size asc")
images = append(images, *img) }).Find(&images).Error
}
} return images, err
return images, nil
} }
func GetRandomImage(mkt string) (*model.Image, error) { func GetRandomImage(mkt string) (*model.ImageRegion, error) {
var img model.Image util.Logger.Debug("Getting random image", zap.String("mkt", mkt))
// SQLite 使用 RANDOM(), MySQL/Postgres 使用 RANDOM() 或 RAND() var imgRegion model.ImageRegion
// 简单起见,先查总数再 Offset
var count int64 var count int64
tx := repo.DB.Model(&model.Image{}) tx := repo.DB.Model(&model.ImageRegion{})
if mkt != "" { if mkt != "" {
tx = tx.Where("mkt = ?", mkt) tx = tx.Where("mkt = ?", mkt)
} }
tx.Count(&count) tx.Count(&count)
if count == 0 && mkt != "" && config.GetConfig().API.EnableOnDemandFetch && util.IsValidRegion(mkt) { 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)) util.Logger.Info("No images found in DB for region, starting asynchronous on-demand fetch", zap.String("mkt", mkt))
f := fetcher.NewFetcher() f := fetcher.NewFetcher()
go func() { go func() {
@@ -133,42 +152,39 @@ func GetRandomImage(mkt string) (*model.Image, error) {
return nil, fmt.Errorf("no images found") return nil, fmt.Errorf("no images found")
} }
// 这种方法不适合海量数据,但对于 30 天的数据没问题 offset := rand.Intn(int(count))
tx = repo.DB.Order("RANDOM()") util.Logger.Debug("Random image selection", zap.Int64("total", count), zap.Int("offset", offset))
if mkt != "" { err := tx.Preload("Variants", func(db *gorm.DB) *gorm.DB {
tx = tx.Where("mkt = ?", mkt) return db.Order("size asc")
} }).Offset(offset).Limit(1).Find(&imgRegion).Error
err := tx.Preload("Variants").First(&img).Error
if err != nil {
// 适配 MySQL
tx = repo.DB.Order("RAND()")
if mkt != "" {
tx = tx.Where("mkt = ?", mkt)
}
err = tx.Preload("Variants").First(&img).Error
}
// 兜底逻辑 if (err != nil || imgRegion.ID == 0) && mkt != "" && config.GetConfig().API.EnableMktFallback {
if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback { defaultMkt := config.GetConfig().GetDefaultRegion()
defaultMkt := config.GetConfig().GetDefaultMkt() util.Logger.Debug("Random image not found, trying fallback", zap.String("mkt", mkt), zap.String("defaultMkt", defaultMkt))
if mkt != defaultMkt { if mkt != defaultMkt {
return GetRandomImage(defaultMkt) return GetRandomImage(defaultMkt)
} }
return GetRandomImage("") return GetRandomImage("")
} }
return &img, err if err == nil && imgRegion.ID == 0 {
return nil, fmt.Errorf("no images found")
}
return &imgRegion, err
} }
func GetImageByDate(date string, mkt string) (*model.Image, error) { func GetImageByDate(date string, mkt string) (*model.ImageRegion, error) {
var img model.Image util.Logger.Debug("Getting image by date", zap.String("date", date), zap.String("mkt", mkt))
var imgRegion model.ImageRegion
tx := repo.DB.Where("date = ?", date) tx := repo.DB.Where("date = ?", date)
if mkt != "" { if mkt != "" {
tx = tx.Where("mkt = ?", 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) { 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)) 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() f := fetcher.NewFetcher()
go func() { go func() {
@@ -177,34 +193,31 @@ func GetImageByDate(date string, mkt string) (*model.Image, error) {
return nil, ErrFetchStarted return nil, ErrFetchStarted
} }
// 兜底逻辑
if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback { if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback {
defaultMkt := config.GetConfig().GetDefaultMkt() defaultMkt := config.GetConfig().GetDefaultRegion()
if mkt != defaultMkt { if mkt != defaultMkt {
return GetImageByDate(date, defaultMkt) return GetImageByDate(date, defaultMkt)
} }
return GetImageByDate(date, "") return GetImageByDate(date, "")
} }
return &img, err return &imgRegion, err
} }
func GetImageList(limit int, offset int, month string, mkt string) ([]model.Image, error) { func GetImageList(limit int, offset int, month string, mkt string) ([]model.ImageRegion, error) {
var images []model.Image var images []model.ImageRegion
tx := repo.DB.Model(&model.Image{}) tx := repo.DB.Model(&model.ImageRegion{})
if month != "" { if month != "" {
// 增强过滤:确保只处理 YYYY-MM 格式,防止注入或非法字符
// 这里简单处理:只要不为空就增加 LIKE 过滤
util.Logger.Debug("Filtering images by month", zap.String("month", month))
tx = tx.Where("date LIKE ?", month+"%") tx = tx.Where("date LIKE ?", month+"%")
} }
if mkt != "" { if mkt != "" {
tx = tx.Where("mkt = ?", 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 { if limit > 0 {
tx = tx.Limit(limit) tx = tx.Limit(limit)
@@ -214,8 +227,5 @@ func GetImageList(limit int, offset int, month string, mkt string) ([]model.Imag
} }
err := tx.Find(&images).Error 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 return images, err
} }

View File

@@ -63,3 +63,15 @@ func (l *LocalStorage) Delete(ctx context.Context, key string) error {
func (l *LocalStorage) PublicURL(key string) (string, bool) { func (l *LocalStorage) PublicURL(key string) (string, bool) {
return "", false return "", false
} }
func (l *LocalStorage) Exists(ctx context.Context, key string) (bool, error) {
path := filepath.Join(l.root, key)
_, err := os.Stat(path)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}

View File

@@ -100,3 +100,18 @@ func (s *S3Storage) PublicURL(key string) (string, bool) {
// 也可以生成签名 URL但这里简单处理 // 也可以生成签名 URL但这里简单处理
return "", false return "", false
} }
func (s *S3Storage) Exists(ctx context.Context, key string) (bool, error) {
_, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
// 判断是否为 404
if strings.Contains(err.Error(), "NotFound") || strings.Contains(err.Error(), "404") {
return false, nil
}
return false, err
}
return true, nil
}

View File

@@ -17,6 +17,7 @@ type Storage interface {
Get(ctx context.Context, key string) (io.ReadCloser, string, error) Get(ctx context.Context, key string) (io.ReadCloser, string, error)
Delete(ctx context.Context, key string) error Delete(ctx context.Context, key string) error
PublicURL(key string) (string, bool) PublicURL(key string) (string, bool)
Exists(ctx context.Context, key string) (bool, error)
} }
var GlobalStorage Storage var GlobalStorage Storage

View File

@@ -72,3 +72,16 @@ func (w *WebDAVStorage) PublicURL(key string) (string, bool) {
} }
return "", false return "", false
} }
func (w *WebDAVStorage) Exists(ctx context.Context, key string) (bool, error) {
_, err := w.client.Stat(key)
if err == nil {
return true, nil
}
// gowebdav 的错误处理比较原始,通常 404 会返回错误
// 这里假设报错就是不存在,或者可以根据错误消息判断
if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "not found") {
return false, nil
}
return false, err
}

View File

@@ -482,7 +482,7 @@ const allRegions = ref<any[]>([])
const config = ref<Config>({ const config = ref<Config>({
Admin: { PasswordBcrypt: '' }, Admin: { PasswordBcrypt: '' },
API: { Mode: 'local', EnableMktFallback: true }, API: { Mode: 'local', EnableMktFallback: true, EnableOnDemandFetch: false },
Cron: { Enabled: true, DailySpec: '0 9 * * *' }, Cron: { Enabled: true, DailySpec: '0 9 * * *' },
DB: { Type: 'sqlite', DSN: '' }, DB: { Type: 'sqlite', DSN: '' },
Feature: { WriteDailyFiles: true }, Feature: { WriteDailyFiles: true },

View File

@@ -83,16 +83,18 @@
<div class="relative group"> <div class="relative group">
<!-- 左右切换按钮 --> <!-- 左右切换按钮 -->
<button <button
v-show="canScrollLeft"
@click="scrollGlobal('left')" @click="scrollGlobal('left')"
class="absolute left-[-20px] top-1/2 -translate-y-1/2 z-20 p-2 bg-black/50 backdrop-blur-md rounded-full text-white opacity-0 group-hover:opacity-100 transition-opacity hidden md:block border border-white/10 hover:bg-black/70" class="absolute left-[-20px] top-1/2 -translate-y-1/2 z-30 p-2 bg-black/60 backdrop-blur-md rounded-full text-white transition-all hidden md:block border border-white/10 hover:bg-black/80 hover:scale-110 active:scale-95 shadow-2xl"
> >
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg> </svg>
</button> </button>
<button <button
v-show="canScrollRight"
@click="scrollGlobal('right')" @click="scrollGlobal('right')"
class="absolute right-[-20px] top-1/2 -translate-y-1/2 z-20 p-2 bg-black/50 backdrop-blur-md rounded-full text-white opacity-0 group-hover:opacity-100 transition-opacity hidden md:block border border-white/10 hover:bg-black/70" class="absolute right-[-20px] top-1/2 -translate-y-1/2 z-30 p-2 bg-black/60 backdrop-blur-md rounded-full text-white transition-all hidden md:block border border-white/10 hover:bg-black/80 hover:scale-110 active:scale-95 shadow-2xl"
> >
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
@@ -102,7 +104,6 @@
<div <div
ref="globalScrollContainer" ref="globalScrollContainer"
class="flex overflow-x-auto gap-6 pb-6 -mx-4 px-4 md:mx-0 md:px-0 scrollbar-hide snap-x scroll-smooth" class="flex overflow-x-auto gap-6 pb-6 -mx-4 px-4 md:mx-0 md:px-0 scrollbar-hide snap-x scroll-smooth"
@wheel="handleGlobalWheel"
> >
<div <div
v-for="image in globalImages" v-for="image in globalImages"
@@ -139,7 +140,14 @@
</div> </div>
<!-- 左右渐变遮罩 (仅在大屏显示) --> <!-- 左右渐变遮罩 (仅在大屏显示) -->
<div class="absolute right-0 top-0 bottom-6 w-24 bg-gradient-to-l from-gray-900 to-transparent pointer-events-none hidden md:block opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div> <div
class="absolute left-0 top-0 bottom-6 w-24 bg-gradient-to-r from-gray-900 to-transparent pointer-events-none hidden md:block transition-opacity duration-500"
:class="canScrollLeft ? 'opacity-100' : 'opacity-0'"
></div>
<div
class="absolute right-0 top-0 bottom-6 w-24 bg-gradient-to-l from-gray-900 to-transparent pointer-events-none hidden md:block transition-opacity duration-500"
:class="canScrollRight ? 'opacity-100' : 'opacity-0'"
></div>
</div> </div>
</div> </div>
</section> </section>
@@ -360,13 +368,22 @@ const latestImage = ref<any>(null)
const todayLoading = ref(false) const todayLoading = ref(false)
// 全球今日图片 // 全球今日图片
const { images: globalImages, loading: globalLoading } = useGlobalTodayImages() const { images: globalImages } = useGlobalTodayImages()
// 滚动功能实现 // 滚动功能实现
const globalScrollContainer = ref<HTMLElement | null>(null) const globalScrollContainer = ref<HTMLElement | null>(null)
const canScrollLeft = ref(false)
const canScrollRight = ref(false)
const updateScrollState = () => {
if (!globalScrollContainer.value) return
const { scrollLeft, scrollWidth, clientWidth } = globalScrollContainer.value
canScrollLeft.value = scrollLeft > 5
canScrollRight.value = scrollLeft + clientWidth < scrollWidth - 5
}
const scrollGlobal = (direction: 'left' | 'right') => { const scrollGlobal = (direction: 'left' | 'right') => {
if (!globalScrollContainer.value) return if (!globalScrollContainer.value) return
// 增加翻页数量:滚动容器宽度的 80%,或者固定 3 张卡片的宽度
const scrollAmount = globalScrollContainer.value.clientWidth * 0.8 || 1000 const scrollAmount = globalScrollContainer.value.clientWidth * 0.8 || 1000
globalScrollContainer.value.scrollBy({ globalScrollContainer.value.scrollBy({
left: direction === 'left' ? -scrollAmount : scrollAmount, left: direction === 'left' ? -scrollAmount : scrollAmount,
@@ -374,25 +391,6 @@ const scrollGlobal = (direction: 'left' | 'right') => {
}) })
} }
// 鼠标滚轮横向滑动
const handleGlobalWheel = (e: WheelEvent) => {
if (!globalScrollContainer.value) return
const container = globalScrollContainer.value
const isAtStart = container.scrollLeft <= 0
const isAtEnd = Math.ceil(container.scrollLeft + container.clientWidth) >= container.scrollWidth
// 当鼠标在滚动区域内,且是纵向滚动时
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
// 如果还没滚动到尽头,则拦截纵向滚动并转为横向
if ((e.deltaY > 0 && !isAtEnd) || (e.deltaY < 0 && !isAtStart)) {
e.preventDefault()
// 加大滚动步进以实现“快速翻页”
container.scrollLeft += e.deltaY * 1.5
}
}
}
// 历史图片列表使用服务端分页和筛选每页15张 // 历史图片列表使用服务端分页和筛选每页15张
const { images, loading, hasMore, loadMore, filterByMonth, filterByMkt } = useImageList(15) const { images, loading, hasMore, loadMore, filterByMonth, filterByMkt } = useImageList(15)
@@ -419,20 +417,24 @@ const loadLatestImage = async () => {
} }
} }
// 初始化加载
onMounted(async () => { // 监听滚动容器的变化
try { watch(globalScrollContainer, (el, _, onCleanup) => {
const backendRegions = await bingPaperApi.getRegions() if (el) {
if (backendRegions && backendRegions.length > 0) { el.addEventListener('scroll', updateScrollState)
regions.value = backendRegions // 初始检查
setSupportedRegions(backendRegions) setTimeout(updateScrollState, 100)
} onCleanup(() => {
} catch (error) { el.removeEventListener('scroll', updateScrollState)
console.error('Failed to fetch regions:', error) })
} }
loadLatestImage()
}) })
// 监听全球图片数据变化
watch(globalImages, () => {
setTimeout(updateScrollState, 500)
}, { deep: true })
// 判断最新图片是否为今天的图片 // 判断最新图片是否为今天的图片
const isToday = computed(() => { const isToday = computed(() => {
if (!latestImage.value?.date) return false if (!latestImage.value?.date) return false
@@ -648,16 +650,34 @@ const setupLoadMoreObserver = () => {
} }
// 初始化 // 初始化
onMounted(() => { onMounted(async () => {
// 1. 获取地区信息
try {
const backendRegions = await bingPaperApi.getRegions()
if (backendRegions && backendRegions.length > 0) {
regions.value = backendRegions
setSupportedRegions(backendRegions)
}
} catch (error) {
console.error('Failed to fetch regions:', error)
}
// 2. 加载最新图片
loadLatestImage() loadLatestImage()
// 3. 设置观察者和滚动状态
if (images.value.length > 0) { if (images.value.length > 0) {
imageVisibility.value = new Array(images.value.length).fill(false) imageVisibility.value = new Array(images.value.length).fill(false)
setTimeout(() => {
setupObserver()
setupLoadMoreObserver()
}, 100)
} }
setTimeout(() => {
setupObserver()
setupLoadMoreObserver()
updateScrollState()
}, 100)
// 4. 全局事件
window.addEventListener('resize', updateScrollState)
}) })
// 清理 // 清理
@@ -668,6 +688,7 @@ onUnmounted(() => {
if (loadMoreObserver) { if (loadMoreObserver) {
loadMoreObserver.disconnect() loadMoreObserver.disconnect()
} }
window.removeEventListener('resize', updateScrollState)
}) })
// 格式化日期 // 格式化日期