mirror of
https://git.fightbot.fun/hxuanyu/BingPaper.git
synced 2026-02-15 08:49:33 +08:00
基本功能实现
This commit is contained in:
243
internal/service/fetcher/fetcher.go
Normal file
243
internal/service/fetcher/fetcher.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package fetcher
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"BingDailyImage/internal/config"
|
||||
"BingDailyImage/internal/model"
|
||||
"BingDailyImage/internal/repo"
|
||||
"BingDailyImage/internal/storage"
|
||||
"BingDailyImage/internal/util"
|
||||
|
||||
"github.com/chai2010/webp"
|
||||
"github.com/disintegration/imaging"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type BingResponse struct {
|
||||
Images []BingImage `json:"images"`
|
||||
}
|
||||
|
||||
type BingImage struct {
|
||||
Startdate string `json:"startdate"`
|
||||
Fullstartdate string `json:"fullstartdate"`
|
||||
Enddate string `json:"enddate"`
|
||||
URL string `json:"url"`
|
||||
URLBase string `json:"urlbase"`
|
||||
Copyright string `json:"copyright"`
|
||||
CopyrightLink string `json:"copyrightlink"`
|
||||
Title string `json:"title"`
|
||||
Quiz string `json:"quiz"`
|
||||
}
|
||||
|
||||
type Fetcher struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewFetcher() *Fetcher {
|
||||
return &Fetcher{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
resp, err := f.httpClient.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var bingResp BingResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&bingResp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
util.Logger.Info("Fetch task completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage) 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))
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
// 解码图片用于缩放
|
||||
srcImg, _, err := image.Decode(bytes.NewReader(imgData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建 DB 记录
|
||||
dbImg := model.Image{
|
||||
Date: dateStr,
|
||||
Title: bingImg.Title,
|
||||
Copyright: bingImg.Copyright,
|
||||
URLBase: bingImg.URLBase,
|
||||
Quiz: bingImg.Quiz,
|
||||
}
|
||||
|
||||
if err := repo.DB.Create(&dbImg).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 保存各种分辨率
|
||||
variants := []struct {
|
||||
name string
|
||||
width int
|
||||
height int
|
||||
}{
|
||||
{variantName, 0, 0}, // 原图 (UHD 或 1080p)
|
||||
{"1920x1080", 1920, 1080},
|
||||
{"1366x768", 1366, 768},
|
||||
}
|
||||
|
||||
for _, v := range variants {
|
||||
// 如果是探测到的最高清版本,且我们已经有了数据,直接使用
|
||||
var currentImgData []byte
|
||||
if v.width == 0 {
|
||||
currentImgData = imgData
|
||||
} else {
|
||||
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: 90}); 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))
|
||||
}
|
||||
|
||||
// 保存 WebP (可选或默认)
|
||||
webpBuf := new(bytes.Buffer)
|
||||
var webpImg image.Image
|
||||
if v.width == 0 {
|
||||
webpImg = srcImg
|
||||
} else {
|
||||
webpImg = imaging.Fill(srcImg, v.width, v.height, imaging.Center, imaging.Lanczos)
|
||||
}
|
||||
if err := webp.Encode(webpBuf, webpImg, &webp.Options{Quality: 80}); err == nil {
|
||||
if err := f.saveVariant(ctx, &dbImg, v.name, "webp", webpBuf.Bytes()); err != nil {
|
||||
util.Logger.Error("Failed to save webp 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)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
if err == nil && resp.StatusCode == http.StatusOK {
|
||||
return uhdURL, "UHD"
|
||||
}
|
||||
return fmt.Sprintf("https://www.bing.com%s_1920x1080.jpg", urlBase), "1920x1080"
|
||||
}
|
||||
|
||||
func (f *Fetcher) downloadImage(url string) ([]byte, error) {
|
||||
resp, err := f.httpClient.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
vRecord := model.ImageVariant{
|
||||
ImageID: img.ID,
|
||||
Variant: variant,
|
||||
Format: format,
|
||||
StorageKey: stored.Key,
|
||||
PublicURL: stored.PublicURL,
|
||||
Size: int64(len(data)),
|
||||
}
|
||||
|
||||
return repo.DB.Create(&vRecord).Error
|
||||
}
|
||||
|
||||
func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte) {
|
||||
util.Logger.Info("Saving daily files")
|
||||
localRoot := config.GetConfig().Storage.Local.Root
|
||||
if config.GetConfig().Storage.Type != "local" {
|
||||
// 如果不是本地存储,保存在临时目录或指定缓存目录
|
||||
localRoot = "static"
|
||||
}
|
||||
os.MkdirAll(filepath.Join(localRoot, "static"), 0755)
|
||||
|
||||
// daily.webp (quality 80)
|
||||
webpPath := filepath.Join(localRoot, "static", "daily.webp")
|
||||
fWebp, _ := os.Create(webpPath)
|
||||
if fWebp != nil {
|
||||
webp.Encode(fWebp, srcImg, &webp.Options{Quality: 80})
|
||||
fWebp.Close()
|
||||
}
|
||||
|
||||
// daily.jpeg (quality 95)
|
||||
jpegPath := filepath.Join(localRoot, "static", "daily.jpeg")
|
||||
fJpeg, _ := os.Create(jpegPath)
|
||||
if fJpeg != nil {
|
||||
jpeg.Encode(fJpeg, srcImg, &jpeg.Options{Quality: 95})
|
||||
fJpeg.Close()
|
||||
}
|
||||
|
||||
// original.jpeg (quality 100)
|
||||
originalPath := filepath.Join(localRoot, "static", "original.jpeg")
|
||||
os.WriteFile(originalPath, originalData, 0644)
|
||||
}
|
||||
92
internal/service/image/image_service.go
Normal file
92
internal/service/image/image_service.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"BingDailyImage/internal/config"
|
||||
"BingDailyImage/internal/model"
|
||||
"BingDailyImage/internal/repo"
|
||||
"BingDailyImage/internal/storage"
|
||||
"BingDailyImage/internal/util"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func CleanupOldImages(ctx context.Context) error {
|
||||
days := config.GetConfig().Retention.Days
|
||||
if days <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
threshold := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
|
||||
util.Logger.Info("Starting cleanup task", zap.Int("retention_days", days), zap.String("threshold", threshold))
|
||||
|
||||
var images []model.Image
|
||||
if err := repo.DB.Where("date < ?", threshold).Preload("Variants").Find(&images).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, img := range images {
|
||||
util.Logger.Info("Deleting old image", zap.String("date", img.Date))
|
||||
for _, v := range img.Variants {
|
||||
if err := storage.GlobalStorage.Delete(ctx, v.StorageKey); err != nil {
|
||||
util.Logger.Warn("Failed to delete storage object", zap.String("key", v.StorageKey), zap.Error(err))
|
||||
}
|
||||
}
|
||||
// 删除 DB 记录 (级联删除由代码处理,或者 GORM 会处理已加载的关联吗?)
|
||||
// 简单起见,手动删除关联
|
||||
repo.DB.Where("image_id = ?", img.ID).Delete(&model.ImageVariant{})
|
||||
repo.DB.Delete(&img)
|
||||
}
|
||||
|
||||
util.Logger.Info("Cleanup task completed", zap.Int("deleted_count", len(images)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetTodayImage() (*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
|
||||
if err != nil {
|
||||
// 如果今天没有,尝试获取最近的一张
|
||||
err = repo.DB.Order("date desc").Preload("Variants").First(&img).Error
|
||||
}
|
||||
return &img, err
|
||||
}
|
||||
|
||||
func GetRandomImage() (*model.Image, error) {
|
||||
var img model.Image
|
||||
// SQLite 使用 RANDOM(), MySQL/Postgres 使用 RANDOM() 或 RAND()
|
||||
// 简单起见,先查总数再 Offset
|
||||
var count int64
|
||||
repo.DB.Model(&model.Image{}).Count(&count)
|
||||
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
|
||||
}
|
||||
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) ([]model.Image, error) {
|
||||
var images []model.Image
|
||||
db := repo.DB.Order("date desc").Preload("Variants")
|
||||
if limit > 0 {
|
||||
db = db.Limit(limit)
|
||||
}
|
||||
err := db.Find(&images).Error
|
||||
return images, err
|
||||
}
|
||||
69
internal/service/token/token_service.go
Normal file
69
internal/service/token/token_service.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package token
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"BingDailyImage/internal/config"
|
||||
"BingDailyImage/internal/model"
|
||||
"BingDailyImage/internal/repo"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func GenerateTokenString() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func CreateToken(name string, expiresAt time.Time) (*model.Token, error) {
|
||||
tString := GenerateTokenString()
|
||||
t := &model.Token{
|
||||
Token: tString,
|
||||
Name: name,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
if err := repo.DB.Create(t).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func ValidateToken(tokenStr string) (*model.Token, error) {
|
||||
var t model.Token
|
||||
if err := repo.DB.Where("token = ? AND disabled = ?", tokenStr, false).First(&t).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if time.Now().After(t.ExpiresAt) {
|
||||
return nil, errors.New("token expired")
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func Login(password string) (*model.Token, error) {
|
||||
cfg := config.GetConfig()
|
||||
err := bcrypt.CompareHashAndPassword([]byte(cfg.Admin.PasswordBcrypt), []byte(password))
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid password")
|
||||
}
|
||||
|
||||
ttl := config.GetTokenTTL()
|
||||
return CreateToken("login-token", time.Now().Add(ttl))
|
||||
}
|
||||
|
||||
func ListTokens() ([]model.Token, error) {
|
||||
var tokens []model.Token
|
||||
err := repo.DB.Order("id desc").Find(&tokens).Error
|
||||
return tokens, err
|
||||
}
|
||||
|
||||
func UpdateToken(id uint, disabled bool) error {
|
||||
return repo.DB.Model(&model.Token{}).Where("id = ?", id).Update("disabled", disabled).Error
|
||||
}
|
||||
|
||||
func DeleteToken(id uint) error {
|
||||
return repo.DB.Delete(&model.Token{}, id).Error
|
||||
}
|
||||
Reference in New Issue
Block a user