diff --git a/CONFIG.md b/CONFIG.md index f7e5c1a..4c1e9e1 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -36,11 +36,15 @@ BingPaper 支持通过配置文件(YAML)和环境变量进行配置。 - `mode`: API 行为模式。 - `local`: (默认) 接口直接返回图片的二进制流,适合图片存储对外部不可见的情况。 - `redirect`: 接口返回 302 重定向到图片的 `PublicURL`,适合配合 S3 或 WebDAV 的公共访问。 +- `enable_mkt_fallback`: 当请求的地区不存在或无数据时,是否允许兜底回退到默认地区或任意可用地区,默认 `true`。 #### cron (定时任务) - `enabled`: 是否启用定时抓取,默认 `true`。 - `daily_spec`: Cron 表达式,定义每日抓取时间。默认 `"0 10 * * *"` (每日上午 10:00)。 +#### fetcher (抓取配置) +- `regions`: 需要抓取的地区编码列表(如 `zh-CN`, `en-US` 等)。如果不设置,默认为包括主要国家在内的 17 个地区。 + #### retention (数据保留) - `days`: 图片及元数据保留天数。超过此天数的数据可能会被清理任务处理。设置为 `0` 表示永久保留,不进行自动清理。默认 `0`。 diff --git a/README.md b/README.md index 431c2a2..58fec55 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ go run . - `GET /api/v1/image/random`:返回随机图片 - `GET /api/v1/image/date/:yyyy-mm-dd`:返回指定日期图片 - **查询参数**: + - `mkt`:地区编码 (zh-CN, en-US, ja-JP 等),默认 `zh-CN` - `variant`:分辨率 (UHD, 1920x1080, 1366x768),默认 `UHD` - `format`:格式 (jpg),默认 `jpg` diff --git a/config.example.yaml b/config.example.yaml index 71ec177..ea340ef 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -16,6 +16,8 @@ log: api: mode: local # local | redirect + enable_mkt_fallback: true # 当请求的地区不存在时,是否回退到默认地区 + enable_on_demand_fetch: false # 是否开启按需抓取(当数据库中没有请求的地区图片时,实时从 Bing 抓取) cron: enabled: true diff --git a/docs/docs.go b/docs/docs.go index 3d67f9f..73321c8 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -413,6 +413,12 @@ const docTemplate = `{ "in": "path", "required": true }, + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + }, { "type": "string", "default": "UHD", @@ -434,6 +440,24 @@ const docTemplate = `{ "schema": { "type": "file" } + }, + "202": { + "description": "按需抓取任务已启动", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "图片未找到,响应体包含具体原因", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } } } } @@ -455,6 +479,12 @@ const docTemplate = `{ "name": "date", "in": "path", "required": true + }, + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" } ], "responses": { @@ -463,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" + } + } } } } @@ -478,6 +526,12 @@ const docTemplate = `{ ], "summary": "获取随机图片", "parameters": [ + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + }, { "type": "string", "default": "UHD", @@ -499,6 +553,24 @@ const docTemplate = `{ "schema": { "type": "file" } + }, + "202": { + "description": "按需抓取任务已启动", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "图片未找到,响应体包含具体原因", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } } } } @@ -513,12 +585,38 @@ const docTemplate = `{ "image" ], "summary": "获取随机图片元数据", + "parameters": [ + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + } + ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/handlers.ImageMetaResp" } + }, + "202": { + "description": "按需抓取任务已启动", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "图片未找到,响应体包含具体原因", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } } } } @@ -534,6 +632,12 @@ const docTemplate = `{ ], "summary": "获取今日图片", "parameters": [ + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + }, { "type": "string", "default": "UHD", @@ -555,6 +659,24 @@ const docTemplate = `{ "schema": { "type": "file" } + }, + "202": { + "description": "按需抓取任务已启动", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "图片未找到,响应体包含具体原因", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } } } } @@ -569,12 +691,38 @@ const docTemplate = `{ "image" ], "summary": "获取今日图片元数据", + "parameters": [ + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + } + ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/handlers.ImageMetaResp" } + }, + "202": { + "description": "按需抓取任务已启动", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "图片未找到,响应体包含具体原因", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } } } } @@ -614,6 +762,12 @@ const docTemplate = `{ "description": "按月份过滤 (格式: YYYY-MM)", "name": "month", "in": "query" + }, + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" } ], "responses": { @@ -628,12 +782,66 @@ 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": "返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。", + "produces": [ + "application/json" + ], + "tags": [ + "image" + ], + "summary": "获取支持的地区列表", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/util.Region" + } + } + } + } + } } }, "definitions": { "config.APIConfig": { "type": "object", "properties": { + "enableMktFallback": { + "description": "当请求的地区不存在时,是否回退到默认地区", + "type": "boolean" + }, + "enableOnDemandFetch": { + "description": "是否启用按需抓取", + "type": "boolean" + }, "mode": { "description": "local | redirect", "type": "string" @@ -666,6 +874,9 @@ const docTemplate = `{ "feature": { "$ref": "#/definitions/config.FeatureConfig" }, + "fetcher": { + "$ref": "#/definitions/config.FetcherConfig" + }, "log": { "$ref": "#/definitions/config.LogConfig" }, @@ -717,6 +928,17 @@ const docTemplate = `{ } } }, + "config.FetcherConfig": { + "type": "object", + "properties": { + "regions": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "config.LocalConfig": { "type": "object", "properties": { @@ -917,6 +1139,9 @@ const docTemplate = `{ "hsh": { "type": "string" }, + "mkt": { + "type": "string" + }, "quiz": { "type": "string" }, @@ -1006,6 +1231,17 @@ const docTemplate = `{ "type": "string" } } + }, + "util.Region": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/docs/swagger.json b/docs/swagger.json index 674cdc6..a51019b 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -407,6 +407,12 @@ "in": "path", "required": true }, + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + }, { "type": "string", "default": "UHD", @@ -428,6 +434,24 @@ "schema": { "type": "file" } + }, + "202": { + "description": "按需抓取任务已启动", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "图片未找到,响应体包含具体原因", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } } } } @@ -449,6 +473,12 @@ "name": "date", "in": "path", "required": true + }, + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" } ], "responses": { @@ -457,6 +487,24 @@ "schema": { "$ref": "#/definitions/handlers.ImageMetaResp" } + }, + "202": { + "description": "按需抓取任务已启动", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "图片未找到,响应体包含具体原因", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } } } } @@ -472,6 +520,12 @@ ], "summary": "获取随机图片", "parameters": [ + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + }, { "type": "string", "default": "UHD", @@ -493,6 +547,24 @@ "schema": { "type": "file" } + }, + "202": { + "description": "按需抓取任务已启动", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "图片未找到,响应体包含具体原因", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } } } } @@ -507,12 +579,38 @@ "image" ], "summary": "获取随机图片元数据", + "parameters": [ + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + } + ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/handlers.ImageMetaResp" } + }, + "202": { + "description": "按需抓取任务已启动", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "图片未找到,响应体包含具体原因", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } } } } @@ -528,6 +626,12 @@ ], "summary": "获取今日图片", "parameters": [ + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + }, { "type": "string", "default": "UHD", @@ -549,6 +653,24 @@ "schema": { "type": "file" } + }, + "202": { + "description": "按需抓取任务已启动", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "图片未找到,响应体包含具体原因", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } } } } @@ -563,12 +685,38 @@ "image" ], "summary": "获取今日图片元数据", + "parameters": [ + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + } + ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/handlers.ImageMetaResp" } + }, + "202": { + "description": "按需抓取任务已启动", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "图片未找到,响应体包含具体原因", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } } } } @@ -608,6 +756,12 @@ "description": "按月份过滤 (格式: YYYY-MM)", "name": "month", "in": "query" + }, + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" } ], "responses": { @@ -622,12 +776,66 @@ } } } + }, + "/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": "返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。", + "produces": [ + "application/json" + ], + "tags": [ + "image" + ], + "summary": "获取支持的地区列表", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/util.Region" + } + } + } + } + } } }, "definitions": { "config.APIConfig": { "type": "object", "properties": { + "enableMktFallback": { + "description": "当请求的地区不存在时,是否回退到默认地区", + "type": "boolean" + }, + "enableOnDemandFetch": { + "description": "是否启用按需抓取", + "type": "boolean" + }, "mode": { "description": "local | redirect", "type": "string" @@ -660,6 +868,9 @@ "feature": { "$ref": "#/definitions/config.FeatureConfig" }, + "fetcher": { + "$ref": "#/definitions/config.FetcherConfig" + }, "log": { "$ref": "#/definitions/config.LogConfig" }, @@ -711,6 +922,17 @@ } } }, + "config.FetcherConfig": { + "type": "object", + "properties": { + "regions": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "config.LocalConfig": { "type": "object", "properties": { @@ -911,6 +1133,9 @@ "hsh": { "type": "string" }, + "mkt": { + "type": "string" + }, "quiz": { "type": "string" }, @@ -1000,6 +1225,17 @@ "type": "string" } } + }, + "util.Region": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 9b4a38e..09621d1 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2,6 +2,12 @@ basePath: /api/v1 definitions: config.APIConfig: properties: + enableMktFallback: + description: 当请求的地区不存在时,是否回退到默认地区 + type: boolean + enableOnDemandFetch: + description: 是否启用按需抓取 + type: boolean mode: description: local | redirect type: string @@ -23,6 +29,8 @@ definitions: $ref: '#/definitions/config.DBConfig' feature: $ref: '#/definitions/config.FeatureConfig' + fetcher: + $ref: '#/definitions/config.FetcherConfig' log: $ref: '#/definitions/config.LogConfig' retention: @@ -56,6 +64,13 @@ definitions: writeDailyFiles: type: boolean type: object + config.FetcherConfig: + properties: + regions: + items: + type: string + type: array + type: object config.LocalConfig: properties: root: @@ -190,6 +205,8 @@ definitions: type: string hsh: type: string + mkt: + type: string quiz: type: string startdate: @@ -248,6 +265,13 @@ definitions: updated_at: type: string type: object + util.Region: + properties: + label: + type: string + value: + type: string + type: object host: localhost:8080 info: contact: {} @@ -501,6 +525,10 @@ paths: name: date required: true type: string + - description: 地区编码 (如 zh-CN, en-US) + in: query + name: mkt + type: string - default: UHD description: 分辨率 in: query @@ -518,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 @@ -530,6 +570,10 @@ paths: name: date required: true type: string + - description: 地区编码 (如 zh-CN, en-US) + in: query + name: mkt + type: string produces: - application/json responses: @@ -537,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 @@ -544,6 +600,10 @@ paths: get: description: 随机返回一张已抓取的图片流或重定向 parameters: + - description: 地区编码 (如 zh-CN, en-US) + in: query + name: mkt + type: string - default: UHD description: 分辨率 in: query @@ -561,12 +621,29 @@ 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 /image/random/meta: get: description: 随机获取一张已抓取图片的元数据 + parameters: + - description: 地区编码 (如 zh-CN, en-US) + in: query + name: mkt + type: string produces: - application/json responses: @@ -574,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 @@ -581,6 +670,10 @@ paths: get: description: 根据参数返回今日必应图片流或重定向 parameters: + - description: 地区编码 (如 zh-CN, en-US) + in: query + name: mkt + type: string - default: UHD description: 分辨率 (UHD, 1920x1080, 1366x768, 1280x720, 1024x768, 800x600, 800x480, 640x480, 640x360, 480x360, 400x240, 320x240) @@ -599,12 +692,29 @@ 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 /image/today/meta: get: description: 获取今日必应图片的标题、版权等元数据 + parameters: + - description: 地区编码 (如 zh-CN, en-US) + in: query + name: mkt + type: string produces: - application/json responses: @@ -612,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 @@ -637,6 +759,10 @@ paths: in: query name: month type: string + - description: 地区编码 (如 zh-CN, en-US) + in: query + name: mkt + type: string produces: - application/json responses: @@ -649,6 +775,36 @@ 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: 返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。 + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/util.Region' + type: array + summary: 获取支持的地区列表 + tags: + - image securityDefinitions: BearerAuth: in: header diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 5729ae7..b9e3bb3 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "os" + "strings" "BingPaper/internal/config" "BingPaper/internal/cron" @@ -52,10 +53,11 @@ func Init(webFS embed.FS, configPath string) *gin.Engine { // 输出配置信息 util.Logger.Info("Application configuration loaded") - util.Logger.Info("├─ Config file", zap.String("path", config.GetRawViper().ConfigFileUsed())) - util.Logger.Info("├─ Database ", zap.String("type", cfg.DB.Type)) - util.Logger.Info("├─ Storage ", zap.String("type", cfg.Storage.Type)) - util.Logger.Info("└─ Server ", zap.Int("port", cfg.Server.Port)) + util.Logger.Info("├─ Config file ", zap.String("path", config.GetRawViper().ConfigFileUsed())) + util.Logger.Info("├─ Database ", zap.String("type", cfg.DB.Type)) + util.Logger.Info("├─ Storage ", zap.String("type", cfg.Storage.Type)) + util.Logger.Info("├─ Server ", zap.Int("port", cfg.Server.Port)) + util.Logger.Info("└─ Active Mkt ", zap.Strings("regions", cfg.Fetcher.Regions)) // 根据存储类型输出更多信息 switch cfg.Storage.Type { @@ -147,5 +149,6 @@ func LogWelcomeInfo() { fmt.Printf(" - 管理后台: %s/admin\n", baseURL) fmt.Printf(" - API 文档: %s/swagger/index.html\n", baseURL) fmt.Printf(" - 今日图片: %s/api/v1/image/today\n", baseURL) + fmt.Printf(" - 激活地区: %s\n", strings.Join(cfg.Fetcher.Regions, ", ")) fmt.Println("---------------------------------------------------------") } diff --git a/internal/config/config.go b/internal/config/config.go index c694f36..38f2b24 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,8 @@ import ( "github.com/fsnotify/fsnotify" "github.com/spf13/viper" "gopkg.in/yaml.v3" + + "BingPaper/internal/util" ) type Config struct { @@ -25,6 +27,7 @@ type Config struct { Token TokenConfig `mapstructure:"token" yaml:"token"` Feature FeatureConfig `mapstructure:"feature" yaml:"feature"` Web WebConfig `mapstructure:"web" yaml:"web"` + Fetcher FetcherConfig `mapstructure:"fetcher" yaml:"fetcher"` } type ServerConfig struct { @@ -57,7 +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 + 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 { @@ -118,6 +123,10 @@ type WebConfig struct { Path string `mapstructure:"path" yaml:"path"` } +type FetcherConfig struct { + Regions []string `mapstructure:"regions" yaml:"regions"` +} + // Bing 默认配置 (内置) const ( BingMkt = "zh-CN" @@ -156,7 +165,9 @@ func Init(configPath string) error { v.SetDefault("log.log_console", true) v.SetDefault("log.show_db_log", false) v.SetDefault("log.db_log_level", "info") - v.SetDefault("api.mode", "local") + v.SetDefault("api.mode", "redirect") + 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) @@ -167,6 +178,13 @@ func Init(configPath string) error { v.SetDefault("token.default_ttl", "168h") v.SetDefault("feature.write_daily_files", true) v.SetDefault("web.path", "web") + + // 默认抓取所有支持的地区 + var defaultRegions []string + for _, r := range util.AllRegions { + defaultRegions = append(defaultRegions, r.Value) + } + v.SetDefault("fetcher.regions", defaultRegions) v.SetDefault("admin.password_bcrypt", "$2a$10$fYHPeWHmwObephJvtlyH1O8DIgaLk5TINbi9BOezo2M8cSjmJchka") // 默认密码: admin123 // 绑定环境变量 @@ -311,3 +329,11 @@ func GetTokenTTL() time.Duration { } return ttl } + +// GetDefaultMkt 返回生效的默认地区编码 +func (c *Config) GetDefaultMkt() string { + if len(c.Fetcher.Regions) > 0 { + return c.Fetcher.Regions[0] + } + return BingMkt +} diff --git a/internal/http/handlers/image.go b/internal/http/handlers/image.go index 7c6ecca..846e934 100644 --- a/internal/http/handlers/image.go +++ b/internal/http/handlers/image.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "strconv" + "strings" "BingPaper/internal/config" "BingPaper/internal/model" @@ -27,6 +28,7 @@ type ImageVariantResp struct { type ImageMetaResp struct { Date string `json:"date"` + Mkt string `json:"mkt"` Title string `json:"title"` Copyright string `json:"copyright"` CopyrightLink string `json:"copyrightlink"` @@ -41,15 +43,23 @@ type ImageMetaResp struct { // @Summary 获取今日图片 // @Description 根据参数返回今日必应图片流或重定向 // @Tags image +// @Param mkt query string false "地区编码 (如 zh-CN, en-US)" // @Param variant query string false "分辨率 (UHD, 1920x1080, 1366x768, 1280x720, 1024x768, 800x600, 800x480, 640x480, 640x360, 480x360, 400x240, 320x240)" default(UHD) // @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) { - img, err := image.GetTodayImage() + mkt := c.Query("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 { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + sendImageNotFound(c, mkt) return } handleImageResponse(c, img, 7200) // 2小时 @@ -59,13 +69,21 @@ func GetToday(c *gin.Context) { // @Summary 获取今日图片元数据 // @Description 获取今日必应图片的标题、版权等元数据 // @Tags image +// @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) { - img, err := image.GetTodayImage() + mkt := c.Query("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 { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + sendImageNotFound(c, mkt) return } c.Header("Cache-Control", "public, max-age=7200") // 2小时 @@ -76,15 +94,23 @@ func GetTodayMeta(c *gin.Context) { // @Summary 获取随机图片 // @Description 随机返回一张已抓取的图片流或重定向 // @Tags image +// @Param mkt query string false "地区编码 (如 zh-CN, en-US)" // @Param variant query string false "分辨率" default(UHD) // @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] func GetRandom(c *gin.Context) { - img, err := image.GetRandomImage() + mkt := c.Query("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 { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + sendImageNotFound(c, mkt) return } handleImageResponse(c, img, 0) // 禁用缓存 @@ -94,13 +120,21 @@ func GetRandom(c *gin.Context) { // @Summary 获取随机图片元数据 // @Description 随机获取一张已抓取图片的元数据 // @Tags image +// @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) { - img, err := image.GetRandomImage() + mkt := c.Query("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 { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + sendImageNotFound(c, mkt) return } c.Header("Cache-Control", "no-cache, no-store, must-revalidate") @@ -112,16 +146,24 @@ func GetRandomMeta(c *gin.Context) { // @Description 根据日期返回图片流或重定向 (yyyy-mm-dd) // @Tags image // @Param date path string true "日期 (yyyy-mm-dd)" +// @Param mkt query string false "地区编码 (如 zh-CN, en-US)" // @Param variant query string false "分辨率" default(UHD) // @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") - img, err := image.GetImageByDate(date) + mkt := c.Query("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 { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + sendImageNotFound(c, mkt) return } handleImageResponse(c, img, 604800) // 7天 @@ -132,14 +174,22 @@ func GetByDate(c *gin.Context) { // @Description 根据日期获取图片元数据 (yyyy-mm-dd) // @Tags image // @Param date path string true "日期 (yyyy-mm-dd)" +// @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") - img, err := image.GetImageByDate(date) + mkt := c.Query("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 { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + sendImageNotFound(c, mkt) return } c.Header("Cache-Control", "public, max-age=604800") // 7天 @@ -154,6 +204,7 @@ func GetByDateMeta(c *gin.Context) { // @Param page query int false "页码 (从1开始)" // @Param page_size query int false "每页数量" // @Param month query string false "按月份过滤 (格式: YYYY-MM)" +// @Param mkt query string false "地区编码 (如 zh-CN, en-US)" // @Produce json // @Success 200 {array} ImageMetaResp // @Router /images [get] @@ -162,10 +213,12 @@ func ListImages(c *gin.Context) { pageStr := c.Query("page") pageSizeStr := c.Query("page_size") month := c.Query("month") + mkt := c.Query("mkt") // 记录请求参数,便于排查过滤失效问题 util.Logger.Debug("ListImages parameters", zap.String("month", month), + zap.String("mkt", mkt), zap.String("page", pageStr), zap.String("page_size", pageSizeStr), zap.String("limit", limitStr)) @@ -192,7 +245,7 @@ func ListImages(c *gin.Context) { offset = 0 } - images, err := image.GetImageList(limit, offset, month) + images, err := image.GetImageList(limit, offset, month, mkt) if err != nil { util.Logger.Error("ListImages service call failed", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -201,11 +254,60 @@ func ListImages(c *gin.Context) { result := []gin.H{} for _, img := range images { - result = append(result, formatMeta(&img)) + result = append(result, formatMetaSummary(&img)) } 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) { variant := c.DefaultQuery("variant", "UHD") format := c.DefaultQuery("format", "jpg") @@ -283,6 +385,49 @@ func serveLocal(c *gin.Context, key string, etag string, maxAge int) { io.Copy(c.Writer, reader) } +func formatMetaSummary(img *model.Image) 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 { + 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) + } 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) + } + variants = append(variants, gin.H{ + "variant": smallest.Variant, + "format": smallest.Format, + "size": smallest.Size, + "url": url, + "storage_key": smallest.StorageKey, + }) + } + + 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, + "variants": variants, + } +} + func formatMeta(img *model.Image) gin.H { cfg := config.GetConfig() variants := []gin.H{} @@ -291,7 +436,7 @@ func formatMeta(img *model.Image) gin.H { if url == "" && cfg.API.Mode == "redirect" && img.URLBase != "" { url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, v.Variant) } else if cfg.API.Mode == "local" || url == "" { - url = fmt.Sprintf("%s/api/v1/image/date/%s?variant=%s&format=%s", cfg.Server.BaseURL, img.Date, v.Variant, v.Format) + 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) } variants = append(variants, gin.H{ "variant": v.Variant, @@ -304,6 +449,7 @@ 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, @@ -314,3 +460,63 @@ func formatMeta(img *model.Image) gin.H { "variants": variants, } } + +// GetRegions 获取支持的地区列表 +// @Summary 获取支持的地区列表 +// @Description 返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。 +// @Tags image +// @Produce json +// @Success 200 {array} util.Region +// @Router /regions [get] +func GetRegions(c *gin.Context) { + cfg := config.GetConfig() + pinned := cfg.Fetcher.Regions + + if len(pinned) == 0 { + // 如果没有配置抓取地区,返回所有支持的地区 + c.JSON(http.StatusOK, util.AllRegions) + return + } + + // 创建一个 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) +} diff --git a/internal/http/handlers/image_test.go b/internal/http/handlers/image_test.go index ac1d48a..1617caa 100644 --- a/internal/http/handlers/image_test.go +++ b/internal/http/handlers/image_test.go @@ -1,6 +1,7 @@ package handlers import ( + "encoding/json" "net/http" "net/http/httptest" "testing" @@ -65,4 +66,43 @@ func TestHandleImageResponseRedirect(t *testing.T) { assert.Contains(t, variants[0]["url"].(string), "myserver.com") assert.Contains(t, variants[0]["url"].(string), "/api/v1/image/date/") }) + + t.Run("FormatMetaSummary should only return the smallest variant", func(t *testing.T) { + imgWithMultipleVariants := &model.Image{ + Date: "2026-01-26", + Variants: []model.ImageVariant{ + {Variant: "UHD", Size: 1000, Format: "jpg"}, + {Variant: "640x480", Size: 200, Format: "jpg"}, + {Variant: "1920x1080", Size: 500, Format: "jpg"}, + }, + } + meta := formatMetaSummary(imgWithMultipleVariants) + variants := meta["variants"].([]gin.H) + assert.Equal(t, 1, len(variants)) + assert.Equal(t, "640x480", variants[0]["variant"]) + }) +} + +func TestGetRegions(t *testing.T) { + gin.SetMode(gin.TestMode) + + t.Run("GetRegions should respect pinned order", func(t *testing.T) { + // Setup config with custom pinned regions + config.Init("") + config.GetConfig().Fetcher.Regions = []string{"en-US", "ja-JP"} + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + GetRegions(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var regions []map[string]string + err := json.Unmarshal(w.Body.Bytes(), ®ions) + assert.NoError(t, err) + + assert.GreaterOrEqual(t, len(regions), 2) + assert.Equal(t, "en-US", regions[0]["value"]) + assert.Equal(t, "ja-JP", regions[1]["value"]) + }) } diff --git a/internal/http/router.go b/internal/http/router.go index acef4fd..d29ddac 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -47,6 +47,8 @@ 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) // 管理接口 admin := api.Group("/admin") diff --git a/internal/model/models.go b/internal/model/models.go index 614e07d..2d46832 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -8,7 +8,8 @@ import ( type Image struct { ID uint `gorm:"primaryKey" json:"id"` - Date string `gorm:"uniqueIndex;type:varchar(10)" json:"date"` // YYYY-MM-DD + Date string `gorm:"uniqueIndex:idx_date_mkt;index:idx_mkt_date,priority:2;type:varchar(10)" json:"date"` // YYYY-MM-DD + Mkt string `gorm:"uniqueIndex:idx_date_mkt;index:idx_mkt_date,priority:1;type:varchar(10)" json:"mkt"` // zh-CN, en-US etc. Title string `json:"title"` Copyright string `json:"copyright"` CopyrightLink string `json:"copyrightlink"` diff --git a/internal/service/fetcher/fetcher.go b/internal/service/fetcher/fetcher.go index f3fcce8..baaefa8 100644 --- a/internal/service/fetcher/fetcher.go +++ b/internal/service/fetcher/fetcher.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" "BingPaper/internal/config" @@ -55,7 +56,44 @@ func NewFetcher() *Fetcher { func (f *Fetcher) Fetch(ctx context.Context, n int) error { util.Logger.Info("Starting fetch task", zap.Int("n", n)) - url := fmt.Sprintf("%s?format=js&idx=0&n=%d&uhd=1&mkt=%s", config.BingAPIBase, n, config.BingMkt) + regions := config.GetConfig().Fetcher.Regions + if len(regions) == 0 { + regions = []string{config.GetConfig().GetDefaultMkt()} + } + + 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)) + // 调用两次 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) if err != nil { @@ -70,49 +108,34 @@ func (f *Fetcher) Fetch(ctx context.Context, n int) error { return err } - util.Logger.Info("Fetched images from Bing", zap.Int("count", len(bingResp.Images))) + util.Logger.Info("Fetched images from Bing", zap.String("mkt", mkt), zap.Int("count", len(bingResp.Images))) for _, bingImg := range bingResp.Images { - if err := f.processImage(ctx, bingImg); err != nil { - util.Logger.Error("Failed to process image", zap.String("date", bingImg.Enddate), zap.Error(err)) + if err := f.processImage(ctx, bingImg, mkt); err != nil { + util.Logger.Error("Failed to process image", zap.String("date", bingImg.Enddate), zap.String("mkt", mkt), zap.Error(err)) } } - util.Logger.Info("Fetch task completed") return nil } -func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage) error { +func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage, mkt string) error { dateStr := fmt.Sprintf("%s-%s-%s", bingImg.Enddate[0:4], bingImg.Enddate[4:6], bingImg.Enddate[6:8]) // 幂等检查 var existing model.Image - if err := repo.DB.Where("date = ?", dateStr).First(&existing).Error; err == nil { - util.Logger.Info("Image already exists, skipping", zap.String("date", dateStr)) + if err := repo.DB.Where("date = ? AND mkt = ?", dateStr, mkt).First(&existing).Error; err == nil { + util.Logger.Debug("Image already exists in DB, skipping", zap.String("date", dateStr), zap.String("mkt", mkt)) return nil } - util.Logger.Info("Processing new image", zap.String("date", dateStr), zap.String("title", bingImg.Title)) - - // 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 - } + imageName := f.extractImageName(bingImg.URLBase, bingImg.HSH) + util.Logger.Info("Processing image", zap.String("date", dateStr), zap.String("mkt", mkt), zap.String("imageName", imageName)) // 创建 DB 记录 dbImg := model.Image{ Date: dateStr, + Mkt: mkt, Title: bingImg.Title, Copyright: bingImg.Copyright, CopyrightLink: bingImg.CopyrightLink, @@ -124,23 +147,25 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage) error { } if err := repo.DB.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "date"}}, + 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 = ?", dateStr).First(&existing).Error; err != nil { + if err := repo.DB.Where("date = ? AND mkt = ?", dateStr, mkt).First(&existing).Error; err != nil { util.Logger.Error("Failed to query existing image record after conflict", zap.Error(err)) return err } dbImg = existing } + // UHD 探测 + imgURL, variantName := f.probeUHD(bingImg.URLBase) + // 保存各种分辨率 targetVariants := []struct { name string @@ -160,40 +185,105 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage) error { {"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)) - } - - 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() - - // 保存 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)) + // 检查是否所有变体都已存在于存储中 + 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 + } } } - // 保存今日额外文件 - today := time.Now().Format("2006-01-02") - if dateStr == today && config.GetConfig().Feature.WriteDailyFiles { - f.saveDailyFiles(srcImg, imgData) + 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)) + } + + 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, &dbImg, imageName, v.name, "jpg", currentImgData); err != nil { + util.Logger.Error("Failed to save variant", zap.String("variant", v.name), zap.Error(err)) + } + } + + // 保存今日额外文件 + today := time.Now().Format("2006-01-02") + if dateStr == today && config.GetConfig().Feature.WriteDailyFiles { + f.saveDailyFiles(srcImg, imgData, mkt) + } } 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) { uhdURL := fmt.Sprintf("https://www.bing.com%s_UHD.jpg", urlBase) resp, err := f.httpClient.Head(uhdURL) @@ -212,25 +302,52 @@ 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", 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, img *model.Image, 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) - if err != nil { - return err + 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) + 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{ ImageID: img.ID, Variant: variant, Format: format, - StorageKey: stored.Key, - PublicURL: stored.PublicURL, - Size: int64(len(data)), + StorageKey: key, + PublicURL: publicURL, + Size: size, } return repo.DB.Clauses(clause.OnConflict{ @@ -239,20 +356,21 @@ func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, variant, fo }).Create(&vRecord).Error } -func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte) { - util.Logger.Info("Saving daily files") +func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte, mkt string) { + util.Logger.Info("Saving daily files", zap.String("mkt", mkt)) localRoot := config.GetConfig().Storage.Local.Root if localRoot == "" { localRoot = "data" } - if err := os.MkdirAll(localRoot, 0755); err != nil { - util.Logger.Error("Failed to create directory", zap.String("path", localRoot), zap.Error(err)) + mktDir := filepath.Join(localRoot, mkt) + if err := os.MkdirAll(mktDir, 0755); err != nil { + util.Logger.Error("Failed to create directory", zap.String("path", mktDir), zap.Error(err)) return } // daily.jpeg (quality 100) - jpegPath := filepath.Join(localRoot, "daily.jpeg") + jpegPath := filepath.Join(mktDir, "daily.jpeg") fJpeg, err := os.Create(jpegPath) if err != nil { util.Logger.Error("Failed to create daily.jpeg", zap.Error(err)) @@ -262,8 +380,21 @@ func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte) { } // original.jpeg (quality 100) - originalPath := filepath.Join(localRoot, "original.jpeg") + originalPath := filepath.Join(mktDir, "original.jpeg") if err := os.WriteFile(originalPath, originalData, 0644); err != nil { util.Logger.Error("Failed to write original.jpeg", zap.Error(err)) } + + // 同时也保留一份在根目录下(兼容旧逻辑,且作为默认地区图片) + // 如果是默认地区或者是第一个抓取的地区,可以覆盖根目录的文件 + if mkt == config.GetConfig().GetDefaultMkt() { + jpegPathRoot := filepath.Join(localRoot, "daily.jpeg") + fJpegRoot, err := os.Create(jpegPathRoot) + if err == nil { + jpeg.Encode(fJpegRoot, srcImg, &jpeg.Options{Quality: 100}) + fJpegRoot.Close() + } + originalPathRoot := filepath.Join(localRoot, "original.jpeg") + os.WriteFile(originalPathRoot, originalData, 0644) + } } diff --git a/internal/service/image/image_service.go b/internal/service/image/image_service.go index 7b0fba0..e29ad6c 100644 --- a/internal/service/image/image_service.go +++ b/internal/service/image/image_service.go @@ -2,18 +2,23 @@ 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" ) +var ErrFetchStarted = errors.New("on-demand fetch started") + func CleanupOldImages(ctx context.Context) error { days := config.GetConfig().Retention.Days if days <= 0 { @@ -50,43 +55,155 @@ func CleanupOldImages(ctx context.Context) error { return nil } -func GetTodayImage() (*model.Image, error) { +func GetTodayImage(mkt string) (*model.Image, error) { 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 - err := repo.DB.Where("date = ?", today).Preload("Variants").First(&img).Error + tx := repo.DB.Where("date = ?", today) + if mkt != "" { + tx = tx.Where("mkt = ?", mkt) + } + 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 { - // 如果今天没有,尝试获取最近的一张 - err = repo.DB.Order("date desc").Preload("Variants").First(&img).Error + 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 + } + + // 兜底逻辑:如果指定地区没找到,且开启了兜底开关,则尝试获取默认地区的图片 + if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback { + 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 { + return GetTodayImage(defaultMkt) + } + return GetTodayImage("") + } + + if err == nil { + util.Logger.Debug("Found image", zap.String("date", img.Date), zap.String("mkt", img.Mkt)) } return &img, err } -func GetRandomImage() (*model.Image, error) { +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) { + util.Logger.Debug("Getting random image", zap.String("mkt", mkt)) var img model.Image // SQLite 使用 RANDOM(), MySQL/Postgres 使用 RANDOM() 或 RAND() // 简单起见,先查总数再 Offset var count int64 - repo.DB.Model(&model.Image{}).Count(&count) + tx := repo.DB.Model(&model.Image{}) + 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 天的数据没问题 - err := repo.DB.Order("RANDOM()").Preload("Variants").First(&img).Error - if err != nil { - // 适配 MySQL - err = repo.DB.Order("RAND()").Preload("Variants").First(&img).Error + // 优化随机查询:使用 Offset 代替 ORDER BY RANDOM() + // 注意:tx 包含了前面的 Where 条件 + offset := rand.Intn(int(count)) + 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 + + // 兜底逻辑 + if (err != nil || img.ID == 0) && mkt != "" && config.GetConfig().API.EnableMktFallback { + defaultMkt := config.GetConfig().GetDefaultMkt() + 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("") + } + + 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 +} + +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 + tx := repo.DB.Where("date = ?", date) + if mkt != "" { + tx = tx.Where("mkt = ?", mkt) + } + 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 { + 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 { + return GetImageByDate(date, defaultMkt) + } + 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 } -func GetImageByDate(date string) (*model.Image, error) { - var img model.Image - err := repo.DB.Where("date = ?", date).Preload("Variants").First(&img).Error - return &img, err -} - -func GetImageList(limit int, offset int, month 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 tx := repo.DB.Model(&model.Image{}) @@ -97,6 +214,10 @@ func GetImageList(limit int, offset int, month string) ([]model.Image, error) { tx = tx.Where("date LIKE ?", month+"%") } + if mkt != "" { + tx = tx.Where("mkt = ?", mkt) + } + tx = tx.Order("date desc").Preload("Variants") if limit > 0 { diff --git a/internal/storage/local/local.go b/internal/storage/local/local.go index 648a5f0..08d1826 100644 --- a/internal/storage/local/local.go +++ b/internal/storage/local/local.go @@ -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 +} diff --git a/internal/storage/s3/s3.go b/internal/storage/s3/s3.go index 8cb02a1..5567aaa 100644 --- a/internal/storage/s3/s3.go +++ b/internal/storage/s3/s3.go @@ -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 +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index fb62dfb..57bdd50 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -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 diff --git a/internal/storage/webdav/webdav.go b/internal/storage/webdav/webdav.go index 5fe10a5..15eb1e1 100644 --- a/internal/storage/webdav/webdav.go +++ b/internal/storage/webdav/webdav.go @@ -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 +} diff --git a/internal/util/regions.go b/internal/util/regions.go new file mode 100644 index 0000000..a89d618 --- /dev/null +++ b/internal/util/regions.go @@ -0,0 +1,35 @@ +package util + +import "golang.org/x/text/language" + +type Region struct { + Value string `json:"value"` + Label string `json:"label"` +} + +// 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: "俄罗斯"}, +} diff --git a/webapp/src/components/ui/calendar/Calendar.vue b/webapp/src/components/ui/calendar/Calendar.vue index f34eeb8..8e16e0d 100644 --- a/webapp/src/components/ui/calendar/Calendar.vue +++ b/webapp/src/components/ui/calendar/Calendar.vue @@ -2,10 +2,10 @@
+ 如果请求的地区无数据,自动回退到默认地区 +
++ 如果请求的地区无数据,尝试实时从 Bing 抓取 +
++ 勾选需要定期抓取壁纸的地区。如果不勾选任何地区,默认将只抓取 zh-CN。 +
+format
格式: jpg (默认: jpg)
mkt
+ 地区编码 (如 zh-CN, en-US, ja-JP),默认由服务器自动探测
+ format
格式 (默认: jpg)
+ mkt
+ 地区编码 (如 zh-CN, en-US, ja-JP)
+ format
格式 (默认: jpg)
+ mkt
+ 地区编码 (如 zh-CN, en-US, ja-JP)
+ date
图片日期(格式:YYYY-MM-DD)
+ mkt
+ 地区编码(如 zh-CN, en-US)
+ title
图片标题
@@ -458,24 +474,26 @@