mirror of
https://git.fightbot.fun/hxuanyu/BingPaper.git
synced 2026-02-15 07:19:33 +08:00
国家地区接口优化
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
// 管理接口
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: "中国台湾"},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user