mirror of
https://git.fightbot.fun/hxuanyu/BingPaper.git
synced 2026-02-15 05:59:32 +08:00
数据库表重新设计,精简数据结构以及存储结构
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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:-}
|
|
||||||
@@ -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]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;index:idx_mkt_date,priority:2;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;index:idx_mkt_date,priority:1;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"`
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(®ions).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(®ions).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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,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 {
|
||||||
@@ -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 {
|
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.Debug("Image already exists in DB, skipping", zap.String("date", dateStr), zap.String("mkt", mkt))
|
util.Logger.Debug("ImageRegion record already exists, skipping", zap.String("date", dateStr), zap.String("mkt", mkt))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
imageName := f.extractImageName(bingImg.URLBase, bingImg.HSH)
|
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 记录
|
// 2. 处理变体
|
||||||
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 探测
|
|
||||||
imgURL, variantName := f.probeUHD(bingImg.URLBase)
|
imgURL, variantName := f.probeUHD(bingImg.URLBase)
|
||||||
|
|
||||||
// 保存各种分辨率
|
|
||||||
targetVariants := []struct {
|
targetVariants := []struct {
|
||||||
name string
|
name string
|
||||||
width int
|
width int
|
||||||
@@ -185,54 +151,34 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage, mkt strin
|
|||||||
{"320x240", 320, 240},
|
{"320x240", 320, 240},
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否所有变体都已存在于存储中
|
// 检查变体是否已存在 (通过 ImageName)
|
||||||
allExist := true
|
var existingVariants []model.ImageVariant
|
||||||
// 检查 UHD/原图
|
repo.DB.Where("image_name = ?", imageName).Find(&existingVariants)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if allExist {
|
allVariantsExist := len(existingVariants) > 0
|
||||||
util.Logger.Debug("All image variants exist in storage, linking only", zap.String("imageName", imageName))
|
|
||||||
// 只建立关联信息
|
var srcImg image.Image
|
||||||
f.saveVariant(ctx, &dbImg, imageName, variantName, "jpg", nil)
|
var imgData []byte
|
||||||
for _, v := range targetVariants {
|
|
||||||
if v.name == variantName {
|
if allVariantsExist {
|
||||||
continue
|
util.Logger.Debug("Image variants already exist for name, linking only", zap.String("imageName", imageName))
|
||||||
}
|
|
||||||
f.saveVariant(ctx, &dbImg, imageName, v.name, "jpg", nil)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// 需要下载并处理
|
util.Logger.Debug("Downloading and processing image", zap.String("url", imgURL), zap.String("imageName", imageName))
|
||||||
util.Logger.Debug("Downloading and processing image", zap.String("url", imgURL))
|
var err error
|
||||||
imgData, err := f.downloadImage(imgURL)
|
imgData, err = f.downloadImage(imgURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
util.Logger.Error("Failed to download image", zap.String("url", imgURL), zap.Error(err))
|
util.Logger.Error("Failed to download image", zap.String("url", imgURL), zap.Error(err))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
srcImg, _, err := image.Decode(bytes.NewReader(imgData))
|
srcImg, _, err = image.Decode(bytes.NewReader(imgData))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
util.Logger.Error("Failed to decode image data", zap.Error(err))
|
util.Logger.Error("Failed to decode image data", zap.Error(err))
|
||||||
return 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))
|
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
|
continue
|
||||||
}
|
}
|
||||||
currentImgData := buf.Bytes()
|
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))
|
util.Logger.Error("Failed to save variant", zap.String("variant", v.name), zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 保存今日额外文件
|
// 3. 创建 ImageRegion 记录
|
||||||
today := time.Now().Format("2006-01-02")
|
regionRecord := model.ImageRegion{
|
||||||
if dateStr == today && config.GetConfig().Feature.WriteDailyFiles {
|
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)
|
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)
|
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)
|
key := f.generateKey(imageName, variant, format)
|
||||||
contentType := "image/jpeg"
|
contentType := "image/jpeg"
|
||||||
if format == "webp" {
|
if format == "webp" {
|
||||||
@@ -319,13 +290,12 @@ func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, imageName,
|
|||||||
exists, _ := storage.GlobalStorage.Exists(ctx, key)
|
exists, _ := storage.GlobalStorage.Exists(ctx, key)
|
||||||
if exists {
|
if exists {
|
||||||
util.Logger.Debug("Variant already exists in storage, linking", zap.String("key", key))
|
util.Logger.Debug("Variant already exists in storage, linking", zap.String("key", key))
|
||||||
// 如果存在,我们需要获取它的大小和公共 URL (如果可能)
|
// 如果存在,尝试获取公共 URL
|
||||||
// 但目前的 Storage 接口没有 Stat,我们可以尝试 Get 或者干脆 size 为 0
|
|
||||||
// 为了简单,我们只从存储中获取公共 URL
|
|
||||||
if pURL, ok := storage.GlobalStorage.PublicURL(key); ok {
|
if pURL, ok := storage.GlobalStorage.PublicURL(key); ok {
|
||||||
publicURL = pURL
|
publicURL = pURL
|
||||||
}
|
}
|
||||||
// size 暂时设为 0 或者从 data 中取 (如果有的话)
|
|
||||||
|
// 如果传入了数据,则使用数据大小
|
||||||
if data != nil {
|
if data != nil {
|
||||||
size = int64(len(data))
|
size = int64(len(data))
|
||||||
}
|
}
|
||||||
@@ -342,7 +312,7 @@ func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, imageName,
|
|||||||
}
|
}
|
||||||
|
|
||||||
vRecord := model.ImageVariant{
|
vRecord := model.ImageVariant{
|
||||||
ImageID: img.ID,
|
ImageName: imageName,
|
||||||
Variant: variant,
|
Variant: variant,
|
||||||
Format: format,
|
Format: format,
|
||||||
StorageKey: key,
|
StorageKey: key,
|
||||||
@@ -351,7 +321,7 @@ func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, imageName,
|
|||||||
}
|
}
|
||||||
|
|
||||||
return repo.DB.Clauses(clause.OnConflict{
|
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,
|
DoNothing: true,
|
||||||
}).Create(&vRecord).Error
|
}).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")
|
jpegPathRoot := filepath.Join(localRoot, "daily.jpeg")
|
||||||
fJpegRoot, err := os.Create(jpegPathRoot)
|
fJpegRoot, err := os.Create(jpegPathRoot)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -15,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")
|
||||||
@@ -28,42 +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(®ionRecords).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")
|
||||||
util.Logger.Debug("Getting today image", zap.String("mkt", mkt), zap.String("today", today))
|
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)
|
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))
|
||||||
@@ -81,13 +93,15 @@ func GetTodayImage(mkt string) (*model.Image, error) {
|
|||||||
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 market", zap.String("mkt", mkt), zap.String("defaultMkt", defaultMkt))
|
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)
|
||||||
}
|
}
|
||||||
@@ -95,18 +109,18 @@ func GetTodayImage(mkt string) (*model.Image, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err == nil {
|
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
|
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 {
|
for _, mkt := range regions {
|
||||||
img, err := GetTodayImage(mkt)
|
img, err := GetTodayImage(mkt)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -116,19 +130,16 @@ func GetAllRegionsTodayImages() ([]model.Image, error) {
|
|||||||
return images, nil
|
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))
|
util.Logger.Debug("Getting random image", zap.String("mkt", mkt))
|
||||||
var img model.Image
|
var imgRegion model.ImageRegion
|
||||||
// SQLite 使用 RANDOM(), MySQL/Postgres 使用 RANDOM() 或 RAND()
|
|
||||||
// 简单起见,先查总数再 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() {
|
||||||
@@ -141,15 +152,14 @@ func GetRandomImage(mkt string) (*model.Image, error) {
|
|||||||
return nil, fmt.Errorf("no images found")
|
return nil, fmt.Errorf("no images found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优化随机查询:使用 Offset 代替 ORDER BY RANDOM()
|
|
||||||
// 注意:tx 包含了前面的 Where 条件
|
|
||||||
offset := rand.Intn(int(count))
|
offset := rand.Intn(int(count))
|
||||||
util.Logger.Debug("Random image selection", zap.Int64("total", count), zap.Int("offset", offset))
|
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 || imgRegion.ID == 0) && mkt != "" && config.GetConfig().API.EnableMktFallback {
|
||||||
if (err != nil || img.ID == 0) && 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))
|
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)
|
||||||
@@ -157,27 +167,24 @@ func GetRandomImage(mkt string) (*model.Image, error) {
|
|||||||
return GetRandomImage("")
|
return GetRandomImage("")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == nil && img.ID == 0 {
|
if err == nil && imgRegion.ID == 0 {
|
||||||
return nil, fmt.Errorf("no images found")
|
return nil, fmt.Errorf("no images found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == nil {
|
return &imgRegion, err
|
||||||
util.Logger.Debug("Found random image", zap.String("date", img.Date), zap.String("mkt", img.Mkt))
|
|
||||||
}
|
|
||||||
|
|
||||||
return &img, 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))
|
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)
|
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() {
|
||||||
@@ -186,39 +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()
|
||||||
util.Logger.Debug("Image by date not found, trying fallback", zap.String("date", date), zap.String("mkt", mkt), zap.String("defaultMkt", defaultMkt))
|
|
||||||
if mkt != defaultMkt {
|
if mkt != defaultMkt {
|
||||||
return GetImageByDate(date, defaultMkt)
|
return GetImageByDate(date, defaultMkt)
|
||||||
}
|
}
|
||||||
return GetImageByDate(date, "")
|
return GetImageByDate(date, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == nil {
|
return &imgRegion, err
|
||||||
util.Logger.Debug("Found image by date", zap.String("date", img.Date), zap.String("mkt", img.Mkt))
|
|
||||||
}
|
|
||||||
return &img, 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) {
|
||||||
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.ImageRegion
|
||||||
var images []model.Image
|
tx := repo.DB.Model(&model.ImageRegion{})
|
||||||
tx := repo.DB.Model(&model.Image{})
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -228,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -364,9 +372,18 @@ 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)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 格式化日期
|
// 格式化日期
|
||||||
|
|||||||
Reference in New Issue
Block a user