5 Commits

21 changed files with 1024 additions and 166 deletions

View File

@@ -17,6 +17,7 @@ log:
api: api:
mode: local # local | redirect mode: local # local | redirect
enable_mkt_fallback: true # 当请求的地区不存在时,是否回退到默认地区 enable_mkt_fallback: true # 当请求的地区不存在时,是否回退到默认地区
enable_on_demand_fetch: false # 是否开启按需抓取(当数据库中没有请求的地区图片时,实时从 Bing 抓取)
cron: cron:
enabled: true enabled: true

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

@@ -62,6 +62,7 @@ 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)

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,12 +48,18 @@ 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) img, 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
} }
handleImageResponse(c, img, 7200) // 2小时 handleImageResponse(c, img, 7200) // 2小时
@@ -66,12 +72,18 @@ 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) img, 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小时
@@ -87,12 +99,18 @@ 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]
func GetRandom(c *gin.Context) { func GetRandom(c *gin.Context) {
mkt := c.Query("mkt") mkt := c.Query("mkt")
img, err := image.GetRandomImage(mkt) img, 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
} }
handleImageResponse(c, img, 0) // 禁用缓存 handleImageResponse(c, img, 0) // 禁用缓存
@@ -105,12 +123,18 @@ 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) img, 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")
@@ -127,13 +151,19 @@ 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) img, 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
} }
handleImageResponse(c, img, 604800) // 7天 handleImageResponse(c, img, 604800) // 7天
@@ -147,13 +177,19 @@ 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) img, 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天
@@ -223,6 +259,55 @@ func ListImages(c *gin.Context) {
c.JSON(http.StatusOK, result) c.JSON(http.StatusOK, result)
} }
// 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, img *model.Image, maxAge int) { func handleImageResponse(c *gin.Context, img *model.Image, maxAge int) {
variant := c.DefaultQuery("variant", "UHD") variant := c.DefaultQuery("variant", "UHD")
format := c.DefaultQuery("format", "jpg") format := c.DefaultQuery("format", "jpg")
@@ -387,34 +472,51 @@ 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
} }
// 对列表进行稳定排序,使置顶地区排在前面 // 创建一个 Map 用于快速查找配置的地区
sort.SliceStable(all, func(i, j int) bool { pinnedMap := make(map[string]bool)
idxI, okI := pinnedMap[all[i].Value] for _, v := range pinned {
idxJ, okJ := pinnedMap[all[j].Value] pinnedMap[v] = true
if okI && okJ {
return idxI < idxJ
}
if okI {
return true
}
if okJ {
return false
}
return false // 保持非置顶地区的原有相对顺序
})
} }
c.JSON(http.StatusOK, all) // 只返回配置中的地区,并保持配置中的顺序
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)
} }

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

@@ -8,8 +8,8 @@ import (
type Image struct { type Image 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.
Title string `json:"title"` Title string `json:"title"`
Copyright string `json:"copyright"` Copyright string `json:"copyright"`
CopyrightLink string `json:"copyrightlink"` CopyrightLink string `json:"copyrightlink"`

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"
@@ -61,19 +62,33 @@ func (f *Fetcher) Fetch(ctx context.Context, n int) error {
} }
for _, mkt := range regions { for _, mkt := range regions {
if err := f.FetchRegion(ctx, mkt); err != nil {
util.Logger.Error("Failed to fetch region images", zap.String("mkt", mkt), zap.Error(err))
}
}
util.Logger.Info("Fetch task completed")
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)) util.Logger.Info("Fetching images for region", zap.String("mkt", mkt))
// 调用两次 API 获取最多两周的数据 // 调用两次 API 获取最多两周的数据
// 第一次 idx=0&n=8 (今天起往回数 8 张) // 第一次 idx=0&n=8 (今天起往回数 8 张)
if err := f.fetchByMkt(ctx, mkt, 0, 8); err != nil { 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)) 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 张,与第一次有重叠,确保不漏) // 第二次 idx=7&n=8 (7天前起往回数 8 张,与第一次有重叠,确保不漏)
if err := f.fetchByMkt(ctx, mkt, 7, 8); err != nil { 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)) util.Logger.Error("Failed to fetch images", zap.String("mkt", mkt), zap.Int("idx", 7), zap.Error(err))
// 第二次失败不一定返回错误,因为可能第一次已经拿到了
} }
}
util.Logger.Info("Fetch task completed")
return nil return nil
} }
@@ -110,27 +125,12 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage, mkt strin
// 幂等检查 // 幂等检查
var existing model.Image var existing model.Image
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(&existing).Error; err == nil {
util.Logger.Info("Image already exists, skipping", zap.String("date", dateStr), zap.String("mkt", mkt)) util.Logger.Debug("Image already exists in DB, skipping", zap.String("date", dateStr), zap.String("mkt", mkt))
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)
util.Logger.Info("Processing image", zap.String("date", dateStr), zap.String("mkt", mkt), zap.String("imageName", imageName))
// UHD 探测
imgURL, variantName := f.probeUHD(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 记录 // 创建 DB 记录
dbImg := model.Image{ dbImg := model.Image{
@@ -154,7 +154,6 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage, mkt strin
return err return err
} }
// 再次检查 dbImg.ID 是否被填充,如果没有被填充(说明由于冲突未插入),则需要查询出已有的 ID
if dbImg.ID == 0 { if dbImg.ID == 0 {
var existing model.Image var existing model.Image
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(&existing).Error; err != nil {
@@ -164,6 +163,9 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage, mkt strin
dbImg = existing dbImg = existing
} }
// UHD 探测
imgURL, variantName := f.probeUHD(bingImg.URLBase)
// 保存各种分辨率 // 保存各种分辨率
targetVariants := []struct { targetVariants := []struct {
name string name string
@@ -183,17 +185,61 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage, mkt strin
{"320x240", 320, 240}, {"320x240", 320, 240},
} }
// 首先保存原图 (UHD 或 1080p) // 检查是否所有变体都已存在于存储中
if err := f.saveVariant(ctx, &dbImg, variantName, "jpg", imgData); err != nil { allExist := true
// 检查 UHD/原图
uhdKey := f.generateKey(imageName, variantName, "jpg")
exists, _ := storage.GlobalStorage.Exists(ctx, uhdKey)
if !exists {
allExist = false
} else {
for _, v := range targetVariants {
if v.name == variantName {
continue
}
vKey := f.generateKey(imageName, v.name, "jpg")
exists, _ := storage.GlobalStorage.Exists(ctx, vKey)
if !exists {
allExist = false
break
}
}
}
if allExist {
util.Logger.Debug("All image variants exist in storage, linking only", zap.String("imageName", imageName))
// 只建立关联信息
f.saveVariant(ctx, &dbImg, imageName, variantName, "jpg", nil)
for _, v := range targetVariants {
if v.name == variantName {
continue
}
f.saveVariant(ctx, &dbImg, imageName, v.name, "jpg", nil)
}
} else {
// 需要下载并处理
util.Logger.Debug("Downloading and processing image", zap.String("url", imgURL))
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
}
// 保存原图
if err := f.saveVariant(ctx, &dbImg, 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))
} }
for _, v := range targetVariants { for _, v := range targetVariants {
// 如果目标分辨率就是原图分辨率,则跳过(已经保存过了)
if v.name == variantName { if v.name == variantName {
continue continue
} }
resized := imaging.Fill(srcImg, v.width, v.height, imaging.Center, imaging.Lanczos) resized := imaging.Fill(srcImg, v.width, v.height, imaging.Center, imaging.Lanczos)
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
if err := jpeg.Encode(buf, resized, &jpeg.Options{Quality: 100}); err != nil { if err := jpeg.Encode(buf, resized, &jpeg.Options{Quality: 100}); err != nil {
@@ -201,9 +247,7 @@ 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 {
// 保存 JPG
if err := f.saveVariant(ctx, &dbImg, 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))
} }
} }
@@ -213,10 +257,33 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage, mkt strin
if dateStr == today && config.GetConfig().Feature.WriteDailyFiles { if dateStr == today && config.GetConfig().Feature.WriteDailyFiles {
f.saveDailyFiles(srcImg, imgData, mkt) f.saveDailyFiles(srcImg, imgData, mkt)
} }
}
return nil return nil
} }
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(urlBase string) (string, string) { func (f *Fetcher) probeUHD(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) resp, err := f.httpClient.Head(uhdURL)
@@ -235,25 +302,52 @@ 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, img *model.Image, 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"
} }
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 (如果可能)
// 但目前的 Storage 接口没有 Stat我们可以尝试 Get 或者干脆 size 为 0
// 为了简单,我们只从存储中获取公共 URL
if pURL, ok := storage.GlobalStorage.PublicURL(key); ok {
publicURL = pURL
}
// size 暂时设为 0 或者从 data 中取 (如果有的话)
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) stored, err := storage.GlobalStorage.Put(ctx, key, bytes.NewReader(data), contentType)
if err != nil { if err != nil {
return err 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{ vRecord := model.ImageVariant{
ImageID: img.ID, ImageID: img.ID,
Variant: variant, Variant: variant,
Format: format, Format: format,
StorageKey: stored.Key, StorageKey: key,
PublicURL: stored.PublicURL, PublicURL: publicURL,
Size: int64(len(data)), Size: size,
} }
return repo.DB.Clauses(clause.OnConflict{ return repo.DB.Clauses(clause.OnConflict{

View File

@@ -2,18 +2,23 @@ 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"
) )
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 {
@@ -52,14 +57,26 @@ func CleanupOldImages(ctx context.Context) error {
func GetTodayImage(mkt string) (*model.Image, error) { func GetTodayImage(mkt string) (*model.Image, 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))
var img model.Image var img model.Image
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").First(&img).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)
@@ -70,16 +87,37 @@ func GetTodayImage(mkt string) (*model.Image, error) {
// 兜底逻辑:如果指定地区没找到,且开启了兜底开关,则尝试获取默认地区的图片 // 兜底逻辑:如果指定地区没找到,且开启了兜底开关,则尝试获取默认地区的图片
if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback { if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback {
defaultMkt := config.GetConfig().GetDefaultMkt() defaultMkt := config.GetConfig().GetDefaultMkt()
util.Logger.Debug("Image not found, trying fallback to default market", zap.String("mkt", mkt), zap.String("defaultMkt", defaultMkt))
if mkt != defaultMkt { if mkt != defaultMkt {
return GetTodayImage(defaultMkt) return GetTodayImage(defaultMkt)
} }
return GetTodayImage("") return GetTodayImage("")
} }
if err == nil {
util.Logger.Debug("Found image", zap.String("date", img.Date), zap.String("mkt", img.Mkt))
}
return &img, err return &img, err
} }
func GetAllRegionsTodayImages() ([]model.Image, error) {
regions := config.GetConfig().Fetcher.Regions
if len(regions) == 0 {
regions = []string{config.GetConfig().GetDefaultMkt()}
}
var images []model.Image
for _, mkt := range regions {
img, err := GetTodayImage(mkt)
if err == nil {
images = append(images, *img)
}
}
return images, nil
}
func GetRandomImage(mkt string) (*model.Image, error) { func GetRandomImage(mkt string) (*model.Image, error) {
util.Logger.Debug("Getting random image", zap.String("mkt", mkt))
var img model.Image var img model.Image
// SQLite 使用 RANDOM(), MySQL/Postgres 使用 RANDOM() 或 RAND() // SQLite 使用 RANDOM(), MySQL/Postgres 使用 RANDOM() 或 RAND()
// 简单起见,先查总数再 Offset // 简单起见,先查总数再 Offset
@@ -89,58 +127,83 @@ func GetRandomImage(mkt string) (*model.Image, error) {
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 代替 ORDER BY RANDOM()
tx = repo.DB.Order("RANDOM()") // 注意tx 包含了前面的 Where 条件
if mkt != "" { offset := rand.Intn(int(count))
tx = tx.Where("mkt = ?", mkt) 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").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 && mkt != "" && config.GetConfig().API.EnableMktFallback { if (err != nil || img.ID == 0) && mkt != "" && config.GetConfig().API.EnableMktFallback {
defaultMkt := config.GetConfig().GetDefaultMkt() 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("")
} }
if err == nil && img.ID == 0 {
return nil, fmt.Errorf("no images found")
}
if err == nil {
util.Logger.Debug("Found random image", zap.String("date", img.Date), zap.String("mkt", img.Mkt))
}
return &img, err return &img, err
} }
func GetImageByDate(date string, mkt string) (*model.Image, error) { func GetImageByDate(date string, mkt string) (*model.Image, error) {
util.Logger.Debug("Getting image by date", zap.String("date", date), zap.String("mkt", mkt))
var img model.Image var img model.Image
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").First(&img).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().GetDefaultMkt()
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 {
util.Logger.Debug("Found image by date", zap.String("date", img.Date), zap.String("mkt", img.Mkt))
}
return &img, err 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.Image, error) {
util.Logger.Debug("Getting image list", zap.Int("limit", limit), zap.Int("offset", offset), zap.String("month", month), zap.String("mkt", mkt))
var images []model.Image var images []model.Image
tx := repo.DB.Model(&model.Image{}) tx := repo.DB.Model(&model.Image{})

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">
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label for="api-fallback">启用地区不存在时兜底</Label> <Label for="api-fallback">启用地区不存在时兜底</Label>
<p class="text-xs text-gray-500">
如果请求的地区无数据自动回退到默认地区
</p>
</div>
<Switch <Switch
id="api-fallback" id="api-fallback"
v-model="config.API.EnableMktFallback" v-model="config.API.EnableMktFallback"
/> />
</div> </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"> <p class="text-xs text-gray-500">
如果请求的地区无数据自动回退到默认地区 如果请求的地区无数据尝试实时从 Bing 抓取
</p> </p>
</div>
<Switch
id="api-on-demand"
v-model="config.API.EnableOnDemandFetch"
/>
</div>
</div>
</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,81 @@
</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
@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"
>
<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
@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"
>
<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"
@wheel="handleGlobalWheel"
>
<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 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>
</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 +337,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 +359,50 @@ 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 scrollGlobal = (direction: 'left' | 'right') => {
if (!globalScrollContainer.value) return
// 增加翻页数量:滚动容器宽度的 80%,或者固定 3 张卡片的宽度
const scrollAmount = globalScrollContainer.value.clientWidth * 0.8 || 1000
globalScrollContainer.value.scrollBy({
left: direction === 'left' ? -scrollAmount : scrollAmount,
behavior: 'smooth'
})
}
// 鼠标滚轮横向滑动
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)
// 获取地区标签
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
@@ -580,8 +696,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 +738,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>