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..ba45dd1 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -16,6 +16,7 @@ log: api: mode: local # local | redirect + enable_mkt_fallback: true # 当请求的地区不存在时,是否回退到默认地区 cron: enabled: true diff --git a/docs/docs.go b/docs/docs.go index 3d67f9f..ea4ffca 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", @@ -455,6 +461,12 @@ const docTemplate = `{ "name": "date", "in": "path", "required": true + }, + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" } ], "responses": { @@ -478,6 +490,12 @@ const docTemplate = `{ ], "summary": "获取随机图片", "parameters": [ + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + }, { "type": "string", "default": "UHD", @@ -513,6 +531,14 @@ const docTemplate = `{ "image" ], "summary": "获取随机图片元数据", + "parameters": [ + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + } + ], "responses": { "200": { "description": "OK", @@ -534,6 +560,12 @@ const docTemplate = `{ ], "summary": "获取今日图片", "parameters": [ + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + }, { "type": "string", "default": "UHD", @@ -569,6 +601,14 @@ const docTemplate = `{ "image" ], "summary": "获取今日图片元数据", + "parameters": [ + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + } + ], "responses": { "200": { "description": "OK", @@ -614,6 +654,12 @@ const docTemplate = `{ "description": "按月份过滤 (格式: YYYY-MM)", "name": "month", "in": "query" + }, + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" } ], "responses": { @@ -628,12 +674,39 @@ const docTemplate = `{ } } } + }, + "/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" + }, "mode": { "description": "local | redirect", "type": "string" @@ -666,6 +739,9 @@ const docTemplate = `{ "feature": { "$ref": "#/definitions/config.FeatureConfig" }, + "fetcher": { + "$ref": "#/definitions/config.FetcherConfig" + }, "log": { "$ref": "#/definitions/config.LogConfig" }, @@ -717,6 +793,17 @@ const docTemplate = `{ } } }, + "config.FetcherConfig": { + "type": "object", + "properties": { + "regions": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "config.LocalConfig": { "type": "object", "properties": { @@ -917,6 +1004,9 @@ const docTemplate = `{ "hsh": { "type": "string" }, + "mkt": { + "type": "string" + }, "quiz": { "type": "string" }, @@ -1006,6 +1096,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..72ef2d0 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", @@ -449,6 +455,12 @@ "name": "date", "in": "path", "required": true + }, + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" } ], "responses": { @@ -472,6 +484,12 @@ ], "summary": "获取随机图片", "parameters": [ + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + }, { "type": "string", "default": "UHD", @@ -507,6 +525,14 @@ "image" ], "summary": "获取随机图片元数据", + "parameters": [ + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + } + ], "responses": { "200": { "description": "OK", @@ -528,6 +554,12 @@ ], "summary": "获取今日图片", "parameters": [ + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + }, { "type": "string", "default": "UHD", @@ -563,6 +595,14 @@ "image" ], "summary": "获取今日图片元数据", + "parameters": [ + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + } + ], "responses": { "200": { "description": "OK", @@ -608,6 +648,12 @@ "description": "按月份过滤 (格式: YYYY-MM)", "name": "month", "in": "query" + }, + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" } ], "responses": { @@ -622,12 +668,39 @@ } } } + }, + "/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" + }, "mode": { "description": "local | redirect", "type": "string" @@ -660,6 +733,9 @@ "feature": { "$ref": "#/definitions/config.FeatureConfig" }, + "fetcher": { + "$ref": "#/definitions/config.FetcherConfig" + }, "log": { "$ref": "#/definitions/config.LogConfig" }, @@ -711,6 +787,17 @@ } } }, + "config.FetcherConfig": { + "type": "object", + "properties": { + "regions": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "config.LocalConfig": { "type": "object", "properties": { @@ -911,6 +998,9 @@ "hsh": { "type": "string" }, + "mkt": { + "type": "string" + }, "quiz": { "type": "string" }, @@ -1000,6 +1090,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..14a2d67 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2,6 +2,9 @@ basePath: /api/v1 definitions: config.APIConfig: properties: + enableMktFallback: + description: 当请求的地区不存在时,是否回退到默认地区 + type: boolean mode: description: local | redirect type: string @@ -23,6 +26,8 @@ definitions: $ref: '#/definitions/config.DBConfig' feature: $ref: '#/definitions/config.FeatureConfig' + fetcher: + $ref: '#/definitions/config.FetcherConfig' log: $ref: '#/definitions/config.LogConfig' retention: @@ -56,6 +61,13 @@ definitions: writeDailyFiles: type: boolean type: object + config.FetcherConfig: + properties: + regions: + items: + type: string + type: array + type: object config.LocalConfig: properties: root: @@ -190,6 +202,8 @@ definitions: type: string hsh: type: string + mkt: + type: string quiz: type: string startdate: @@ -248,6 +262,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 +522,10 @@ paths: name: date required: true type: string + - description: 地区编码 (如 zh-CN, en-US) + in: query + name: mkt + type: string - default: UHD description: 分辨率 in: query @@ -530,6 +555,10 @@ paths: name: date required: true type: string + - description: 地区编码 (如 zh-CN, en-US) + in: query + name: mkt + type: string produces: - application/json responses: @@ -544,6 +573,10 @@ paths: get: description: 随机返回一张已抓取的图片流或重定向 parameters: + - description: 地区编码 (如 zh-CN, en-US) + in: query + name: mkt + type: string - default: UHD description: 分辨率 in: query @@ -567,6 +600,11 @@ paths: /image/random/meta: get: description: 随机获取一张已抓取图片的元数据 + parameters: + - description: 地区编码 (如 zh-CN, en-US) + in: query + name: mkt + type: string produces: - application/json responses: @@ -581,6 +619,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) @@ -605,6 +647,11 @@ paths: /image/today/meta: get: description: 获取今日必应图片的标题、版权等元数据 + parameters: + - description: 地区编码 (如 zh-CN, en-US) + in: query + name: mkt + type: string produces: - application/json responses: @@ -637,6 +684,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 +700,21 @@ paths: 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..cb39215 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,8 @@ 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"` // 当请求的地区不存在时,是否回退到默认地区 } type CronConfig struct { @@ -118,6 +122,10 @@ type WebConfig struct { Path string `mapstructure:"path" yaml:"path"` } +type FetcherConfig struct { + Regions []string `mapstructure:"regions" yaml:"regions"` +} + // Bing 默认配置 (内置) const ( BingMkt = "zh-CN" @@ -157,6 +165,7 @@ func Init(configPath string) error { v.SetDefault("log.show_db_log", false) v.SetDefault("log.db_log_level", "info") v.SetDefault("api.mode", "local") + v.SetDefault("api.enable_mkt_fallback", true) v.SetDefault("cron.enabled", true) v.SetDefault("cron.daily_spec", "20 8-23/4 * * *") v.SetDefault("retention.days", 0) @@ -167,6 +176,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 +327,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..9fe58cf 100644 --- a/internal/http/handlers/image.go +++ b/internal/http/handlers/image.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "sort" "strconv" "BingPaper/internal/config" @@ -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,13 +43,15 @@ 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 // @Router /image/today [get] func GetToday(c *gin.Context) { - img, err := image.GetTodayImage() + mkt := c.Query("mkt") + img, err := image.GetTodayImage(mkt) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return @@ -59,11 +63,13 @@ func GetToday(c *gin.Context) { // @Summary 获取今日图片元数据 // @Description 获取今日必应图片的标题、版权等元数据 // @Tags image +// @Param mkt query string false "地区编码 (如 zh-CN, en-US)" // @Produce json // @Success 200 {object} ImageMetaResp // @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 != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return @@ -76,13 +82,15 @@ 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 // @Router /image/random [get] func GetRandom(c *gin.Context) { - img, err := image.GetRandomImage() + mkt := c.Query("mkt") + img, err := image.GetRandomImage(mkt) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return @@ -94,11 +102,13 @@ func GetRandom(c *gin.Context) { // @Summary 获取随机图片元数据 // @Description 随机获取一张已抓取图片的元数据 // @Tags image +// @Param mkt query string false "地区编码 (如 zh-CN, en-US)" // @Produce json // @Success 200 {object} ImageMetaResp // @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 != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return @@ -112,6 +122,7 @@ 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 @@ -119,7 +130,8 @@ func GetRandomMeta(c *gin.Context) { // @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 != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return @@ -132,12 +144,14 @@ 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 // @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 != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return @@ -154,6 +168,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 +177,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 +209,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()}) @@ -291,7 +308,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 +321,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 +332,46 @@ 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 + + // 创建副本以避免修改原始全局变量 + 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 // 保持非置顶地区的原有相对顺序 + }) + } + + c.JSON(http.StatusOK, all) +} diff --git a/internal/http/handlers/image_test.go b/internal/http/handlers/image_test.go index ac1d48a..f10d98b 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" @@ -66,3 +67,27 @@ func TestHandleImageResponseRedirect(t *testing.T) { assert.Contains(t, variants[0]["url"].(string), "/api/v1/image/date/") }) } + +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..dce749f 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -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("/regions", handlers.GetRegions) // 管理接口 admin := api.Group("/admin") diff --git a/internal/model/models.go b/internal/model/models.go index 614e07d..d56fbd1 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;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. 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..4029d95 100644 --- a/internal/service/fetcher/fetcher.go +++ b/internal/service/fetcher/fetcher.go @@ -55,7 +55,30 @@ 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 { + 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)) + } + } + + util.Logger.Info("Fetch task completed") + 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,29 +93,28 @@ 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.Info("Image already exists, 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)) + util.Logger.Info("Processing new image", zap.String("date", dateStr), zap.String("mkt", mkt), zap.String("title", bingImg.Title)) // UHD 探测 imgURL, variantName := f.probeUHD(bingImg.URLBase) @@ -113,6 +135,7 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage) error { // 创建 DB 记录 dbImg := model.Image{ Date: dateStr, + Mkt: mkt, Title: bingImg.Title, Copyright: bingImg.Copyright, CopyrightLink: bingImg.CopyrightLink, @@ -124,7 +147,7 @@ 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)) @@ -134,7 +157,7 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage) error { // 再次检查 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 } @@ -188,7 +211,7 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage) error { // 保存今日额外文件 today := time.Now().Format("2006-01-02") if dateStr == today && config.GetConfig().Feature.WriteDailyFiles { - f.saveDailyFiles(srcImg, imgData) + f.saveDailyFiles(srcImg, imgData, mkt) } return nil @@ -213,7 +236,7 @@ func (f *Fetcher) downloadImage(url string) ([]byte, error) { } 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) + key := fmt.Sprintf("%s/%s/%s_%s.%s", img.Mkt, img.Date, img.Date, variant, format) contentType := "image/jpeg" if format == "webp" { contentType = "image/webp" @@ -239,20 +262,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 +286,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..4ed11ce 100644 --- a/internal/service/image/image_service.go +++ b/internal/service/image/image_service.go @@ -50,43 +50,97 @@ 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") 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 { // 如果今天没有,尝试获取最近的一张 - err = repo.DB.Order("date desc").Preload("Variants").First(&img).Error + 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() + if mkt != defaultMkt { + return GetTodayImage(defaultMkt) + } + return GetTodayImage("") + } + return &img, err } -func GetRandomImage() (*model.Image, error) { +func GetRandomImage(mkt string) (*model.Image, error) { 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 { return nil, fmt.Errorf("no images found") } // 这种方法不适合海量数据,但对于 30 天的数据没问题 - err := repo.DB.Order("RANDOM()").Preload("Variants").First(&img).Error + tx = repo.DB.Order("RANDOM()") + if mkt != "" { + tx = tx.Where("mkt = ?", mkt) + } + err := tx.Preload("Variants").First(&img).Error if err != nil { // 适配 MySQL - err = repo.DB.Order("RAND()").Preload("Variants").First(&img).Error + tx = repo.DB.Order("RAND()") + if mkt != "" { + tx = tx.Where("mkt = ?", mkt) + } + err = tx.Preload("Variants").First(&img).Error } + + // 兜底逻辑 + if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback { + defaultMkt := config.GetConfig().GetDefaultMkt() + if mkt != defaultMkt { + return GetRandomImage(defaultMkt) + } + return GetRandomImage("") + } + return &img, err } -func GetImageByDate(date string) (*model.Image, error) { +func GetImageByDate(date string, mkt string) (*model.Image, error) { var img model.Image - err := repo.DB.Where("date = ?", date).Preload("Variants").First(&img).Error + 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.EnableMktFallback { + defaultMkt := config.GetConfig().GetDefaultMkt() + if mkt != defaultMkt { + return GetImageByDate(date, defaultMkt) + } + return GetImageByDate(date, "") + } + 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) { var images []model.Image tx := repo.DB.Model(&model.Image{}) @@ -97,6 +151,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/util/regions.go b/internal/util/regions.go new file mode 100644 index 0000000..75ea1a3 --- /dev/null +++ b/internal/util/regions.go @@ -0,0 +1,26 @@ +package util + +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)"}, +} 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 @@
+ 如果请求的地区无数据,自动回退到默认地区 +
@@ -372,6 +382,31 @@ + ++ 勾选需要定期抓取壁纸的地区。如果不勾选任何地区,默认将只抓取 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 @@