10 Commits

25 changed files with 1343 additions and 398 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,24 +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
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:
@@ -46,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

@@ -15,22 +15,3 @@ services:
- 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

@@ -440,6 +440,24 @@ const docTemplate = `{
"schema": { "schema": {
"type": "file" "type": "file"
} }
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
} }
} }
} }
@@ -475,6 +493,24 @@ const docTemplate = `{
"schema": { "schema": {
"$ref": "#/definitions/handlers.ImageMetaResp" "$ref": "#/definitions/handlers.ImageMetaResp"
} }
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
} }
} }
} }
@@ -517,6 +553,24 @@ const docTemplate = `{
"schema": { "schema": {
"type": "file" "type": "file"
} }
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
} }
} }
} }
@@ -545,6 +599,24 @@ const docTemplate = `{
"schema": { "schema": {
"$ref": "#/definitions/handlers.ImageMetaResp" "$ref": "#/definitions/handlers.ImageMetaResp"
} }
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
} }
} }
} }
@@ -587,6 +659,24 @@ const docTemplate = `{
"schema": { "schema": {
"type": "file" "type": "file"
} }
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
} }
} }
} }
@@ -615,6 +705,24 @@ const docTemplate = `{
"schema": { "schema": {
"$ref": "#/definitions/handlers.ImageMetaResp" "$ref": "#/definitions/handlers.ImageMetaResp"
} }
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
} }
} }
} }
@@ -675,6 +783,29 @@ const docTemplate = `{
} }
} }
}, },
"/images/global/today": {
"get": {
"description": "获取配置文件中所有已开启地区的今日必应图片元数据(缩略图)",
"produces": [
"application/json"
],
"tags": [
"image"
],
"summary": "获取所有地区的今日图片列表",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/handlers.ImageMetaResp"
}
}
}
}
}
},
"/regions": { "/regions": {
"get": { "get": {
"description": "返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。", "description": "返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。",
@@ -707,6 +838,10 @@ const docTemplate = `{
"description": "当请求的地区不存在时,是否回退到默认地区", "description": "当请求的地区不存在时,是否回退到默认地区",
"type": "boolean" "type": "boolean"
}, },
"enableOnDemandFetch": {
"description": "是否启用按需抓取",
"type": "boolean"
},
"mode": { "mode": {
"description": "local | redirect", "description": "local | redirect",
"type": "string" "type": "string"

View File

@@ -434,6 +434,24 @@
"schema": { "schema": {
"type": "file" "type": "file"
} }
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
} }
} }
} }
@@ -469,6 +487,24 @@
"schema": { "schema": {
"$ref": "#/definitions/handlers.ImageMetaResp" "$ref": "#/definitions/handlers.ImageMetaResp"
} }
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
} }
} }
} }
@@ -511,6 +547,24 @@
"schema": { "schema": {
"type": "file" "type": "file"
} }
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
} }
} }
} }
@@ -539,6 +593,24 @@
"schema": { "schema": {
"$ref": "#/definitions/handlers.ImageMetaResp" "$ref": "#/definitions/handlers.ImageMetaResp"
} }
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
} }
} }
} }
@@ -581,6 +653,24 @@
"schema": { "schema": {
"type": "file" "type": "file"
} }
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
} }
} }
} }
@@ -609,6 +699,24 @@
"schema": { "schema": {
"$ref": "#/definitions/handlers.ImageMetaResp" "$ref": "#/definitions/handlers.ImageMetaResp"
} }
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
} }
} }
} }
@@ -669,6 +777,29 @@
} }
} }
}, },
"/images/global/today": {
"get": {
"description": "获取配置文件中所有已开启地区的今日必应图片元数据(缩略图)",
"produces": [
"application/json"
],
"tags": [
"image"
],
"summary": "获取所有地区的今日图片列表",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/handlers.ImageMetaResp"
}
}
}
}
}
},
"/regions": { "/regions": {
"get": { "get": {
"description": "返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。", "description": "返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。",
@@ -701,6 +832,10 @@
"description": "当请求的地区不存在时,是否回退到默认地区", "description": "当请求的地区不存在时,是否回退到默认地区",
"type": "boolean" "type": "boolean"
}, },
"enableOnDemandFetch": {
"description": "是否启用按需抓取",
"type": "boolean"
},
"mode": { "mode": {
"description": "local | redirect", "description": "local | redirect",
"type": "string" "type": "string"

View File

@@ -5,6 +5,9 @@ definitions:
enableMktFallback: enableMktFallback:
description: 当请求的地区不存在时,是否回退到默认地区 description: 当请求的地区不存在时,是否回退到默认地区
type: boolean type: boolean
enableOnDemandFetch:
description: 是否启用按需抓取
type: boolean
mode: mode:
description: local | redirect description: local | redirect
type: string type: string
@@ -543,6 +546,18 @@ paths:
description: OK description: OK
schema: schema:
type: file type: file
"202":
description: 按需抓取任务已启动
schema:
additionalProperties:
type: string
type: object
"404":
description: 图片未找到,响应体包含具体原因
schema:
additionalProperties:
type: string
type: object
summary: 获取指定日期图片 summary: 获取指定日期图片
tags: tags:
- image - image
@@ -566,6 +581,18 @@ paths:
description: OK description: OK
schema: schema:
$ref: '#/definitions/handlers.ImageMetaResp' $ref: '#/definitions/handlers.ImageMetaResp'
"202":
description: 按需抓取任务已启动
schema:
additionalProperties:
type: string
type: object
"404":
description: 图片未找到,响应体包含具体原因
schema:
additionalProperties:
type: string
type: object
summary: 获取指定日期图片元数据 summary: 获取指定日期图片元数据
tags: tags:
- image - image
@@ -594,6 +621,18 @@ paths:
description: OK description: OK
schema: schema:
type: file type: file
"202":
description: 按需抓取任务已启动
schema:
additionalProperties:
type: string
type: object
"404":
description: 图片未找到,响应体包含具体原因
schema:
additionalProperties:
type: string
type: object
summary: 获取随机图片 summary: 获取随机图片
tags: tags:
- image - image
@@ -612,6 +651,18 @@ paths:
description: OK description: OK
schema: schema:
$ref: '#/definitions/handlers.ImageMetaResp' $ref: '#/definitions/handlers.ImageMetaResp'
"202":
description: 按需抓取任务已启动
schema:
additionalProperties:
type: string
type: object
"404":
description: 图片未找到,响应体包含具体原因
schema:
additionalProperties:
type: string
type: object
summary: 获取随机图片元数据 summary: 获取随机图片元数据
tags: tags:
- image - image
@@ -641,6 +692,18 @@ paths:
description: OK description: OK
schema: schema:
type: file type: file
"202":
description: 按需抓取任务已启动
schema:
additionalProperties:
type: string
type: object
"404":
description: 图片未找到,响应体包含具体原因
schema:
additionalProperties:
type: string
type: object
summary: 获取今日图片 summary: 获取今日图片
tags: tags:
- image - image
@@ -659,6 +722,18 @@ paths:
description: OK description: OK
schema: schema:
$ref: '#/definitions/handlers.ImageMetaResp' $ref: '#/definitions/handlers.ImageMetaResp'
"202":
description: 按需抓取任务已启动
schema:
additionalProperties:
type: string
type: object
"404":
description: 图片未找到,响应体包含具体原因
schema:
additionalProperties:
type: string
type: object
summary: 获取今日图片元数据 summary: 获取今日图片元数据
tags: tags:
- image - image
@@ -700,6 +775,21 @@ paths:
summary: 获取图片列表 summary: 获取图片列表
tags: tags:
- image - image
/images/global/today:
get:
description: 获取配置文件中所有已开启地区的今日必应图片元数据(缩略图)
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/handlers.ImageMetaResp'
type: array
summary: 获取所有地区的今日图片列表
tags:
- image
/regions: /regions:
get: get:
description: 返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。 description: 返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。

View File

@@ -60,8 +60,9 @@ func (c LogConfig) GetShowDBLog() bool { return c.ShowDBLog }
func (c LogConfig) GetDBLogLevel() string { return c.DBLogLevel } func (c LogConfig) GetDBLogLevel() string { return c.DBLogLevel }
type APIConfig struct { type APIConfig struct {
Mode string `mapstructure:"mode" yaml:"mode"` // local | redirect Mode string `mapstructure:"mode" yaml:"mode"` // local | redirect
EnableMktFallback bool `mapstructure:"enable_mkt_fallback" yaml:"enable_mkt_fallback"` // 当请求的地区不存在时,是否回退到默认地区 EnableMktFallback bool `mapstructure:"enable_mkt_fallback" yaml:"enable_mkt_fallback"` // 当请求的地区不存在时,是否回退到默认地区
EnableOnDemandFetch bool `mapstructure:"enable_on_demand_fetch" yaml:"enable_on_demand_fetch"` // 是否启用按需抓取
} }
type CronConfig struct { type CronConfig struct {
@@ -165,7 +166,8 @@ func Init(configPath string) error {
v.SetDefault("log.show_db_log", false) v.SetDefault("log.show_db_log", false)
v.SetDefault("log.db_log_level", "info") v.SetDefault("log.db_log_level", "info")
v.SetDefault("api.mode", "redirect") v.SetDefault("api.mode", "redirect")
v.SetDefault("api.enable_mkt_fallback", true) v.SetDefault("api.enable_mkt_fallback", false)
v.SetDefault("api.enable_on_demand_fetch", false)
v.SetDefault("cron.enabled", true) v.SetDefault("cron.enabled", true)
v.SetDefault("cron.daily_spec", "20 8-23/4 * * *") v.SetDefault("cron.daily_spec", "20 8-23/4 * * *")
v.SetDefault("retention.days", 0) v.SetDefault("retention.days", 0)
@@ -328,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

@@ -5,8 +5,8 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"sort"
"strconv" "strconv"
"strings"
"BingPaper/internal/config" "BingPaper/internal/config"
"BingPaper/internal/model" "BingPaper/internal/model"
@@ -48,15 +48,21 @@ type ImageMetaResp struct {
// @Param format query string false "格式 (jpg)" default(jpg) // @Param format query string false "格式 (jpg)" default(jpg)
// @Produce image/jpeg // @Produce image/jpeg
// @Success 200 {file} binary // @Success 200 {file} binary
// @Success 202 {object} map[string]string "按需抓取任务已启动"
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @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 != nil { if err == image.ErrFetchStarted {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
return return
} }
handleImageResponse(c, img, 7200) // 2小时 if err != nil {
sendImageNotFound(c, mkt)
return
}
handleImageResponse(c, imgRegion, 7200) // 2小时
} }
// GetTodayMeta 获取今日图片元数据 // GetTodayMeta 获取今日图片元数据
@@ -66,16 +72,22 @@ func GetToday(c *gin.Context) {
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)" // @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
// @Produce json // @Produce json
// @Success 200 {object} ImageMetaResp // @Success 200 {object} ImageMetaResp
// @Success 202 {object} map[string]string "按需抓取任务已启动"
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @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 {
c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
return
}
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) sendImageNotFound(c, mkt)
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 获取随机图片
@@ -87,15 +99,22 @@ func GetTodayMeta(c *gin.Context) {
// @Param format query string false "格式" default(jpg) // @Param format query string false "格式" default(jpg)
// @Produce image/jpeg // @Produce image/jpeg
// @Success 200 {file} binary // @Success 200 {file} binary
// @Success 202 {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 != nil { if err == image.ErrFetchStarted {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
return return
} }
handleImageResponse(c, img, 0) // 禁用缓存 if err != nil {
sendImageNotFound(c, mkt)
return
}
handleImageResponse(c, imgRegion, 0) // 禁用缓存
} }
// GetRandomMeta 获取随机图片元数据 // GetRandomMeta 获取随机图片元数据
@@ -105,16 +124,22 @@ func GetRandom(c *gin.Context) {
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)" // @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
// @Produce json // @Produce json
// @Success 200 {object} ImageMetaResp // @Success 200 {object} ImageMetaResp
// @Success 202 {object} map[string]string "按需抓取任务已启动"
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @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 {
c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
return
}
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) sendImageNotFound(c, mkt)
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 获取指定日期图片
@@ -127,16 +152,22 @@ func GetRandomMeta(c *gin.Context) {
// @Param format query string false "格式" default(jpg) // @Param format query string false "格式" default(jpg)
// @Produce image/jpeg // @Produce image/jpeg
// @Success 200 {file} binary // @Success 200 {file} binary
// @Success 202 {object} map[string]string "按需抓取任务已启动"
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/date/{date} [get] // @Router /image/date/{date} [get]
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 != nil { if err == image.ErrFetchStarted {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
return return
} }
handleImageResponse(c, img, 604800) // 7天 if err != nil {
sendImageNotFound(c, mkt)
return
}
handleImageResponse(c, imgRegion, 604800) // 7天
} }
// GetByDateMeta 获取指定日期图片元数据 // GetByDateMeta 获取指定日期图片元数据
@@ -147,17 +178,23 @@ func GetByDate(c *gin.Context) {
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)" // @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
// @Produce json // @Produce json
// @Success 200 {object} ImageMetaResp // @Success 200 {object} ImageMetaResp
// @Success 202 {object} map[string]string "按需抓取任务已启动"
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/date/{date}/meta [get] // @Router /image/date/{date}/meta [get]
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 {
c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
return
}
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) sendImageNotFound(c, mkt)
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 获取图片列表
@@ -223,21 +260,70 @@ func ListImages(c *gin.Context) {
c.JSON(http.StatusOK, result) c.JSON(http.StatusOK, result)
} }
func handleImageResponse(c *gin.Context, img *model.Image, maxAge int) { // ListGlobalTodayImages 获取所有地区的今日图片列表
// @Summary 获取所有地区的今日图片列表
// @Description 获取配置文件中所有已开启地区的今日必应图片元数据(缩略图)
// @Tags image
// @Produce json
// @Success 200 {array} ImageMetaResp
// @Router /images/global/today [get]
func ListGlobalTodayImages(c *gin.Context) {
images, err := image.GetAllRegionsTodayImages()
if err != nil {
util.Logger.Error("ListGlobalTodayImages service call failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
result := []gin.H{}
for _, img := range images {
result = append(result, formatMetaSummary(&img))
}
c.JSON(http.StatusOK, result)
}
func sendImageNotFound(c *gin.Context, mkt string) {
cfg := config.GetConfig().API
message := "image not found"
if mkt != "" {
reasons := []string{}
if !util.IsValidRegion(mkt) {
reasons = append(reasons, fmt.Sprintf("[%s] is not a standard region code", mkt))
} else {
if !cfg.EnableOnDemandFetch {
reasons = append(reasons, "on-demand fetch is disabled")
}
if !cfg.EnableMktFallback {
reasons = append(reasons, "region fallback is disabled")
}
}
if len(reasons) > 0 {
message = fmt.Sprintf("Image not found for region [%s]. Reasons: %s.", mkt, strings.Join(reasons, ", "))
} else {
message = fmt.Sprintf("Image not found for region [%s] even after on-demand fetch and fallback attempts.", mkt)
}
}
c.JSON(http.StatusNotFound, gin.H{"error": message})
}
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 {
@@ -254,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 {
@@ -264,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)
} }
} }
@@ -300,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,
@@ -330,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,
@@ -363,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,
} }
} }
@@ -387,34 +484,85 @@ func GetRegions(c *gin.Context) {
cfg := config.GetConfig() cfg := config.GetConfig()
pinned := cfg.Fetcher.Regions pinned := cfg.Fetcher.Regions
// 创建副本以避免修改原始全局变量 if len(pinned) == 0 {
all := make([]util.Region, len(util.AllRegions)) // 如果没有配置抓取地区,返回所有支持的地区
copy(all, util.AllRegions) c.JSON(http.StatusOK, util.AllRegions)
return
if len(pinned) > 0 {
// 创建一个 Map 用于快速查找置顶地区及其顺序
pinnedMap := make(map[string]int)
for i, v := range pinned {
pinnedMap[v] = i
}
// 对列表进行稳定排序,使置顶地区排在前面
sort.SliceStable(all, func(i, j int) bool {
idxI, okI := pinnedMap[all[i].Value]
idxJ, okJ := pinnedMap[all[j].Value]
if okI && okJ {
return idxI < idxJ
}
if okI {
return true
}
if okJ {
return false
}
return false // 保持非置顶地区的原有相对顺序
})
} }
c.JSON(http.StatusOK, all) // 创建一个 Map 用于快速查找配置的地区
pinnedMap := make(map[string]bool)
for _, v := range pinned {
pinnedMap[v] = true
}
// 只返回配置中的地区,并保持配置中的顺序
var result []util.Region
// 为了保持配置顺序,我们遍历 pinned 而不是 AllRegions
for _, pVal := range pinned {
for _, r := range util.AllRegions {
if r.Value == pVal {
result = append(result, r)
break
}
}
}
// 如果配置了一些不在 AllRegions 里的 mkt上述循环可能漏掉
// 但根据之前的逻辑AllRegions 是已知的 17 个地区。
// 如果用户配置了 fr-CA (不在 17 个内),我们也应该返回它吗?
// 需求说 "前端页面对地区进行约束",如果配置了,前端就该显示。
// 如果不在 AllRegions 里的,我们直接返回原始编码作为 label 或者查找一下。
if len(result) < len(pinned) {
// 补全不在 AllRegions 里的地区
for _, pVal := range pinned {
found := false
for _, r := range result {
if r.Value == pVal {
found = true
break
}
}
if !found {
result = append(result, util.Region{Value: pVal, Label: pVal})
}
}
}
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

@@ -47,6 +47,7 @@ func SetupRouter(webFS embed.FS) *gin.Engine {
img.GET("/date/:date/meta", handlers.GetByDateMeta) img.GET("/date/:date/meta", handlers.GetByDateMeta)
} }
api.GET("/images", handlers.ListImages) api.GET("/images", handlers.ListImages)
api.GET("/images/global/today", handlers.ListGlobalTodayImages)
api.GET("/regions", handlers.GetRegions) api.GET("/regions", handlers.GetRegions)
// 管理接口 // 管理接口

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,19 +58,12 @@ 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 {
util.Logger.Info("Fetching images for region", zap.String("mkt", mkt)) if err := f.FetchRegion(ctx, mkt); err != nil {
// 调用两次 API 获取最多两周的数据 util.Logger.Error("Failed to fetch region images", zap.String("mkt", mkt), zap.Error(err))
// 第一次 idx=0&n=8 (今天起往回数 8 张)
if err := f.fetchByMkt(ctx, mkt, 0, 8); err != nil {
util.Logger.Error("Failed to fetch images", zap.String("mkt", mkt), zap.Int("idx", 0), zap.Error(err))
}
// 第二次 idx=7&n=8 (7天前起往回数 8 张,与第一次有重叠,确保不漏)
if err := f.fetchByMkt(ctx, mkt, 7, 8); err != nil {
util.Logger.Error("Failed to fetch images", zap.String("mkt", mkt), zap.Int("idx", 7), zap.Error(err))
} }
} }
@@ -77,16 +71,51 @@ func (f *Fetcher) Fetch(ctx context.Context, n int) error {
return nil return nil
} }
// FetchRegion 抓取指定地区的图片
func (f *Fetcher) FetchRegion(ctx context.Context, mkt string) error {
if !util.IsValidRegion(mkt) {
util.Logger.Warn("Skipping fetch for invalid region", zap.String("mkt", mkt))
return fmt.Errorf("invalid region code: %s", mkt)
}
util.Logger.Info("Fetching images for region", zap.String("mkt", mkt))
// 调用两次 API 获取最多两周的数据
// 第一次 idx=0&n=8 (今天起往回数 8 张)
if err := f.fetchByMkt(ctx, mkt, 0, 8); err != nil {
util.Logger.Error("Failed to fetch images", zap.String("mkt", mkt), zap.Int("idx", 0), zap.Error(err))
return err
}
// 第二次 idx=7&n=8 (7天前起往回数 8 张,与第一次有重叠,确保不漏)
if err := f.fetchByMkt(ctx, mkt, 7, 8); err != nil {
util.Logger.Error("Failed to fetch images", zap.String("mkt", mkt), zap.Int("idx", 7), zap.Error(err))
// 第二次失败不一定返回错误,因为可能第一次已经拿到了
}
return nil
}
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))
@@ -96,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))
} }
@@ -107,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
@@ -183,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
} }
@@ -235,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) {
@@ -293,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

@@ -2,18 +2,24 @@ package image
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"math/rand"
"time" "time"
"BingPaper/internal/config" "BingPaper/internal/config"
"BingPaper/internal/model" "BingPaper/internal/model"
"BingPaper/internal/repo" "BingPaper/internal/repo"
"BingPaper/internal/service/fetcher"
"BingPaper/internal/storage" "BingPaper/internal/storage"
"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")
func CleanupOldImages(ctx context.Context) error { func CleanupOldImages(ctx context.Context) error {
days := config.GetConfig().Retention.Days days := config.GetConfig().Retention.Days
if days <= 0 { if days <= 0 {
@@ -23,139 +29,195 @@ 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) {
// 如果没找到,尝试异步按需抓取该地区
util.Logger.Info("Image not found in DB, starting asynchronous on-demand fetch", zap.String("mkt", mkt))
f := fetcher.NewFetcher()
go func() {
_ = f.FetchRegion(context.Background(), mkt)
}()
return nil, ErrFetchStarted
}
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 GetRandomImage(mkt string) (*model.Image, error) { func GetAllRegionsTodayImages() ([]model.ImageRegion, error) {
var img model.Image today := time.Now().Format("2006-01-02")
// SQLite 使用 RANDOM(), MySQL/Postgres 使用 RANDOM() 或 RAND() regions := config.GetConfig().Fetcher.Regions
// 简单起见,先查总数再 Offset if len(regions) == 0 {
regions = []string{config.GetConfig().GetDefaultRegion()}
}
var images []model.ImageRegion
err := repo.DB.Where("date = ? AND mkt IN ?", today, regions).
Preload("Variants", func(db *gorm.DB) *gorm.DB {
return db.Order("size asc")
}).Find(&images).Error
return images, err
}
func GetRandomImage(mkt string) (*model.ImageRegion, error) {
util.Logger.Debug("Getting random image", zap.String("mkt", mkt))
var imgRegion model.ImageRegion
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) {
util.Logger.Info("No images found in DB for region, starting asynchronous on-demand fetch", zap.String("mkt", mkt))
f := fetcher.NewFetcher()
go func() {
_ = f.FetchRegion(context.Background(), mkt)
}()
return nil, ErrFetchStarted
}
if count == 0 { if count == 0 {
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) {
util.Logger.Info("Image not found in DB for date, starting asynchronous on-demand fetch", zap.String("mkt", mkt), zap.String("date", date))
f := fetcher.NewFetcher()
go func() {
_ = f.FetchRegion(context.Background(), mkt)
}()
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)
@@ -165,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

@@ -1,26 +1,35 @@
package util package util
import "golang.org/x/text/language"
type Region struct { type Region struct {
Value string `json:"value"` Value string `json:"value"`
Label string `json:"label"` Label string `json:"label"`
} }
var AllRegions = []Region{ // IsValidRegion 校验是否为标准的地区编码 (BCP 47)
{Value: "zh-CN", Label: "中国 (zh-CN)"}, func IsValidRegion(mkt string) bool {
{Value: "en-US", Label: "美国 (en-US)"}, if mkt == "" {
{Value: "ja-JP", Label: "日本 (ja-JP)"}, return false
{Value: "en-AU", Label: "澳大利亚 (en-AU)"}, }
{Value: "en-GB", Label: "英国 (en-GB)"}, _, err := language.Parse(mkt)
{Value: "de-DE", Label: "德国 (de-DE)"}, return err == nil
{Value: "en-NZ", Label: "新西兰 (en-NZ)"}, }
{Value: "en-CA", Label: "加拿大 (en-CA)"},
{Value: "fr-FR", Label: "法国 (fr-FR)"}, var AllRegions = []Region{
{Value: "it-IT", Label: "意大利 (it-IT)"}, {Value: "zh-CN", Label: "中国"},
{Value: "es-ES", Label: "西班牙 (es-ES)"}, {Value: "en-US", Label: "美国"},
{Value: "pt-BR", Label: "巴西 (pt-BR)"}, {Value: "ja-JP", Label: "日本"},
{Value: "ko-KR", Label: "韩国 (ko-KR)"}, {Value: "en-AU", Label: "澳大利亚"},
{Value: "en-IN", Label: "印度 (en-IN)"}, {Value: "en-GB", Label: "英国"},
{Value: "ru-RU", Label: "俄罗斯 (ru-RU)"}, {Value: "de-DE", Label: "德国"},
{Value: "zh-HK", Label: "中国香港 (zh-HK)"}, {Value: "en-NZ", Label: "新西兰"},
{Value: "zh-TW", Label: "中国台湾 (zh-TW)"}, {Value: "en-CA", Label: "加拿大"},
{Value: "fr-FR", Label: "法国"},
{Value: "it-IT", Label: "意大利"},
{Value: "es-ES", Label: "西班牙"},
{Value: "pt-BR", Label: "巴西"},
{Value: "ko-KR", Label: "韩国"},
{Value: "en-IN", Label: "印度"},
{Value: "ru-RU", Label: "俄罗斯"},
} }

View File

@@ -37,6 +37,39 @@ export function useTodayImage(mkt?: string) {
} }
} }
/**
* 获取全球今日图片
*/
export function useGlobalTodayImages() {
const images = ref<ImageMeta[]>([])
const loading = ref(false)
const error = ref<Error | null>(null)
const fetchImages = async () => {
loading.value = true
error.value = null
try {
images.value = await bingPaperApi.getGlobalTodayImages()
} catch (e) {
error.value = e as Error
console.error('Failed to fetch global today images:', e)
} finally {
loading.value = false
}
}
onMounted(() => {
fetchImages()
})
return {
images,
loading,
error,
refetch: fetchImages
}
}
/** /**
* 获取图片列表(支持分页和月份筛选) * 获取图片列表(支持分页和月份筛选)
*/ */

View File

@@ -118,6 +118,13 @@ export class BingPaperApiService {
return apiClient.get<ImageMeta[]>(endpoint) return apiClient.get<ImageMeta[]>(endpoint)
} }
/**
* 获取所有地区的今日图片列表
*/
async getGlobalTodayImages(): Promise<ImageMeta[]> {
return apiClient.get<ImageMeta[]>('/images/global/today')
}
/** /**
* 获取支持的地区列表 * 获取支持的地区列表
*/ */
@@ -212,6 +219,7 @@ export const {
manualFetch, manualFetch,
manualCleanup, manualCleanup,
getImages, getImages,
getGlobalTodayImages,
getRegions, getRegions,
getTodayImageMeta, getTodayImageMeta,
getImageMetaByDate, getImageMetaByDate,

View File

@@ -75,6 +75,7 @@ export interface AdminConfig {
export interface APIConfig { export interface APIConfig {
Mode: string // 'local' | 'redirect' Mode: string // 'local' | 'redirect'
EnableMktFallback: boolean EnableMktFallback: boolean
EnableOnDemandFetch: boolean
} }
export interface CronConfig { export interface CronConfig {

View File

@@ -6,23 +6,23 @@ const DEFAULT_MKT = 'zh-CN'
* 默认地区列表 (兜底用) * 默认地区列表 (兜底用)
*/ */
export const DEFAULT_REGIONS = [ export const DEFAULT_REGIONS = [
{ value: 'zh-CN', label: '中国 (zh-CN)' }, { value: 'zh-CN', label: '中国' },
{ value: 'en-US', label: '美国 (en-US)' }, { value: 'en-US', label: '美国' },
{ value: 'ja-JP', label: '日本 (ja-JP)' }, { value: 'ja-JP', label: '日本' },
{ value: 'en-AU', label: '澳大利亚 (en-AU)' }, { value: 'en-AU', label: '澳大利亚' },
{ value: 'en-GB', label: '英国 (en-GB)' }, { value: 'en-GB', label: '英国' },
{ value: 'de-DE', label: '德国 (de-DE)' }, { value: 'de-DE', label: '德国' },
{ value: 'en-NZ', label: '新西兰 (en-NZ)' }, { value: 'en-NZ', label: '新西兰' },
{ value: 'en-CA', label: '加拿大 (en-CA)' }, { value: 'en-CA', label: '加拿大' },
{ value: 'fr-FR', label: '法国 (fr-FR)' }, { value: 'fr-FR', label: '法国' },
{ value: 'it-IT', label: '意大利 (it-IT)' }, { value: 'it-IT', label: '意大利' },
{ value: 'es-ES', label: '西班牙 (es-ES)' }, { value: 'es-ES', label: '西班牙' },
{ value: 'pt-BR', label: '巴西 (pt-BR)' }, { value: 'pt-BR', label: '巴西' },
{ value: 'ko-KR', label: '韩国 (ko-KR)' }, { value: 'ko-KR', label: '韩国' },
{ value: 'en-IN', label: '印度 (en-IN)' }, { value: 'en-IN', label: '印度' },
{ value: 'ru-RU', label: '俄罗斯 (ru-RU)' }, { value: 'ru-RU', label: '俄罗斯' },
{ value: 'zh-HK', label: '中国香港 (zh-HK)' }, { value: 'zh-HK', label: '中国香港' },
{ value: 'zh-TW', label: '中国台湾 (zh-TW)' }, { value: 'zh-TW', label: '中国台湾' },
] ]
/** /**

View File

@@ -102,16 +102,33 @@
local: 直接返回图片流; redirect: 重定向到存储位置 local: 直接返回图片流; redirect: 重定向到存储位置
</p> </p>
</div> </div>
<div class="flex items-center gap-2"> <div class="space-y-4">
<Label for="api-fallback">启用地区不存在时兜底</Label> <div class="flex items-center justify-between">
<Switch <div class="space-y-0.5">
id="api-fallback" <Label for="api-fallback">启用地区不存在时兜底</Label>
v-model="config.API.EnableMktFallback" <p class="text-xs text-gray-500">
/> 如果请求的地区无数据自动回退到默认地区
</p>
</div>
<Switch
id="api-fallback"
v-model="config.API.EnableMktFallback"
/>
</div>
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label for="api-on-demand">启用按需实时抓取</Label>
<p class="text-xs text-gray-500">
如果请求的地区无数据尝试实时从 Bing 抓取
</p>
</div>
<Switch
id="api-on-demand"
v-model="config.API.EnableOnDemandFetch"
/>
</div>
</div> </div>
<p class="text-xs text-gray-500">
如果请求的地区无数据自动回退到默认地区
</p>
</CardContent> </CardContent>
</Card> </Card>
@@ -465,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

@@ -69,6 +69,89 @@
</div> </div>
</section> </section>
<!-- Global Section - 必应全球 -->
<section v-if="globalImages.length > 0" class="py-16 px-4 md:px-8 lg:px-16 bg-white/5 border-y border-white/5">
<div class="max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-8">
<div class="space-y-1">
<h2 class="text-3xl md:text-4xl font-bold text-white">
必应全球
</h2>
</div>
</div>
<div class="relative group">
<!-- 左右切换按钮 -->
<button
v-show="canScrollLeft"
@click="scrollGlobal('left')"
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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
</button>
<button
v-show="canScrollRight"
@click="scrollGlobal('right')"
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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</button>
<div
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"
>
<div
v-for="image in globalImages"
:key="image.mkt! + image.date!"
class="flex-none w-[280px] md:w-[350px] aspect-video rounded-xl overflow-hidden cursor-pointer transform transition-all duration-500 hover:scale-[1.02] hover:shadow-2xl snap-start relative group/card"
@click="viewImage(image.date!, image.mkt!)"
>
<!-- 图片层 -->
<img
:src="getImageUrl(image)"
:alt="image.title"
class="w-full h-full object-cover transition-transform duration-700 group-hover/card:scale-110"
/>
<!-- 渐变层 -->
<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent group-hover/card:via-black/40 transition-colors duration-500"></div>
<!-- 内容层 -->
<div class="absolute inset-0 flex flex-col justify-end p-5">
<div class="text-[10px] md:text-xs text-white/60 mb-1 transform translate-y-2 group-hover/card:translate-y-0 transition-transform duration-500">
{{ formatDate(image.date) }}
</div>
<div class="flex items-center gap-2 mb-2 transform translate-y-2 group-hover/card:translate-y-0 transition-transform duration-500">
<span class="text-xs text-white/70 font-medium">
{{ getRegionLabel(image.mkt) }}
</span>
</div>
<h3 class="text-white font-bold text-base md:text-lg line-clamp-1 transform translate-y-1 group-hover/card:translate-y-0 transition-transform duration-500">
{{ image.title || '必应每日一图' }}
</h3>
</div>
</div>
</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>
</section>
<!-- Gallery Section - 历史图片 --> <!-- Gallery Section - 历史图片 -->
<section class="py-16 px-4 md:px-8 lg:px-16"> <section class="py-16 px-4 md:px-8 lg:px-16">
<div class="max-w-7xl mx-auto"> <div class="max-w-7xl mx-auto">
@@ -262,7 +345,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useImageList } from '@/composables/useImages' import { useImageList, useGlobalTodayImages } from '@/composables/useImages'
import { bingPaperApi } from '@/lib/api-service' import { bingPaperApi } from '@/lib/api-service'
import { normalizeImageUrl } from '@/lib/api-config' import { normalizeImageUrl } from '@/lib/api-config'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -284,9 +367,40 @@ const regions = ref(SUPPORTED_REGIONS)
const latestImage = ref<any>(null) const latestImage = ref<any>(null)
const todayLoading = ref(false) const todayLoading = ref(false)
// 全球今日图片
const { images: globalImages } = useGlobalTodayImages()
// 滚动功能实现
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') => {
if (!globalScrollContainer.value) return
const scrollAmount = globalScrollContainer.value.clientWidth * 0.8 || 1000
globalScrollContainer.value.scrollBy({
left: direction === 'left' ? -scrollAmount : scrollAmount,
behavior: 'smooth'
})
}
// 历史图片列表使用服务端分页和筛选每页15张 // 历史图片列表使用服务端分页和筛选每页15张
const { images, loading, hasMore, loadMore, filterByMonth, filterByMkt } = useImageList(15) const { images, loading, hasMore, loadMore, filterByMonth, filterByMkt } = useImageList(15)
// 获取地区标签
const getRegionLabel = (mkt?: string) => {
if (!mkt) return ''
const region = regions.value.find(r => r.value === mkt)
return region ? region.label : mkt
}
// 加载顶部最新图片 // 加载顶部最新图片
const loadLatestImage = async () => { const loadLatestImage = async () => {
todayLoading.value = true todayLoading.value = true
@@ -303,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
@@ -532,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)
}) })
// 清理 // 清理
@@ -552,6 +688,7 @@ onUnmounted(() => {
if (loadMoreObserver) { if (loadMoreObserver) {
loadMoreObserver.disconnect() loadMoreObserver.disconnect()
} }
window.removeEventListener('resize', updateScrollState)
}) })
// 格式化日期 // 格式化日期
@@ -580,8 +717,9 @@ const getImageUrl = (image: any) => {
} }
// 查看图片详情 // 查看图片详情
const viewImage = (date: string) => { const viewImage = (date: string, mkt?: string) => {
router.push(`/image/${date}`) const query = mkt ? `?mkt=${mkt}` : ''
router.push(`/image/${date}${query}`)
} }
// 打开版权详情链接 // 打开版权详情链接
@@ -621,4 +759,13 @@ html {
html::-webkit-scrollbar { html::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */ display: none; /* Chrome, Safari, Opera */
} }
/* 隐藏横向滚动条 */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
</style> </style>