mirror of
https://git.fightbot.fun/hxuanyu/BingPaper.git
synced 2026-03-07 16:49:32 +08:00
Compare commits
10 Commits
6868a67ed7
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| fb7545f9a2 | |||
| ee814f0380 | |||
| d3ca6fa919 | |||
| 49c78506b2 | |||
| e40677f105 | |||
| e48959d5ba | |||
| 852a72c597 | |||
| 2660970320 | |||
| fb636b9450 | |||
| 8ef66b2cb1 |
@@ -1,7 +1,6 @@
|
||||
server:
|
||||
port: 8080
|
||||
base_url: ""
|
||||
|
||||
log:
|
||||
level: info
|
||||
filename: data/logs/app.log
|
||||
@@ -13,24 +12,20 @@ log:
|
||||
log_console: true
|
||||
show_db_log: false
|
||||
db_log_level: info
|
||||
|
||||
api:
|
||||
mode: local # local | redirect
|
||||
enable_mkt_fallback: true # 当请求的地区不存在时,是否回退到默认地区
|
||||
|
||||
mode: redirect
|
||||
enable_mkt_fallback: false
|
||||
enable_on_demand_fetch: false
|
||||
cron:
|
||||
enabled: true
|
||||
daily_spec: "20 8-23/4 * * *"
|
||||
|
||||
daily_spec: 20 8-23/4 * * *
|
||||
retention:
|
||||
days: 0
|
||||
|
||||
db:
|
||||
type: sqlite # sqlite | mysql | postgres
|
||||
type: sqlite
|
||||
dsn: data/bing_paper.db
|
||||
|
||||
storage:
|
||||
type: local # local | s3 | webdav
|
||||
type: local
|
||||
local:
|
||||
root: data/picture
|
||||
s3:
|
||||
@@ -46,15 +41,28 @@ storage:
|
||||
username: ""
|
||||
password: ""
|
||||
public_url_prefix: ""
|
||||
|
||||
admin:
|
||||
password_bcrypt: "$2a$10$fYHPeWHmwObephJvtlyH1O8DIgaLk5TINbi9BOezo2M8cSjmJchka" # 默认密码: admin123
|
||||
|
||||
password_bcrypt: $2a$10$fYHPeWHmwObephJvtlyH1O8DIgaLk5TINbi9BOezo2M8cSjmJchka
|
||||
token:
|
||||
default_ttl: 168h
|
||||
|
||||
feature:
|
||||
write_daily_files: true
|
||||
|
||||
web:
|
||||
path: web
|
||||
fetcher:
|
||||
regions:
|
||||
- zh-CN
|
||||
- en-US
|
||||
- ja-JP
|
||||
- en-AU
|
||||
- en-GB
|
||||
- de-DE
|
||||
- en-NZ
|
||||
- en-CA
|
||||
- fr-FR
|
||||
- it-IT
|
||||
- es-ES
|
||||
- pt-BR
|
||||
- ko-KR
|
||||
- en-IN
|
||||
- ru-RU
|
||||
|
||||
@@ -14,23 +14,4 @@ services:
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- BINGPAPER_SERVER_PORT=${BINGPAPER_SERVER_PORT:-8080}
|
||||
- BINGPAPER_LOG_LEVEL=${BINGPAPER_LOG_LEVEL:-info}
|
||||
- BINGPAPER_API_MODE=${BINGPAPER_API_MODE:-local}
|
||||
- BINGPAPER_CRON_ENABLED=${BINGPAPER_CRON_ENABLED:-true}
|
||||
- BINGPAPER_DB_TYPE=${BINGPAPER_DB_TYPE:-sqlite}
|
||||
- BINGPAPER_DB_DSN=${BINGPAPER_DB_DSN:-data/bing_paper.db}
|
||||
- BINGPAPER_STORAGE_TYPE=${BINGPAPER_STORAGE_TYPE:-local}
|
||||
- BINGPAPER_STORAGE_LOCAL_ROOT=${BINGPAPER_STORAGE_LOCAL_ROOT:-data/picture}
|
||||
- BINGPAPER_RETENTION_DAYS=${BINGPAPER_RETENTION_DAYS:-30}
|
||||
# S3 配置 (可选)
|
||||
# - BINGPAPER_STORAGE_S3_ENDPOINT=${BINGPAPER_STORAGE_S3_ENDPOINT:-}
|
||||
# - BINGPAPER_STORAGE_S3_REGION=${BINGPAPER_STORAGE_S3_REGION:-}
|
||||
# - BINGPAPER_STORAGE_S3_BUCKET=${BINGPAPER_STORAGE_S3_BUCKET:-}
|
||||
# - BINGPAPER_STORAGE_S3_ACCESS_KEY=${BINGPAPER_STORAGE_S3_ACCESS_KEY:-}
|
||||
# - BINGPAPER_STORAGE_S3_SECRET_KEY=${BINGPAPER_STORAGE_S3_SECRET_KEY:-}
|
||||
# - BINGPAPER_STORAGE_S3_PUBLIC_URL_PREFIX=${BINGPAPER_STORAGE_S3_PUBLIC_URL_PREFIX:-}
|
||||
# WebDAV 配置 (可选)
|
||||
# - BINGPAPER_STORAGE_WEBDAV_URL=${BINGPAPER_STORAGE_WEBDAV_URL:-}
|
||||
# - BINGPAPER_STORAGE_WEBDAV_USERNAME=${BINGPAPER_STORAGE_WEBDAV_USERNAME:-}
|
||||
# - BINGPAPER_STORAGE_WEBDAV_PASSWORD=${BINGPAPER_STORAGE_WEBDAV_PASSWORD:-}
|
||||
# - BINGPAPER_STORAGE_WEBDAV_PUBLIC_URL_PREFIX=${BINGPAPER_STORAGE_WEBDAV_PUBLIC_URL_PREFIX:-}
|
||||
- BINGPAPER_LOG_LEVEL=${BINGPAPER_LOG_LEVEL:-info}
|
||||
135
docs/docs.go
135
docs/docs.go
@@ -440,6 +440,24 @@ const docTemplate = `{
|
||||
"schema": {
|
||||
"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": {
|
||||
"$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": {
|
||||
"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": {
|
||||
"$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": {
|
||||
"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": {
|
||||
"$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": {
|
||||
"get": {
|
||||
"description": "返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。",
|
||||
@@ -707,6 +838,10 @@ const docTemplate = `{
|
||||
"description": "当请求的地区不存在时,是否回退到默认地区",
|
||||
"type": "boolean"
|
||||
},
|
||||
"enableOnDemandFetch": {
|
||||
"description": "是否启用按需抓取",
|
||||
"type": "boolean"
|
||||
},
|
||||
"mode": {
|
||||
"description": "local | redirect",
|
||||
"type": "string"
|
||||
|
||||
@@ -434,6 +434,24 @@
|
||||
"schema": {
|
||||
"type": "file"
|
||||
}
|
||||
},
|
||||
"202": {
|
||||
"description": "按需抓取任务已启动",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "图片未找到,响应体包含具体原因",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -469,6 +487,24 @@
|
||||
"schema": {
|
||||
"$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": {
|
||||
"type": "file"
|
||||
}
|
||||
},
|
||||
"202": {
|
||||
"description": "按需抓取任务已启动",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "图片未找到,响应体包含具体原因",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -539,6 +593,24 @@
|
||||
"schema": {
|
||||
"$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": {
|
||||
"type": "file"
|
||||
}
|
||||
},
|
||||
"202": {
|
||||
"description": "按需抓取任务已启动",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "图片未找到,响应体包含具体原因",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -609,6 +699,24 @@
|
||||
"schema": {
|
||||
"$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": {
|
||||
"get": {
|
||||
"description": "返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。",
|
||||
@@ -701,6 +832,10 @@
|
||||
"description": "当请求的地区不存在时,是否回退到默认地区",
|
||||
"type": "boolean"
|
||||
},
|
||||
"enableOnDemandFetch": {
|
||||
"description": "是否启用按需抓取",
|
||||
"type": "boolean"
|
||||
},
|
||||
"mode": {
|
||||
"description": "local | redirect",
|
||||
"type": "string"
|
||||
|
||||
@@ -5,6 +5,9 @@ definitions:
|
||||
enableMktFallback:
|
||||
description: 当请求的地区不存在时,是否回退到默认地区
|
||||
type: boolean
|
||||
enableOnDemandFetch:
|
||||
description: 是否启用按需抓取
|
||||
type: boolean
|
||||
mode:
|
||||
description: local | redirect
|
||||
type: string
|
||||
@@ -543,6 +546,18 @@ paths:
|
||||
description: OK
|
||||
schema:
|
||||
type: file
|
||||
"202":
|
||||
description: 按需抓取任务已启动
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"404":
|
||||
description: 图片未找到,响应体包含具体原因
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: 获取指定日期图片
|
||||
tags:
|
||||
- image
|
||||
@@ -566,6 +581,18 @@ paths:
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ImageMetaResp'
|
||||
"202":
|
||||
description: 按需抓取任务已启动
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"404":
|
||||
description: 图片未找到,响应体包含具体原因
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: 获取指定日期图片元数据
|
||||
tags:
|
||||
- image
|
||||
@@ -594,6 +621,18 @@ paths:
|
||||
description: OK
|
||||
schema:
|
||||
type: file
|
||||
"202":
|
||||
description: 按需抓取任务已启动
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"404":
|
||||
description: 图片未找到,响应体包含具体原因
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: 获取随机图片
|
||||
tags:
|
||||
- image
|
||||
@@ -612,6 +651,18 @@ paths:
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ImageMetaResp'
|
||||
"202":
|
||||
description: 按需抓取任务已启动
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"404":
|
||||
description: 图片未找到,响应体包含具体原因
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: 获取随机图片元数据
|
||||
tags:
|
||||
- image
|
||||
@@ -641,6 +692,18 @@ paths:
|
||||
description: OK
|
||||
schema:
|
||||
type: file
|
||||
"202":
|
||||
description: 按需抓取任务已启动
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"404":
|
||||
description: 图片未找到,响应体包含具体原因
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: 获取今日图片
|
||||
tags:
|
||||
- image
|
||||
@@ -659,6 +722,18 @@ paths:
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ImageMetaResp'
|
||||
"202":
|
||||
description: 按需抓取任务已启动
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"404":
|
||||
description: 图片未找到,响应体包含具体原因
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: 获取今日图片元数据
|
||||
tags:
|
||||
- image
|
||||
@@ -700,6 +775,21 @@ paths:
|
||||
summary: 获取图片列表
|
||||
tags:
|
||||
- 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:
|
||||
get:
|
||||
description: 返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。
|
||||
|
||||
@@ -60,8 +60,9 @@ func (c LogConfig) GetShowDBLog() bool { return c.ShowDBLog }
|
||||
func (c LogConfig) GetDBLogLevel() string { return c.DBLogLevel }
|
||||
|
||||
type APIConfig struct {
|
||||
Mode string `mapstructure:"mode" yaml:"mode"` // local | redirect
|
||||
EnableMktFallback bool `mapstructure:"enable_mkt_fallback" yaml:"enable_mkt_fallback"` // 当请求的地区不存在时,是否回退到默认地区
|
||||
Mode string `mapstructure:"mode" yaml:"mode"` // local | redirect
|
||||
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 {
|
||||
@@ -165,7 +166,8 @@ func Init(configPath string) error {
|
||||
v.SetDefault("log.show_db_log", false)
|
||||
v.SetDefault("log.db_log_level", "info")
|
||||
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.daily_spec", "20 8-23/4 * * *")
|
||||
v.SetDefault("retention.days", 0)
|
||||
@@ -328,8 +330,8 @@ func GetTokenTTL() time.Duration {
|
||||
return ttl
|
||||
}
|
||||
|
||||
// GetDefaultMkt 返回生效的默认地区编码
|
||||
func (c *Config) GetDefaultMkt() string {
|
||||
// GetDefaultRegion 返回生效的默认地区编码
|
||||
func (c *Config) GetDefaultRegion() string {
|
||||
if len(c.Fetcher.Regions) > 0 {
|
||||
return c.Fetcher.Regions[0]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"BingPaper/internal/config"
|
||||
"BingPaper/internal/model"
|
||||
@@ -48,15 +48,21 @@ type ImageMetaResp struct {
|
||||
// @Param format query string false "格式 (jpg)" default(jpg)
|
||||
// @Produce image/jpeg
|
||||
// @Success 200 {file} binary
|
||||
// @Success 202 {object} map[string]string "按需抓取任务已启动"
|
||||
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
|
||||
// @Router /image/today [get]
|
||||
func GetToday(c *gin.Context) {
|
||||
mkt := c.Query("mkt")
|
||||
img, err := image.GetTodayImage(mkt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
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
|
||||
}
|
||||
handleImageResponse(c, img, 7200) // 2小时
|
||||
if err != nil {
|
||||
sendImageNotFound(c, mkt)
|
||||
return
|
||||
}
|
||||
handleImageResponse(c, imgRegion, 7200) // 2小时
|
||||
}
|
||||
|
||||
// GetTodayMeta 获取今日图片元数据
|
||||
@@ -66,16 +72,22 @@ func GetToday(c *gin.Context) {
|
||||
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
|
||||
// @Produce json
|
||||
// @Success 200 {object} ImageMetaResp
|
||||
// @Success 202 {object} map[string]string "按需抓取任务已启动"
|
||||
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
|
||||
// @Router /image/today/meta [get]
|
||||
func GetTodayMeta(c *gin.Context) {
|
||||
mkt := c.Query("mkt")
|
||||
img, err := image.GetTodayImage(mkt)
|
||||
imgRegion, err := image.GetTodayImage(mkt)
|
||||
if err == image.ErrFetchStarted {
|
||||
c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
sendImageNotFound(c, mkt)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "public, max-age=7200") // 2小时
|
||||
c.JSON(http.StatusOK, formatMeta(img))
|
||||
c.JSON(http.StatusOK, formatMeta(imgRegion))
|
||||
}
|
||||
|
||||
// GetRandom 获取随机图片
|
||||
@@ -87,15 +99,22 @@ func GetTodayMeta(c *gin.Context) {
|
||||
// @Param format query string false "格式" default(jpg)
|
||||
// @Produce image/jpeg
|
||||
// @Success 200 {file} binary
|
||||
// @Success 202 {object} map[string]string "按需抓取任务已启动"
|
||||
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
|
||||
// @Router /image/random [get]
|
||||
// GetRandom 获取随机图片
|
||||
func GetRandom(c *gin.Context) {
|
||||
mkt := c.Query("mkt")
|
||||
img, err := image.GetRandomImage(mkt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
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
|
||||
}
|
||||
handleImageResponse(c, img, 0) // 禁用缓存
|
||||
if err != nil {
|
||||
sendImageNotFound(c, mkt)
|
||||
return
|
||||
}
|
||||
handleImageResponse(c, imgRegion, 0) // 禁用缓存
|
||||
}
|
||||
|
||||
// GetRandomMeta 获取随机图片元数据
|
||||
@@ -105,16 +124,22 @@ func GetRandom(c *gin.Context) {
|
||||
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
|
||||
// @Produce json
|
||||
// @Success 200 {object} ImageMetaResp
|
||||
// @Success 202 {object} map[string]string "按需抓取任务已启动"
|
||||
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
|
||||
// @Router /image/random/meta [get]
|
||||
func GetRandomMeta(c *gin.Context) {
|
||||
mkt := c.Query("mkt")
|
||||
img, err := image.GetRandomImage(mkt)
|
||||
imgRegion, err := image.GetRandomImage(mkt)
|
||||
if err == image.ErrFetchStarted {
|
||||
c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
sendImageNotFound(c, mkt)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
c.JSON(http.StatusOK, formatMeta(img))
|
||||
c.JSON(http.StatusOK, formatMeta(imgRegion))
|
||||
}
|
||||
|
||||
// GetByDate 获取指定日期图片
|
||||
@@ -127,16 +152,22 @@ func GetRandomMeta(c *gin.Context) {
|
||||
// @Param format query string false "格式" default(jpg)
|
||||
// @Produce image/jpeg
|
||||
// @Success 200 {file} binary
|
||||
// @Success 202 {object} map[string]string "按需抓取任务已启动"
|
||||
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
|
||||
// @Router /image/date/{date} [get]
|
||||
func GetByDate(c *gin.Context) {
|
||||
date := c.Param("date")
|
||||
mkt := c.Query("mkt")
|
||||
img, err := image.GetImageByDate(date, mkt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
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
|
||||
}
|
||||
handleImageResponse(c, img, 604800) // 7天
|
||||
if err != nil {
|
||||
sendImageNotFound(c, mkt)
|
||||
return
|
||||
}
|
||||
handleImageResponse(c, imgRegion, 604800) // 7天
|
||||
}
|
||||
|
||||
// GetByDateMeta 获取指定日期图片元数据
|
||||
@@ -147,17 +178,23 @@ func GetByDate(c *gin.Context) {
|
||||
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
|
||||
// @Produce json
|
||||
// @Success 200 {object} ImageMetaResp
|
||||
// @Success 202 {object} map[string]string "按需抓取任务已启动"
|
||||
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
|
||||
// @Router /image/date/{date}/meta [get]
|
||||
func GetByDateMeta(c *gin.Context) {
|
||||
date := c.Param("date")
|
||||
mkt := c.Query("mkt")
|
||||
img, err := image.GetImageByDate(date, mkt)
|
||||
imgRegion, err := image.GetImageByDate(date, mkt)
|
||||
if err == image.ErrFetchStarted {
|
||||
c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
sendImageNotFound(c, mkt)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "public, max-age=604800") // 7天
|
||||
c.JSON(http.StatusOK, formatMeta(img))
|
||||
c.JSON(http.StatusOK, formatMeta(imgRegion))
|
||||
}
|
||||
|
||||
// ListImages 获取图片列表
|
||||
@@ -223,21 +260,70 @@ func ListImages(c *gin.Context) {
|
||||
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")
|
||||
format := c.DefaultQuery("format", "jpg")
|
||||
|
||||
var selected *model.ImageVariant
|
||||
for _, v := range img.Variants {
|
||||
for _, v := range m.Variants {
|
||||
if v.Variant == variant && v.Format == format {
|
||||
selected = &v
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if selected == nil && len(img.Variants) > 0 {
|
||||
if selected == nil && len(m.Variants) > 0 {
|
||||
// 回退逻辑
|
||||
selected = &img.Variants[0]
|
||||
selected = &m.Variants[0]
|
||||
}
|
||||
|
||||
if selected == nil {
|
||||
@@ -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.Redirect(http.StatusFound, selected.PublicURL)
|
||||
} else if img.URLBase != "" {
|
||||
} else if m.URLBase != "" {
|
||||
// 兜底重定向到原始 Bing
|
||||
bingURL := fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, selected.Variant)
|
||||
bingURL := fmt.Sprintf("https://www.bing.com%s_%s.jpg", m.URLBase, selected.Variant)
|
||||
if maxAge > 0 {
|
||||
c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
|
||||
} else {
|
||||
@@ -264,10 +350,10 @@ func handleImageResponse(c *gin.Context, img *model.Image, maxAge int) {
|
||||
}
|
||||
c.Redirect(http.StatusFound, bingURL)
|
||||
} else {
|
||||
serveLocal(c, selected.StorageKey, img.Date, maxAge)
|
||||
serveLocal(c, selected.StorageKey, m.Date, maxAge)
|
||||
}
|
||||
} else {
|
||||
serveLocal(c, selected.StorageKey, img.Date, maxAge)
|
||||
serveLocal(c, selected.StorageKey, m.Date, maxAge)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,25 +386,36 @@ func serveLocal(c *gin.Context, key string, etag string, maxAge int) {
|
||||
io.Copy(c.Writer, reader)
|
||||
}
|
||||
|
||||
func formatMetaSummary(img *model.Image) gin.H {
|
||||
func formatMetaSummary(m *model.ImageRegion) gin.H {
|
||||
cfg := config.GetConfig()
|
||||
|
||||
// 找到最小的变体(Size 最小)
|
||||
// 找到最小的变体
|
||||
var smallest *model.ImageVariant
|
||||
for i := range img.Variants {
|
||||
v := &img.Variants[i]
|
||||
if smallest == nil || v.Size < smallest.Size {
|
||||
for i := range m.Variants {
|
||||
v := &m.Variants[i]
|
||||
if smallest == nil {
|
||||
smallest = v
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果当前变体 Size 更小且不为 0,或者 smallest 的 Size 为 0
|
||||
if v.Size > 0 && (smallest.Size == 0 || v.Size < smallest.Size) {
|
||||
smallest = v
|
||||
} else if v.Size == smallest.Size {
|
||||
// 如果 Size 相同(包括都为 0),根据分辨率名称判断
|
||||
if compareResolution(v.Variant, smallest.Variant) < 0 {
|
||||
smallest = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variants := []gin.H{}
|
||||
if smallest != nil {
|
||||
url := smallest.PublicURL
|
||||
if url == "" && cfg.API.Mode == "redirect" && img.URLBase != "" {
|
||||
url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, smallest.Variant)
|
||||
if url == "" && cfg.API.Mode == "redirect" && m.URLBase != "" {
|
||||
url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", m.URLBase, smallest.Variant)
|
||||
} else if cfg.API.Mode == "local" || url == "" {
|
||||
url = fmt.Sprintf("%s/api/v1/image/date/%s?variant=%s&format=%s&mkt=%s", cfg.Server.BaseURL, img.Date, smallest.Variant, smallest.Format, img.Mkt)
|
||||
url = fmt.Sprintf("%s/api/v1/image/date/%s?variant=%s&format=%s&mkt=%s", cfg.Server.BaseURL, m.Date, smallest.Variant, smallest.Format, m.Mkt)
|
||||
}
|
||||
variants = append(variants, gin.H{
|
||||
"variant": smallest.Variant,
|
||||
@@ -330,28 +427,28 @@ func formatMetaSummary(img *model.Image) gin.H {
|
||||
}
|
||||
|
||||
return gin.H{
|
||||
"date": img.Date,
|
||||
"mkt": img.Mkt,
|
||||
"title": img.Title,
|
||||
"copyright": img.Copyright,
|
||||
"copyrightlink": img.CopyrightLink,
|
||||
"quiz": img.Quiz,
|
||||
"startdate": img.StartDate,
|
||||
"fullstartdate": img.FullStartDate,
|
||||
"hsh": img.HSH,
|
||||
"date": m.Date,
|
||||
"mkt": m.Mkt,
|
||||
"title": m.Title,
|
||||
"copyright": m.Copyright,
|
||||
"copyrightlink": m.CopyrightLink,
|
||||
"quiz": m.Quiz,
|
||||
"startdate": m.StartDate,
|
||||
"fullstartdate": m.FullStartDate,
|
||||
"hsh": m.HSH,
|
||||
"variants": variants,
|
||||
}
|
||||
}
|
||||
|
||||
func formatMeta(img *model.Image) gin.H {
|
||||
func formatMeta(m *model.ImageRegion) gin.H {
|
||||
cfg := config.GetConfig()
|
||||
variants := []gin.H{}
|
||||
for _, v := range img.Variants {
|
||||
for _, v := range m.Variants {
|
||||
url := v.PublicURL
|
||||
if url == "" && cfg.API.Mode == "redirect" && img.URLBase != "" {
|
||||
url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, v.Variant)
|
||||
if url == "" && cfg.API.Mode == "redirect" && m.URLBase != "" {
|
||||
url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", m.URLBase, v.Variant)
|
||||
} else if cfg.API.Mode == "local" || url == "" {
|
||||
url = fmt.Sprintf("%s/api/v1/image/date/%s?variant=%s&format=%s&mkt=%s", cfg.Server.BaseURL, img.Date, v.Variant, v.Format, img.Mkt)
|
||||
url = fmt.Sprintf("%s/api/v1/image/date/%s?variant=%s&format=%s&mkt=%s", cfg.Server.BaseURL, m.Date, v.Variant, v.Format, m.Mkt)
|
||||
}
|
||||
variants = append(variants, gin.H{
|
||||
"variant": v.Variant,
|
||||
@@ -363,15 +460,15 @@ func formatMeta(img *model.Image) gin.H {
|
||||
}
|
||||
|
||||
return gin.H{
|
||||
"date": img.Date,
|
||||
"mkt": img.Mkt,
|
||||
"title": img.Title,
|
||||
"copyright": img.Copyright,
|
||||
"copyrightlink": img.CopyrightLink,
|
||||
"quiz": img.Quiz,
|
||||
"startdate": img.StartDate,
|
||||
"fullstartdate": img.FullStartDate,
|
||||
"hsh": img.HSH,
|
||||
"date": m.Date,
|
||||
"mkt": m.Mkt,
|
||||
"title": m.Title,
|
||||
"copyright": m.Copyright,
|
||||
"copyrightlink": m.CopyrightLink,
|
||||
"quiz": m.Quiz,
|
||||
"startdate": m.StartDate,
|
||||
"fullstartdate": m.FullStartDate,
|
||||
"hsh": m.HSH,
|
||||
"variants": variants,
|
||||
}
|
||||
}
|
||||
@@ -387,34 +484,85 @@ func GetRegions(c *gin.Context) {
|
||||
cfg := config.GetConfig()
|
||||
pinned := cfg.Fetcher.Regions
|
||||
|
||||
// 创建副本以避免修改原始全局变量
|
||||
all := make([]util.Region, len(util.AllRegions))
|
||||
copy(all, util.AllRegions)
|
||||
|
||||
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 // 保持非置顶地区的原有相对顺序
|
||||
})
|
||||
if len(pinned) == 0 {
|
||||
// 如果没有配置抓取地区,返回所有支持的地区
|
||||
c.JSON(http.StatusOK, util.AllRegions)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -21,11 +21,15 @@ func TestHandleImageResponseRedirect(t *testing.T) {
|
||||
config.GetConfig().API.Mode = "redirect"
|
||||
|
||||
// Mock Image and Variant
|
||||
img := &model.Image{
|
||||
Date: "2026-01-26",
|
||||
URLBase: "/th?id=OHR.TestImage",
|
||||
imgRegion := &model.ImageRegion{
|
||||
Date: "2026-01-26",
|
||||
Mkt: "zh-CN",
|
||||
HSH: "testhsh",
|
||||
ImageName: "TestImage",
|
||||
URLBase: "/th?id=OHR.TestImage",
|
||||
Variants: []model.ImageVariant{
|
||||
{
|
||||
ImageName: "TestImage",
|
||||
Variant: "UHD",
|
||||
Format: "jpg",
|
||||
PublicURL: "", // Empty for local storage simulation
|
||||
@@ -39,7 +43,7 @@ func TestHandleImageResponseRedirect(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/api/v1/image/today?variant=UHD", nil)
|
||||
|
||||
handleImageResponse(c, img, 0)
|
||||
handleImageResponse(c, imgRegion, 0)
|
||||
|
||||
assert.Equal(t, http.StatusFound, w.Code)
|
||||
assert.Contains(t, w.Header().Get("Location"), "bing.com")
|
||||
@@ -48,7 +52,7 @@ func TestHandleImageResponseRedirect(t *testing.T) {
|
||||
|
||||
t.Run("FormatMeta in redirect mode should return Bing URL if PublicURL is empty", func(t *testing.T) {
|
||||
config.GetConfig().API.Mode = "redirect"
|
||||
meta := formatMeta(img)
|
||||
meta := formatMeta(imgRegion)
|
||||
|
||||
variants := meta["variants"].([]gin.H)
|
||||
assert.Equal(t, 1, len(variants))
|
||||
@@ -59,7 +63,7 @@ func TestHandleImageResponseRedirect(t *testing.T) {
|
||||
t.Run("FormatMeta in local mode should return API URL", func(t *testing.T) {
|
||||
config.GetConfig().API.Mode = "local"
|
||||
config.GetConfig().Server.BaseURL = "http://myserver.com"
|
||||
meta := formatMeta(img)
|
||||
meta := formatMeta(imgRegion)
|
||||
|
||||
variants := meta["variants"].([]gin.H)
|
||||
assert.Equal(t, 1, len(variants))
|
||||
@@ -68,12 +72,13 @@ func TestHandleImageResponseRedirect(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("FormatMetaSummary should only return the smallest variant", func(t *testing.T) {
|
||||
imgWithMultipleVariants := &model.Image{
|
||||
Date: "2026-01-26",
|
||||
imgWithMultipleVariants := &model.ImageRegion{
|
||||
Date: "2026-01-26",
|
||||
ImageName: "TestImage2",
|
||||
Variants: []model.ImageVariant{
|
||||
{Variant: "UHD", Size: 1000, Format: "jpg"},
|
||||
{Variant: "640x480", Size: 200, Format: "jpg"},
|
||||
{Variant: "1920x1080", Size: 500, Format: "jpg"},
|
||||
{ImageName: "TestImage2", Variant: "UHD", Size: 1000, Format: "jpg"},
|
||||
{ImageName: "TestImage2", Variant: "640x480", Size: 200, Format: "jpg"},
|
||||
{ImageName: "TestImage2", Variant: "1920x1080", Size: 500, Format: "jpg"},
|
||||
},
|
||||
}
|
||||
meta := formatMetaSummary(imgWithMultipleVariants)
|
||||
@@ -81,6 +86,21 @@ func TestHandleImageResponseRedirect(t *testing.T) {
|
||||
assert.Equal(t, 1, len(variants))
|
||||
assert.Equal(t, "640x480", variants[0]["variant"])
|
||||
})
|
||||
|
||||
t.Run("FormatMetaSummary should handle zero size by following order if names suggest it", func(t *testing.T) {
|
||||
imgWithZeroSize := &model.ImageRegion{
|
||||
Date: "2026-01-26",
|
||||
ImageName: "TestImage3",
|
||||
Variants: []model.ImageVariant{
|
||||
{ImageName: "TestImage3", Variant: "UHD", Size: 0, Format: "jpg"},
|
||||
{ImageName: "TestImage3", Variant: "320x240", Size: 0, Format: "jpg"},
|
||||
},
|
||||
}
|
||||
meta := formatMetaSummary(imgWithZeroSize)
|
||||
variants := meta["variants"].([]gin.H)
|
||||
assert.Equal(t, 1, len(variants))
|
||||
assert.Equal(t, "320x240", variants[0]["variant"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetRegions(t *testing.T) {
|
||||
|
||||
@@ -47,6 +47,7 @@ func SetupRouter(webFS embed.FS) *gin.Engine {
|
||||
img.GET("/date/:date/meta", handlers.GetByDateMeta)
|
||||
}
|
||||
api.GET("/images", handlers.ListImages)
|
||||
api.GET("/images/global/today", handlers.ListGlobalTodayImages)
|
||||
api.GET("/regions", handlers.GetRegions)
|
||||
|
||||
// 管理接口
|
||||
|
||||
@@ -6,29 +6,30 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Image struct {
|
||||
type ImageRegion struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Date string `gorm:"uniqueIndex:idx_date_mkt;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.
|
||||
Date string `gorm:"uniqueIndex:idx_date_mkt;index:idx_mkt_date,priority:2;type:varchar(10)" json:"date"` // YYYY-MM-DD
|
||||
Mkt string `gorm:"uniqueIndex:idx_date_mkt;index:idx_mkt_date,priority:1;type:varchar(10)" json:"mkt"` // zh-CN, en-US etc.
|
||||
HSH string `gorm:"type:varchar(64)" json:"hsh"`
|
||||
URLBase string `json:"urlbase"`
|
||||
ImageName string `gorm:"index" json:"image_name"`
|
||||
Title string `json:"title"`
|
||||
Copyright string `json:"copyright"`
|
||||
CopyrightLink string `json:"copyrightlink"`
|
||||
URLBase string `json:"urlbase"`
|
||||
Quiz string `json:"quiz"`
|
||||
StartDate string `json:"startdate"`
|
||||
FullStartDate string `json:"fullstartdate"`
|
||||
HSH string `json:"hsh"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
Variants []ImageVariant `gorm:"foreignKey:ImageID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;" json:"variants"`
|
||||
Variants []ImageVariant `gorm:"foreignKey:ImageName;references:ImageName" json:"variants"`
|
||||
}
|
||||
|
||||
type ImageVariant struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ImageID uint `gorm:"index;uniqueIndex:idx_image_variant_format" json:"image_id"`
|
||||
Variant string `gorm:"uniqueIndex:idx_image_variant_format;type:varchar(20)" json:"variant"` // UHD, 1920x1080, etc.
|
||||
Format string `gorm:"uniqueIndex:idx_image_variant_format;type:varchar(10)" json:"format"` // jpg, webp
|
||||
ImageName string `gorm:"uniqueIndex:idx_name_variant_format;type:varchar(100)" json:"image_name"`
|
||||
Variant string `gorm:"uniqueIndex:idx_name_variant_format;type:varchar(20)" json:"variant"` // UHD, 1920x1080, etc.
|
||||
Format string `gorm:"uniqueIndex:idx_name_variant_format;type:varchar(10)" json:"format"` // jpg, webp
|
||||
StorageKey string `json:"storage_key"`
|
||||
PublicURL string `json:"public_url"`
|
||||
Size int64 `json:"size"`
|
||||
|
||||
@@ -131,7 +131,7 @@ func InitDB() error {
|
||||
// 但此处假设 DSN 中指定的数据库已经存在。AutoMigrate 会负责创建表。
|
||||
|
||||
// 迁移
|
||||
if err := db.AutoMigrate(&model.Image{}, &model.ImageVariant{}, &model.Token{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.ImageRegion{}, &model.ImageVariant{}, &model.Token{}); err != nil {
|
||||
util.Logger.Error("Database migration failed", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -29,19 +29,17 @@ func MigrateDataToNewDB(oldDB *gorm.DB, newConfig *config.Config) error {
|
||||
}
|
||||
|
||||
// 2. 自动迁移结构
|
||||
if err := newDB.AutoMigrate(&model.Image{}, &model.ImageVariant{}, &model.Token{}); err != nil {
|
||||
if err := newDB.AutoMigrate(&model.ImageRegion{}, &model.ImageVariant{}, &model.Token{}); err != nil {
|
||||
return fmt.Errorf("failed to migrate schema in new DB: %w", err)
|
||||
}
|
||||
|
||||
// 3. 清空新数据库中的现有数据(防止冲突)
|
||||
util.Logger.Info("Cleaning up destination database before migration")
|
||||
// 备份或清空目标数据库。由于用户要求“可能需要清空或备份”,
|
||||
// 这里我们选择在迁移前清空目标表,以确保迁移过来的数据是完整且不冲突的。
|
||||
if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.ImageVariant{}).Error; err != nil {
|
||||
return fmt.Errorf("failed to clear ImageVariants: %w", err)
|
||||
}
|
||||
if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.Image{}).Error; err != nil {
|
||||
return fmt.Errorf("failed to clear Images: %w", err)
|
||||
if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.ImageRegion{}).Error; err != nil {
|
||||
return fmt.Errorf("failed to clear ImageRegions: %w", err)
|
||||
}
|
||||
if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.Token{}).Error; err != nil {
|
||||
return fmt.Errorf("failed to clear Tokens: %w", err)
|
||||
@@ -50,15 +48,15 @@ func MigrateDataToNewDB(oldDB *gorm.DB, newConfig *config.Config) error {
|
||||
// 4. 开始迁移数据
|
||||
// 使用事务确保迁移的原子性
|
||||
return newDB.Transaction(func(tx *gorm.DB) error {
|
||||
// 迁移 Images
|
||||
var images []model.Image
|
||||
if err := oldDB.Find(&images).Error; err != nil {
|
||||
return fmt.Errorf("failed to fetch images from old DB: %w", err)
|
||||
// 迁移 ImageRegions
|
||||
var regions []model.ImageRegion
|
||||
if err := oldDB.Find(®ions).Error; err != nil {
|
||||
return fmt.Errorf("failed to fetch image regions from old DB: %w", err)
|
||||
}
|
||||
if len(images) > 0 {
|
||||
util.Logger.Info("Migrating images", zap.Int("count", len(images)))
|
||||
if err := tx.Create(&images).Error; err != nil {
|
||||
return fmt.Errorf("failed to insert images into new DB: %w", err)
|
||||
if len(regions) > 0 {
|
||||
util.Logger.Info("Migrating image regions", zap.Int("count", len(regions)))
|
||||
if err := tx.Create(®ions).Error; err != nil {
|
||||
return fmt.Errorf("failed to insert image regions into new DB: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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))
|
||||
regions := config.GetConfig().Fetcher.Regions
|
||||
if len(regions) == 0 {
|
||||
regions = []string{config.GetConfig().GetDefaultMkt()}
|
||||
regions = []string{config.GetConfig().GetDefaultRegion()}
|
||||
}
|
||||
|
||||
for _, mkt := range regions {
|
||||
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))
|
||||
}
|
||||
// 第二次 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))
|
||||
if err := f.FetchRegion(ctx, mkt); err != nil {
|
||||
util.Logger.Error("Failed to fetch region images", zap.String("mkt", mkt), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,16 +71,51 @@ func (f *Fetcher) Fetch(ctx context.Context, n int) error {
|
||||
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 {
|
||||
url := fmt.Sprintf("%s?format=js&idx=%d&n=%d&uhd=1&mkt=%s", config.BingAPIBase, idx, n, mkt)
|
||||
util.Logger.Debug("Requesting Bing API", zap.String("url", url))
|
||||
resp, err := f.httpClient.Get(url)
|
||||
lang := strings.Split(mkt, "-")[0]
|
||||
url := fmt.Sprintf("%s?format=js&idx=%d&n=%d&uhd=1&mkt=%s&setlang=%s", config.BingAPIBase, idx, n, mkt, lang)
|
||||
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 {
|
||||
util.Logger.Error("Failed to request Bing API", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
util.Logger.Info("Received response from Bing API", zap.String("mkt", mkt), zap.Int("status", resp.StatusCode))
|
||||
|
||||
var bingResp BingResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&bingResp); err != nil {
|
||||
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)))
|
||||
|
||||
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 {
|
||||
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 {
|
||||
dateStr := fmt.Sprintf("%s-%s-%s", bingImg.Enddate[0:4], bingImg.Enddate[4:6], bingImg.Enddate[6:8])
|
||||
|
||||
// 幂等检查
|
||||
var existing model.Image
|
||||
if err := repo.DB.Where("date = ? AND mkt = ?", dateStr, mkt).First(&existing).Error; err == nil {
|
||||
util.Logger.Info("Image already exists, skipping", zap.String("date", dateStr), zap.String("mkt", mkt))
|
||||
// 1. 地区关联幂等检查
|
||||
var existingRegion model.ImageRegion
|
||||
if err := repo.DB.Where("date = ? AND mkt = ?", dateStr, mkt).First(&existingRegion).Error; err == nil {
|
||||
util.Logger.Info("ImageRegion record already exists, skipping", zap.String("date", dateStr), zap.String("mkt", mkt), zap.String("title", bingImg.Title))
|
||||
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 探测
|
||||
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 记录
|
||||
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
|
||||
}
|
||||
|
||||
// 保存各种分辨率
|
||||
// 2. 处理变体
|
||||
imgURL, variantName := f.probeUHD(ctx, bingImg.URLBase)
|
||||
targetVariants := []struct {
|
||||
name string
|
||||
width int
|
||||
@@ -183,51 +171,138 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage, mkt strin
|
||||
{"320x240", 320, 240},
|
||||
}
|
||||
|
||||
// 首先保存原图 (UHD 或 1080p)
|
||||
if err := f.saveVariant(ctx, &dbImg, variantName, "jpg", imgData); err != nil {
|
||||
util.Logger.Error("Failed to save original variant", zap.String("variant", variantName), zap.Error(err))
|
||||
}
|
||||
// 检查变体是否已存在 (通过 ImageName)
|
||||
var existingVariants []model.ImageVariant
|
||||
repo.DB.Where("image_name = ?", imageName).Find(&existingVariants)
|
||||
|
||||
for _, v := range targetVariants {
|
||||
// 如果目标分辨率就是原图分辨率,则跳过(已经保存过了)
|
||||
if v.name == variantName {
|
||||
continue
|
||||
allVariantsExist := len(existingVariants) > 0
|
||||
|
||||
var srcImg image.Image
|
||||
var imgData []byte
|
||||
|
||||
if allVariantsExist {
|
||||
util.Logger.Debug("Image variants already exist for name, linking only", zap.String("imageName", imageName))
|
||||
} else {
|
||||
util.Logger.Debug("Downloading and processing image", zap.String("url", imgURL), 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)
|
||||
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
|
||||
srcImg, _, err = image.Decode(bytes.NewReader(imgData))
|
||||
if err != nil {
|
||||
util.Logger.Error("Failed to decode image data", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
currentImgData := buf.Bytes()
|
||||
|
||||
// 保存 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))
|
||||
// 保存原图变体
|
||||
if err := f.saveVariant(ctx, imageName, variantName, "jpg", imgData); err != nil {
|
||||
util.Logger.Error("Failed to save original variant", zap.String("variant", variantName), zap.Error(err))
|
||||
}
|
||||
|
||||
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(®ionRecord).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")
|
||||
if dateStr == today && config.GetConfig().Feature.WriteDailyFiles {
|
||||
f.saveDailyFiles(srcImg, imgData, mkt)
|
||||
if imgData != nil && srcImg != nil {
|
||||
f.saveDailyFiles(srcImg, imgData, mkt)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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 {
|
||||
return uhdURL, "UHD"
|
||||
}
|
||||
return fmt.Sprintf("https://www.bing.com%s_1920x1080.jpg", urlBase), "1920x1080"
|
||||
}
|
||||
|
||||
func (f *Fetcher) downloadImage(url string) ([]byte, error) {
|
||||
resp, err := f.httpClient.Get(url)
|
||||
func (f *Fetcher) downloadImage(ctx context.Context, url string) ([]byte, error) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -235,31 +310,67 @@ func (f *Fetcher) downloadImage(url string) ([]byte, error) {
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, variant, format string, data []byte) error {
|
||||
key := fmt.Sprintf("%s/%s/%s_%s.%s", img.Mkt, img.Date, img.Date, variant, format)
|
||||
func (f *Fetcher) generateKey(imageName, variant, format string) string {
|
||||
return fmt.Sprintf("%s/%s_%s.%s", imageName, imageName, variant, format)
|
||||
}
|
||||
|
||||
func (f *Fetcher) saveVariant(ctx context.Context, imageName, variant, format string, data []byte) error {
|
||||
key := f.generateKey(imageName, variant, format)
|
||||
contentType := "image/jpeg"
|
||||
if format == "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 {
|
||||
return err
|
||||
}
|
||||
|
||||
vRecord := model.ImageVariant{
|
||||
ImageID: img.ID,
|
||||
Variant: variant,
|
||||
Format: format,
|
||||
StorageKey: stored.Key,
|
||||
PublicURL: stored.PublicURL,
|
||||
Size: int64(len(data)),
|
||||
}
|
||||
util.Logger.Info("Successfully saved ImageVariant record to database",
|
||||
zap.String("image_name", imageName),
|
||||
zap.String("variant", variant),
|
||||
zap.String("format", format))
|
||||
|
||||
return repo.DB.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "image_id"}, {Name: "variant"}, {Name: "format"}},
|
||||
DoNothing: true,
|
||||
}).Create(&vRecord).Error
|
||||
return nil
|
||||
}
|
||||
|
||||
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")
|
||||
fJpegRoot, err := os.Create(jpegPathRoot)
|
||||
if err == nil {
|
||||
|
||||
@@ -2,18 +2,24 @@ package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"BingPaper/internal/config"
|
||||
"BingPaper/internal/model"
|
||||
"BingPaper/internal/repo"
|
||||
"BingPaper/internal/service/fetcher"
|
||||
"BingPaper/internal/storage"
|
||||
"BingPaper/internal/util"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var ErrFetchStarted = errors.New("on-demand fetch started")
|
||||
|
||||
func CleanupOldImages(ctx context.Context) error {
|
||||
days := config.GetConfig().Retention.Days
|
||||
if days <= 0 {
|
||||
@@ -23,139 +29,195 @@ func CleanupOldImages(ctx context.Context) error {
|
||||
threshold := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
|
||||
util.Logger.Info("Starting cleanup task", zap.Int("retention_days", days), zap.String("threshold", threshold))
|
||||
|
||||
var images []model.Image
|
||||
if err := repo.DB.Where("date < ?", threshold).Preload("Variants").Find(&images).Error; err != nil {
|
||||
util.Logger.Error("Failed to query old images for cleanup", zap.Error(err))
|
||||
var regionRecords []model.ImageRegion
|
||||
if err := repo.DB.Where("date < ?", threshold).Preload("Variants").Find(®ionRecords).Error; err != nil {
|
||||
util.Logger.Error("Failed to query old image regions for cleanup", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
for _, img := range images {
|
||||
util.Logger.Info("Deleting old image", zap.String("date", img.Date))
|
||||
for _, v := range img.Variants {
|
||||
if err := storage.GlobalStorage.Delete(ctx, v.StorageKey); err != nil {
|
||||
util.Logger.Warn("Failed to delete storage object", zap.String("key", v.StorageKey), zap.Error(err))
|
||||
for _, m := range regionRecords {
|
||||
util.Logger.Info("Deleting old image region record", zap.String("date", m.Date), zap.String("mkt", m.Mkt))
|
||||
|
||||
// 检查该图片名是否还有其他地区或日期在使用
|
||||
var count int64
|
||||
repo.DB.Model(&model.ImageRegion{}).Where("image_name = ? AND id != ?", m.ImageName, m.ID).Count(&count)
|
||||
|
||||
if count == 0 {
|
||||
util.Logger.Info("Image content no longer referenced, deleting files and variants", zap.String("image_name", m.ImageName))
|
||||
for _, v := range m.Variants {
|
||||
if err := storage.GlobalStorage.Delete(ctx, v.StorageKey); err != nil {
|
||||
util.Logger.Warn("Failed to delete storage object", zap.String("key", v.StorageKey), zap.Error(err))
|
||||
}
|
||||
}
|
||||
// 删除变体记录
|
||||
if err := repo.DB.Where("image_name = ?", m.ImageName).Delete(&model.ImageVariant{}).Error; err != nil {
|
||||
util.Logger.Error("Failed to delete variants", zap.String("image_name", m.ImageName), zap.Error(err))
|
||||
}
|
||||
}
|
||||
// 删除关联记录(逻辑外键控制)
|
||||
if err := repo.DB.Where("image_id = ?", img.ID).Delete(&model.ImageVariant{}).Error; err != nil {
|
||||
util.Logger.Error("Failed to delete variants", zap.Uint("image_id", img.ID), zap.Error(err))
|
||||
}
|
||||
// 删除主表记录
|
||||
if err := repo.DB.Delete(&img).Error; err != nil {
|
||||
util.Logger.Error("Failed to delete image", zap.Uint("id", img.ID), zap.Error(err))
|
||||
|
||||
// 删除地区记录
|
||||
if err := repo.DB.Delete(&m).Error; err != nil {
|
||||
util.Logger.Error("Failed to delete image region record", zap.Uint("id", m.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
util.Logger.Info("Cleanup task completed", zap.Int("deleted_count", len(images)))
|
||||
util.Logger.Info("Cleanup task completed", zap.Int("deleted_count", len(regionRecords)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetTodayImage(mkt string) (*model.Image, error) {
|
||||
func GetTodayImage(mkt string) (*model.ImageRegion, error) {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
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)
|
||||
if mkt != "" {
|
||||
tx = tx.Where("mkt = ?", mkt)
|
||||
}
|
||||
err := tx.Preload("Variants").First(&img).Error
|
||||
err := tx.Preload("Variants", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("size asc")
|
||||
}).First(&imgRegion).Error
|
||||
if err != nil && mkt != "" && config.GetConfig().API.EnableOnDemandFetch && util.IsValidRegion(mkt) {
|
||||
// 如果没找到,尝试异步按需抓取该地区
|
||||
util.Logger.Info("Image not found in DB, starting asynchronous on-demand fetch", zap.String("mkt", mkt))
|
||||
f := fetcher.NewFetcher()
|
||||
go func() {
|
||||
_ = f.FetchRegion(context.Background(), mkt)
|
||||
}()
|
||||
return nil, ErrFetchStarted
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// 如果今天没有,尝试获取最近的一张
|
||||
util.Logger.Debug("Today image not found, trying latest image", zap.String("mkt", mkt))
|
||||
// 如果今天还是没有,尝试获取最近的一张
|
||||
tx = repo.DB.Order("date desc")
|
||||
if mkt != "" {
|
||||
tx = tx.Where("mkt = ?", mkt)
|
||||
}
|
||||
err = tx.Preload("Variants").First(&img).Error
|
||||
err = tx.Preload("Variants", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("size asc")
|
||||
}).First(&imgRegion).Error
|
||||
}
|
||||
|
||||
// 兜底逻辑:如果指定地区没找到,且开启了兜底开关,则尝试获取默认地区的图片
|
||||
// 兜底逻辑
|
||||
if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback {
|
||||
defaultMkt := config.GetConfig().GetDefaultMkt()
|
||||
defaultMkt := config.GetConfig().GetDefaultRegion()
|
||||
util.Logger.Debug("Image not found, trying fallback to default region", zap.String("mkt", mkt), zap.String("defaultMkt", defaultMkt))
|
||||
if mkt != defaultMkt {
|
||||
return GetTodayImage(defaultMkt)
|
||||
}
|
||||
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) {
|
||||
var img model.Image
|
||||
// SQLite 使用 RANDOM(), MySQL/Postgres 使用 RANDOM() 或 RAND()
|
||||
// 简单起见,先查总数再 Offset
|
||||
func GetAllRegionsTodayImages() ([]model.ImageRegion, error) {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
regions := config.GetConfig().Fetcher.Regions
|
||||
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
|
||||
tx := repo.DB.Model(&model.Image{})
|
||||
tx := repo.DB.Model(&model.ImageRegion{})
|
||||
if mkt != "" {
|
||||
tx = tx.Where("mkt = ?", mkt)
|
||||
}
|
||||
tx.Count(&count)
|
||||
if count == 0 && mkt != "" && config.GetConfig().API.EnableOnDemandFetch && util.IsValidRegion(mkt) {
|
||||
util.Logger.Info("No images found in DB for region, starting asynchronous on-demand fetch", zap.String("mkt", mkt))
|
||||
f := fetcher.NewFetcher()
|
||||
go func() {
|
||||
_ = f.FetchRegion(context.Background(), mkt)
|
||||
}()
|
||||
return nil, ErrFetchStarted
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return nil, fmt.Errorf("no images found")
|
||||
}
|
||||
|
||||
// 这种方法不适合海量数据,但对于 30 天的数据没问题
|
||||
tx = repo.DB.Order("RANDOM()")
|
||||
if mkt != "" {
|
||||
tx = tx.Where("mkt = ?", mkt)
|
||||
}
|
||||
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
|
||||
}
|
||||
offset := rand.Intn(int(count))
|
||||
util.Logger.Debug("Random image selection", zap.Int64("total", count), zap.Int("offset", offset))
|
||||
err := tx.Preload("Variants", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("size asc")
|
||||
}).Offset(offset).Limit(1).Find(&imgRegion).Error
|
||||
|
||||
// 兜底逻辑
|
||||
if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback {
|
||||
defaultMkt := config.GetConfig().GetDefaultMkt()
|
||||
if (err != nil || imgRegion.ID == 0) && mkt != "" && config.GetConfig().API.EnableMktFallback {
|
||||
defaultMkt := config.GetConfig().GetDefaultRegion()
|
||||
util.Logger.Debug("Random image not found, trying fallback", zap.String("mkt", mkt), zap.String("defaultMkt", defaultMkt))
|
||||
if mkt != defaultMkt {
|
||||
return GetRandomImage(defaultMkt)
|
||||
}
|
||||
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) {
|
||||
var img model.Image
|
||||
func GetImageByDate(date string, mkt string) (*model.ImageRegion, error) {
|
||||
util.Logger.Debug("Getting image by date", zap.String("date", date), zap.String("mkt", mkt))
|
||||
var imgRegion model.ImageRegion
|
||||
tx := repo.DB.Where("date = ?", date)
|
||||
if mkt != "" {
|
||||
tx = tx.Where("mkt = ?", mkt)
|
||||
}
|
||||
err := tx.Preload("Variants").First(&img).Error
|
||||
err := tx.Preload("Variants", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("size asc")
|
||||
}).First(&imgRegion).Error
|
||||
if err != nil && mkt != "" && config.GetConfig().API.EnableOnDemandFetch && util.IsValidRegion(mkt) {
|
||||
util.Logger.Info("Image not found in DB for date, starting asynchronous on-demand fetch", zap.String("mkt", mkt), zap.String("date", date))
|
||||
f := fetcher.NewFetcher()
|
||||
go func() {
|
||||
_ = f.FetchRegion(context.Background(), mkt)
|
||||
}()
|
||||
return nil, ErrFetchStarted
|
||||
}
|
||||
|
||||
// 兜底逻辑
|
||||
if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback {
|
||||
defaultMkt := config.GetConfig().GetDefaultMkt()
|
||||
defaultMkt := config.GetConfig().GetDefaultRegion()
|
||||
if mkt != defaultMkt {
|
||||
return GetImageByDate(date, defaultMkt)
|
||||
}
|
||||
return GetImageByDate(date, "")
|
||||
}
|
||||
|
||||
return &img, err
|
||||
return &imgRegion, err
|
||||
}
|
||||
|
||||
func GetImageList(limit int, offset int, month string, mkt string) ([]model.Image, error) {
|
||||
var images []model.Image
|
||||
tx := repo.DB.Model(&model.Image{})
|
||||
func GetImageList(limit int, offset int, month string, mkt string) ([]model.ImageRegion, error) {
|
||||
var images []model.ImageRegion
|
||||
tx := repo.DB.Model(&model.ImageRegion{})
|
||||
|
||||
if month != "" {
|
||||
// 增强过滤:确保只处理 YYYY-MM 格式,防止注入或非法字符
|
||||
// 这里简单处理:只要不为空就增加 LIKE 过滤
|
||||
util.Logger.Debug("Filtering images by month", zap.String("month", month))
|
||||
tx = tx.Where("date LIKE ?", month+"%")
|
||||
}
|
||||
|
||||
if mkt != "" {
|
||||
tx = tx.Where("mkt = ?", mkt)
|
||||
}
|
||||
|
||||
tx = tx.Order("date desc").Preload("Variants")
|
||||
tx = tx.Order("date desc").Preload("Variants", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("size asc")
|
||||
})
|
||||
|
||||
if limit > 0 {
|
||||
tx = tx.Limit(limit)
|
||||
@@ -165,8 +227,5 @@ func GetImageList(limit int, offset int, month string, mkt string) ([]model.Imag
|
||||
}
|
||||
|
||||
err := tx.Find(&images).Error
|
||||
if err != nil {
|
||||
util.Logger.Error("Failed to get image list", zap.Error(err), zap.String("month", month))
|
||||
}
|
||||
return images, err
|
||||
}
|
||||
|
||||
@@ -63,3 +63,15 @@ func (l *LocalStorage) Delete(ctx context.Context, key string) error {
|
||||
func (l *LocalStorage) PublicURL(key string) (string, bool) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -100,3 +100,18 @@ func (s *S3Storage) PublicURL(key string) (string, bool) {
|
||||
// 也可以生成签名 URL,但这里简单处理
|
||||
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
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ type Storage interface {
|
||||
Get(ctx context.Context, key string) (io.ReadCloser, string, error)
|
||||
Delete(ctx context.Context, key string) error
|
||||
PublicURL(key string) (string, bool)
|
||||
Exists(ctx context.Context, key string) (bool, error)
|
||||
}
|
||||
|
||||
var GlobalStorage Storage
|
||||
|
||||
@@ -72,3 +72,16 @@ func (w *WebDAVStorage) PublicURL(key string) (string, bool) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,26 +1,35 @@
|
||||
package util
|
||||
|
||||
import "golang.org/x/text/language"
|
||||
|
||||
type Region struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
var AllRegions = []Region{
|
||||
{Value: "zh-CN", Label: "中国 (zh-CN)"},
|
||||
{Value: "en-US", Label: "美国 (en-US)"},
|
||||
{Value: "ja-JP", Label: "日本 (ja-JP)"},
|
||||
{Value: "en-AU", Label: "澳大利亚 (en-AU)"},
|
||||
{Value: "en-GB", Label: "英国 (en-GB)"},
|
||||
{Value: "de-DE", Label: "德国 (de-DE)"},
|
||||
{Value: "en-NZ", Label: "新西兰 (en-NZ)"},
|
||||
{Value: "en-CA", Label: "加拿大 (en-CA)"},
|
||||
{Value: "fr-FR", Label: "法国 (fr-FR)"},
|
||||
{Value: "it-IT", Label: "意大利 (it-IT)"},
|
||||
{Value: "es-ES", Label: "西班牙 (es-ES)"},
|
||||
{Value: "pt-BR", Label: "巴西 (pt-BR)"},
|
||||
{Value: "ko-KR", Label: "韩国 (ko-KR)"},
|
||||
{Value: "en-IN", Label: "印度 (en-IN)"},
|
||||
{Value: "ru-RU", Label: "俄罗斯 (ru-RU)"},
|
||||
{Value: "zh-HK", Label: "中国香港 (zh-HK)"},
|
||||
{Value: "zh-TW", Label: "中国台湾 (zh-TW)"},
|
||||
// IsValidRegion 校验是否为标准的地区编码 (BCP 47)
|
||||
func IsValidRegion(mkt string) bool {
|
||||
if mkt == "" {
|
||||
return false
|
||||
}
|
||||
_, err := language.Parse(mkt)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
var AllRegions = []Region{
|
||||
{Value: "zh-CN", Label: "中国"},
|
||||
{Value: "en-US", Label: "美国"},
|
||||
{Value: "ja-JP", Label: "日本"},
|
||||
{Value: "en-AU", Label: "澳大利亚"},
|
||||
{Value: "en-GB", Label: "英国"},
|
||||
{Value: "de-DE", Label: "德国"},
|
||||
{Value: "en-NZ", Label: "新西兰"},
|
||||
{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: "俄罗斯"},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片列表(支持分页和月份筛选)
|
||||
*/
|
||||
|
||||
@@ -118,6 +118,13 @@ export class BingPaperApiService {
|
||||
return apiClient.get<ImageMeta[]>(endpoint)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有地区的今日图片列表
|
||||
*/
|
||||
async getGlobalTodayImages(): Promise<ImageMeta[]> {
|
||||
return apiClient.get<ImageMeta[]>('/images/global/today')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支持的地区列表
|
||||
*/
|
||||
@@ -212,6 +219,7 @@ export const {
|
||||
manualFetch,
|
||||
manualCleanup,
|
||||
getImages,
|
||||
getGlobalTodayImages,
|
||||
getRegions,
|
||||
getTodayImageMeta,
|
||||
getImageMetaByDate,
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface AdminConfig {
|
||||
export interface APIConfig {
|
||||
Mode: string // 'local' | 'redirect'
|
||||
EnableMktFallback: boolean
|
||||
EnableOnDemandFetch: boolean
|
||||
}
|
||||
|
||||
export interface CronConfig {
|
||||
|
||||
@@ -6,23 +6,23 @@ const DEFAULT_MKT = 'zh-CN'
|
||||
* 默认地区列表 (兜底用)
|
||||
*/
|
||||
export const DEFAULT_REGIONS = [
|
||||
{ value: 'zh-CN', label: '中国 (zh-CN)' },
|
||||
{ value: 'en-US', label: '美国 (en-US)' },
|
||||
{ value: 'ja-JP', label: '日本 (ja-JP)' },
|
||||
{ value: 'en-AU', label: '澳大利亚 (en-AU)' },
|
||||
{ value: 'en-GB', label: '英国 (en-GB)' },
|
||||
{ value: 'de-DE', label: '德国 (de-DE)' },
|
||||
{ value: 'en-NZ', label: '新西兰 (en-NZ)' },
|
||||
{ value: 'en-CA', label: '加拿大 (en-CA)' },
|
||||
{ value: 'fr-FR', label: '法国 (fr-FR)' },
|
||||
{ value: 'it-IT', label: '意大利 (it-IT)' },
|
||||
{ value: 'es-ES', label: '西班牙 (es-ES)' },
|
||||
{ value: 'pt-BR', label: '巴西 (pt-BR)' },
|
||||
{ value: 'ko-KR', label: '韩国 (ko-KR)' },
|
||||
{ value: 'en-IN', label: '印度 (en-IN)' },
|
||||
{ value: 'ru-RU', label: '俄罗斯 (ru-RU)' },
|
||||
{ value: 'zh-HK', label: '中国香港 (zh-HK)' },
|
||||
{ value: 'zh-TW', label: '中国台湾 (zh-TW)' },
|
||||
{ value: 'zh-CN', label: '中国' },
|
||||
{ value: 'en-US', label: '美国' },
|
||||
{ value: 'ja-JP', label: '日本' },
|
||||
{ value: 'en-AU', label: '澳大利亚' },
|
||||
{ value: 'en-GB', label: '英国' },
|
||||
{ value: 'de-DE', label: '德国' },
|
||||
{ value: 'en-NZ', label: '新西兰' },
|
||||
{ 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: '俄罗斯' },
|
||||
{ value: 'zh-HK', label: '中国香港' },
|
||||
{ value: 'zh-TW', label: '中国台湾' },
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -102,16 +102,33 @@
|
||||
local: 直接返回图片流; redirect: 重定向到存储位置
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Label for="api-fallback">启用地区不存在时兜底</Label>
|
||||
<Switch
|
||||
id="api-fallback"
|
||||
v-model="config.API.EnableMktFallback"
|
||||
/>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<Label for="api-fallback">启用地区不存在时兜底</Label>
|
||||
<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>
|
||||
<p class="text-xs text-gray-500">
|
||||
如果请求的地区无数据,自动回退到默认地区
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -465,7 +482,7 @@ const allRegions = ref<any[]>([])
|
||||
|
||||
const config = ref<Config>({
|
||||
Admin: { PasswordBcrypt: '' },
|
||||
API: { Mode: 'local', EnableMktFallback: true },
|
||||
API: { Mode: 'local', EnableMktFallback: true, EnableOnDemandFetch: false },
|
||||
Cron: { Enabled: true, DailySpec: '0 9 * * *' },
|
||||
DB: { Type: 'sqlite', DSN: '' },
|
||||
Feature: { WriteDailyFiles: true },
|
||||
|
||||
@@ -69,6 +69,89 @@
|
||||
</div>
|
||||
</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 - 历史图片 -->
|
||||
<section class="py-16 px-4 md:px-8 lg:px-16">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
@@ -262,7 +345,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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 { normalizeImageUrl } from '@/lib/api-config'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -284,9 +367,40 @@ const regions = ref(SUPPORTED_REGIONS)
|
||||
const latestImage = ref<any>(null)
|
||||
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张)
|
||||
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 () => {
|
||||
todayLoading.value = true
|
||||
@@ -303,20 +417,24 @@ const loadLatestImage = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化加载
|
||||
onMounted(async () => {
|
||||
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)
|
||||
|
||||
// 监听滚动容器的变化
|
||||
watch(globalScrollContainer, (el, _, onCleanup) => {
|
||||
if (el) {
|
||||
el.addEventListener('scroll', updateScrollState)
|
||||
// 初始检查
|
||||
setTimeout(updateScrollState, 100)
|
||||
onCleanup(() => {
|
||||
el.removeEventListener('scroll', updateScrollState)
|
||||
})
|
||||
}
|
||||
loadLatestImage()
|
||||
})
|
||||
|
||||
// 监听全球图片数据变化
|
||||
watch(globalImages, () => {
|
||||
setTimeout(updateScrollState, 500)
|
||||
}, { deep: true })
|
||||
|
||||
// 判断最新图片是否为今天的图片
|
||||
const isToday = computed(() => {
|
||||
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()
|
||||
|
||||
// 3. 设置观察者和滚动状态
|
||||
if (images.value.length > 0) {
|
||||
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) {
|
||||
loadMoreObserver.disconnect()
|
||||
}
|
||||
window.removeEventListener('resize', updateScrollState)
|
||||
})
|
||||
|
||||
// 格式化日期
|
||||
@@ -580,8 +717,9 @@ const getImageUrl = (image: any) => {
|
||||
}
|
||||
|
||||
// 查看图片详情
|
||||
const viewImage = (date: string) => {
|
||||
router.push(`/image/${date}`)
|
||||
const viewImage = (date: string, mkt?: string) => {
|
||||
const query = mkt ? `?mkt=${mkt}` : ''
|
||||
router.push(`/image/${date}${query}`)
|
||||
}
|
||||
|
||||
// 打开版权详情链接
|
||||
@@ -621,4 +759,13 @@ html {
|
||||
html::-webkit-scrollbar {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user