国家地区接口优化

This commit is contained in:
2026-01-30 15:45:55 +08:00
parent 6868a67ed7
commit 8ef66b2cb1
16 changed files with 491 additions and 95 deletions

View File

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

View File

@@ -5,8 +5,8 @@ import (
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"BingPaper/internal/config"
"BingPaper/internal/model"
@@ -48,12 +48,13 @@ type ImageMetaResp struct {
// @Param format query string false "格式 (jpg)" default(jpg)
// @Produce image/jpeg
// @Success 200 {file} binary
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/today [get]
func GetToday(c *gin.Context) {
mkt := c.Query("mkt")
img, err := image.GetTodayImage(mkt)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
sendImageNotFound(c, mkt)
return
}
handleImageResponse(c, img, 7200) // 2小时
@@ -66,12 +67,13 @@ func GetToday(c *gin.Context) {
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
// @Produce json
// @Success 200 {object} ImageMetaResp
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/today/meta [get]
func GetTodayMeta(c *gin.Context) {
mkt := c.Query("mkt")
img, err := image.GetTodayImage(mkt)
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小时
@@ -87,12 +89,13 @@ func GetTodayMeta(c *gin.Context) {
// @Param format query string false "格式" default(jpg)
// @Produce image/jpeg
// @Success 200 {file} binary
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/random [get]
func GetRandom(c *gin.Context) {
mkt := c.Query("mkt")
img, err := image.GetRandomImage(mkt)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
sendImageNotFound(c, mkt)
return
}
handleImageResponse(c, img, 0) // 禁用缓存
@@ -105,12 +108,13 @@ func GetRandom(c *gin.Context) {
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
// @Produce json
// @Success 200 {object} ImageMetaResp
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/random/meta [get]
func GetRandomMeta(c *gin.Context) {
mkt := c.Query("mkt")
img, err := image.GetRandomImage(mkt)
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")
@@ -127,13 +131,14 @@ func GetRandomMeta(c *gin.Context) {
// @Param format query string false "格式" default(jpg)
// @Produce image/jpeg
// @Success 200 {file} binary
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/date/{date} [get]
func GetByDate(c *gin.Context) {
date := c.Param("date")
mkt := c.Query("mkt")
img, err := image.GetImageByDate(date, mkt)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
sendImageNotFound(c, mkt)
return
}
handleImageResponse(c, img, 604800) // 7天
@@ -147,13 +152,14 @@ func GetByDate(c *gin.Context) {
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
// @Produce json
// @Success 200 {object} ImageMetaResp
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/date/{date}/meta [get]
func GetByDateMeta(c *gin.Context) {
date := c.Param("date")
mkt := c.Query("mkt")
img, err := image.GetImageByDate(date, mkt)
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天
@@ -223,6 +229,55 @@ func ListImages(c *gin.Context) {
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")
@@ -387,34 +442,51 @@ func GetRegions(c *gin.Context) {
cfg := config.GetConfig()
pinned := cfg.Fetcher.Regions
// 创建副本以避免修改原始全局变量
all := make([]util.Region, len(util.AllRegions))
copy(all, util.AllRegions)
if len(pinned) > 0 {
// 创建一个 Map 用于快速查找置顶地区及其顺序
pinnedMap := make(map[string]int)
for i, v := range pinned {
pinnedMap[v] = i
}
// 对列表进行稳定排序,使置顶地区排在前面
sort.SliceStable(all, func(i, j int) bool {
idxI, okI := pinnedMap[all[i].Value]
idxJ, okJ := pinnedMap[all[j].Value]
if okI && okJ {
return idxI < idxJ
}
if okI {
return true
}
if okJ {
return false
}
return false // 保持非置顶地区的原有相对顺序
})
if len(pinned) == 0 {
// 如果没有配置抓取地区,返回所有支持的地区
c.JSON(http.StatusOK, util.AllRegions)
return
}
c.JSON(http.StatusOK, all)
// 创建一个 Map 用于快速查找配置的地区
pinnedMap := make(map[string]bool)
for _, v := range pinned {
pinnedMap[v] = true
}
// 只返回配置中的地区,并保持配置中的顺序
var result []util.Region
// 为了保持配置顺序,我们遍历 pinned 而不是 AllRegions
for _, pVal := range pinned {
for _, r := range util.AllRegions {
if r.Value == pVal {
result = append(result, r)
break
}
}
}
// 如果配置了一些不在 AllRegions 里的 mkt上述循环可能漏掉
// 但根据之前的逻辑AllRegions 是已知的 17 个地区。
// 如果用户配置了 fr-CA (不在 17 个内),我们也应该返回它吗?
// 需求说 "前端页面对地区进行约束",如果配置了,前端就该显示。
// 如果不在 AllRegions 里的,我们直接返回原始编码作为 label 或者查找一下。
if len(result) < len(pinned) {
// 补全不在 AllRegions 里的地区
for _, pVal := range pinned {
found := false
for _, r := range result {
if r.Value == pVal {
found = true
break
}
}
if !found {
result = append(result, util.Region{Value: pVal, Label: pVal})
}
}
}
c.JSON(http.StatusOK, result)
}

View File

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

View File

@@ -61,15 +61,8 @@ func (f *Fetcher) Fetch(ctx context.Context, n int) error {
}
for _, mkt := range regions {
util.Logger.Info("Fetching images for region", zap.String("mkt", mkt))
// 调用两次 API 获取最多两周的数据
// 第一次 idx=0&n=8 (今天起往回数 8 张)
if err := f.fetchByMkt(ctx, mkt, 0, 8); err != nil {
util.Logger.Error("Failed to fetch images", zap.String("mkt", mkt), zap.Int("idx", 0), zap.Error(err))
}
// 第二次 idx=7&n=8 (7天前起往回数 8 张,与第一次有重叠,确保不漏)
if err := f.fetchByMkt(ctx, mkt, 7, 8); err != nil {
util.Logger.Error("Failed to fetch images", zap.String("mkt", mkt), zap.Int("idx", 7), zap.Error(err))
if err := f.FetchRegion(ctx, mkt); err != nil {
util.Logger.Error("Failed to fetch region images", zap.String("mkt", mkt), zap.Error(err))
}
}
@@ -77,6 +70,27 @@ func (f *Fetcher) Fetch(ctx context.Context, n int) error {
return nil
}
// FetchRegion 抓取指定地区的图片
func (f *Fetcher) FetchRegion(ctx context.Context, mkt string) error {
if !util.IsValidRegion(mkt) {
util.Logger.Warn("Skipping fetch for invalid region", zap.String("mkt", mkt))
return fmt.Errorf("invalid region code: %s", mkt)
}
util.Logger.Info("Fetching images for region", zap.String("mkt", mkt))
// 调用两次 API 获取最多两周的数据
// 第一次 idx=0&n=8 (今天起往回数 8 张)
if err := f.fetchByMkt(ctx, mkt, 0, 8); err != nil {
util.Logger.Error("Failed to fetch images", zap.String("mkt", mkt), zap.Int("idx", 0), zap.Error(err))
return err
}
// 第二次 idx=7&n=8 (7天前起往回数 8 张,与第一次有重叠,确保不漏)
if err := f.fetchByMkt(ctx, mkt, 7, 8); err != nil {
util.Logger.Error("Failed to fetch images", zap.String("mkt", mkt), zap.Int("idx", 7), zap.Error(err))
// 第二次失败不一定返回错误,因为可能第一次已经拿到了
}
return nil
}
func (f *Fetcher) fetchByMkt(ctx context.Context, mkt string, idx int, n int) error {
url := fmt.Sprintf("%s?format=js&idx=%d&n=%d&uhd=1&mkt=%s", config.BingAPIBase, idx, n, mkt)
util.Logger.Debug("Requesting Bing API", zap.String("url", url))

View File

@@ -8,6 +8,7 @@ import (
"BingPaper/internal/config"
"BingPaper/internal/model"
"BingPaper/internal/repo"
"BingPaper/internal/service/fetcher"
"BingPaper/internal/storage"
"BingPaper/internal/util"
@@ -58,8 +59,19 @@ func GetTodayImage(mkt string) (*model.Image, error) {
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, attempting on-demand fetch", zap.String("mkt", mkt))
f := fetcher.NewFetcher()
_ = f.FetchRegion(context.Background(), mkt)
// 抓取后重新查询
tx = repo.DB.Where("date = ?", today).Where("mkt = ?", mkt)
err = tx.Preload("Variants").First(&img).Error
}
if err != nil {
// 如果今天没有,尝试获取最近的一张
// 如果今天还是没有,尝试获取最近的一张
tx = repo.DB.Order("date desc")
if mkt != "" {
tx = tx.Where("mkt = ?", mkt)
@@ -79,6 +91,22 @@ func GetTodayImage(mkt string) (*model.Image, error) {
return &img, err
}
func GetAllRegionsTodayImages() ([]model.Image, error) {
regions := config.GetConfig().Fetcher.Regions
if len(regions) == 0 {
regions = []string{config.GetConfig().GetDefaultMkt()}
}
var images []model.Image
for _, mkt := range regions {
img, err := GetTodayImage(mkt)
if err == nil {
images = append(images, *img)
}
}
return images, nil
}
func GetRandomImage(mkt string) (*model.Image, error) {
var img model.Image
// SQLite 使用 RANDOM(), MySQL/Postgres 使用 RANDOM() 或 RAND()
@@ -89,6 +117,17 @@ func GetRandomImage(mkt string) (*model.Image, error) {
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, attempting on-demand fetch", zap.String("mkt", mkt))
f := fetcher.NewFetcher()
_ = f.FetchRegion(context.Background(), mkt)
// 抓取后重新计数
tx = repo.DB.Model(&model.Image{}).Where("mkt = ?", mkt)
tx.Count(&count)
}
if count == 0 {
return nil, fmt.Errorf("no images found")
}
@@ -127,6 +166,16 @@ func GetImageByDate(date string, mkt string) (*model.Image, error) {
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, attempting on-demand fetch", zap.String("mkt", mkt), zap.String("date", date))
f := fetcher.NewFetcher()
_ = f.FetchRegion(context.Background(), mkt)
// 抓取后重新查询
tx = repo.DB.Where("date = ?", date).Where("mkt = ?", mkt)
err = tx.Preload("Variants").First(&img).Error
}
// 兜底逻辑
if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback {

View File

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