mirror of
https://git.fightbot.fun/hxuanyu/BingPaper.git
synced 2026-02-15 07:19:33 +08:00
基本功能实现
This commit is contained in:
102
internal/bootstrap/bootstrap.go
Normal file
102
internal/bootstrap/bootstrap.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"BingDailyImage/internal/config"
|
||||
"BingDailyImage/internal/cron"
|
||||
"BingDailyImage/internal/http"
|
||||
"BingDailyImage/internal/repo"
|
||||
"BingDailyImage/internal/service/fetcher"
|
||||
"BingDailyImage/internal/storage"
|
||||
"BingDailyImage/internal/storage/local"
|
||||
"BingDailyImage/internal/storage/s3"
|
||||
"BingDailyImage/internal/storage/webdav"
|
||||
"BingDailyImage/internal/util"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Init 初始化应用各项服务
|
||||
func Init() *gin.Engine {
|
||||
// 0. 确保数据目录存在
|
||||
_ = os.MkdirAll("data/picture", 0755)
|
||||
|
||||
// 1. 初始化配置
|
||||
if err := config.Init(""); err != nil {
|
||||
log.Fatalf("Failed to initialize config: %v", err)
|
||||
}
|
||||
cfg := config.GetConfig()
|
||||
|
||||
// 2. 初始化日志
|
||||
util.InitLogger(cfg.Log.Level)
|
||||
|
||||
// 3. 初始化数据库
|
||||
if err := repo.InitDB(); err != nil {
|
||||
util.Logger.Fatal("Failed to initialize database")
|
||||
}
|
||||
|
||||
// 4. 初始化存储
|
||||
var s storage.Storage
|
||||
var err error
|
||||
switch cfg.Storage.Type {
|
||||
case "s3":
|
||||
s, err = s3.NewS3Storage(
|
||||
cfg.Storage.S3.Endpoint,
|
||||
cfg.Storage.S3.Region,
|
||||
cfg.Storage.S3.Bucket,
|
||||
cfg.Storage.S3.AccessKey,
|
||||
cfg.Storage.S3.SecretKey,
|
||||
cfg.Storage.S3.PublicURLPrefix,
|
||||
cfg.Storage.S3.ForcePathStyle,
|
||||
)
|
||||
case "webdav":
|
||||
s, err = webdav.NewWebDAVStorage(
|
||||
cfg.Storage.WebDAV.URL,
|
||||
cfg.Storage.WebDAV.Username,
|
||||
cfg.Storage.WebDAV.Password,
|
||||
cfg.Storage.WebDAV.PublicURLPrefix,
|
||||
)
|
||||
default: // local
|
||||
s, err = local.NewLocalStorage(cfg.Storage.Local.Root)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
util.Logger.Fatal("Failed to initialize storage", zap.Error(err))
|
||||
}
|
||||
storage.GlobalStorage = s
|
||||
|
||||
// 5. 初始化定时任务
|
||||
cron.InitCron()
|
||||
|
||||
// 6. 启动时执行一次抓取 (可选,这里我们默认执行一次以确保有数据)
|
||||
go func() {
|
||||
f := fetcher.NewFetcher()
|
||||
f.Fetch(context.Background(), config.BingFetchN)
|
||||
}()
|
||||
|
||||
// 7. 设置路由
|
||||
return http.SetupRouter()
|
||||
}
|
||||
|
||||
// LogWelcomeInfo 输出欢迎信息和快速跳转地址
|
||||
func LogWelcomeInfo() {
|
||||
cfg := config.GetConfig()
|
||||
port := cfg.Server.Port
|
||||
baseURL := cfg.Server.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = fmt.Sprintf("http://localhost:%d", port)
|
||||
}
|
||||
|
||||
fmt.Println("\n---------------------------------------------------------")
|
||||
fmt.Println(" BingDailyImage 服务已启动!")
|
||||
fmt.Printf(" - 首页地址: %s/\n", baseURL)
|
||||
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.Println("---------------------------------------------------------")
|
||||
}
|
||||
192
internal/config/config.go
Normal file
192
internal/config/config.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
API APIConfig `mapstructure:"api"`
|
||||
Cron CronConfig `mapstructure:"cron"`
|
||||
Retention RetentionConfig `mapstructure:"retention"`
|
||||
DB DBConfig `mapstructure:"db"`
|
||||
Storage StorageConfig `mapstructure:"storage"`
|
||||
Admin AdminConfig `mapstructure:"admin"`
|
||||
Token TokenConfig `mapstructure:"token"`
|
||||
Feature FeatureConfig `mapstructure:"feature"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `mapstructure:"port"`
|
||||
BaseURL string `mapstructure:"base_url"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
Level string `mapstructure:"level"`
|
||||
}
|
||||
|
||||
type APIConfig struct {
|
||||
Mode string `mapstructure:"mode"` // local | redirect
|
||||
}
|
||||
|
||||
type CronConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
DailySpec string `mapstructure:"daily_spec"`
|
||||
}
|
||||
|
||||
type RetentionConfig struct {
|
||||
Days int `mapstructure:"days"`
|
||||
}
|
||||
|
||||
type DBConfig struct {
|
||||
Type string `mapstructure:"type"` // sqlite/mysql/postgres
|
||||
DSN string `mapstructure:"dsn"`
|
||||
}
|
||||
|
||||
type StorageConfig struct {
|
||||
Type string `mapstructure:"type"` // local/s3/webdav
|
||||
Local LocalConfig `mapstructure:"local"`
|
||||
S3 S3Config `mapstructure:"s3"`
|
||||
WebDAV WebDAVConfig `mapstructure:"webdav"`
|
||||
}
|
||||
|
||||
type LocalConfig struct {
|
||||
Root string `mapstructure:"root"`
|
||||
}
|
||||
|
||||
type S3Config struct {
|
||||
Endpoint string `mapstructure:"endpoint"`
|
||||
Region string `mapstructure:"region"`
|
||||
Bucket string `mapstructure:"bucket"`
|
||||
AccessKey string `mapstructure:"access_key"`
|
||||
SecretKey string `mapstructure:"secret_key"`
|
||||
PublicURLPrefix string `mapstructure:"public_url_prefix"`
|
||||
ForcePathStyle bool `mapstructure:"force_path_style"`
|
||||
}
|
||||
|
||||
type WebDAVConfig struct {
|
||||
URL string `mapstructure:"url"`
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
PublicURLPrefix string `mapstructure:"public_url_prefix"`
|
||||
}
|
||||
|
||||
type AdminConfig struct {
|
||||
PasswordBcrypt string `mapstructure:"password_bcrypt"`
|
||||
}
|
||||
|
||||
type TokenConfig struct {
|
||||
DefaultTTL string `mapstructure:"default_ttl"`
|
||||
}
|
||||
|
||||
type FeatureConfig struct {
|
||||
WriteDailyFiles bool `mapstructure:"write_daily_files"`
|
||||
}
|
||||
|
||||
// Bing 默认配置 (内置)
|
||||
const (
|
||||
BingMkt = "zh-CN"
|
||||
BingFetchN = 8
|
||||
BingAPIBase = "https://www.bing.com/HPImageArchive.aspx"
|
||||
)
|
||||
|
||||
var (
|
||||
GlobalConfig *Config
|
||||
configLock sync.RWMutex
|
||||
v *viper.Viper
|
||||
)
|
||||
|
||||
func Init(configPath string) error {
|
||||
v = viper.New()
|
||||
if configPath != "" {
|
||||
v.SetConfigFile(configPath)
|
||||
} else {
|
||||
v.SetConfigName("config")
|
||||
v.SetConfigType("yaml")
|
||||
v.AddConfigPath("./data")
|
||||
v.AddConfigPath(".")
|
||||
}
|
||||
|
||||
v.SetDefault("server.port", 8080)
|
||||
v.SetDefault("log.level", "info")
|
||||
v.SetDefault("api.mode", "local")
|
||||
v.SetDefault("cron.enabled", true)
|
||||
v.SetDefault("cron.daily_spec", "0 10 * * *")
|
||||
v.SetDefault("retention.days", 30)
|
||||
v.SetDefault("db.type", "sqlite")
|
||||
v.SetDefault("db.dsn", "data/bing_daily_image.db")
|
||||
v.SetDefault("storage.type", "local")
|
||||
v.SetDefault("storage.local.root", "data/picture")
|
||||
v.SetDefault("token.default_ttl", "168h")
|
||||
v.SetDefault("feature.write_daily_files", true)
|
||||
v.SetDefault("admin.password_bcrypt", "$2a$10$fYHPeWHmwObephJvtlyH1O8DIgaLk5TINbi9BOezo2M8cSjmJchka") // 默认密码: admin123
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
return err
|
||||
}
|
||||
// 如果文件不存在,我们使用默认值并尝试创建一个默认配置文件
|
||||
fmt.Println("Config file not found, creating default config at ./data/config.yaml")
|
||||
if err := v.SafeWriteConfigAs("./data/config.yaml"); err != nil {
|
||||
fmt.Printf("Warning: Failed to create default config file: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := v.Unmarshal(&cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
GlobalConfig = &cfg
|
||||
|
||||
v.OnConfigChange(func(e fsnotify.Event) {
|
||||
fmt.Println("Config file changed:", e.Name)
|
||||
var newCfg Config
|
||||
if err := v.Unmarshal(&newCfg); err == nil {
|
||||
configLock.Lock()
|
||||
GlobalConfig = &newCfg
|
||||
configLock.Unlock()
|
||||
}
|
||||
})
|
||||
v.WatchConfig()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetConfig() *Config {
|
||||
configLock.RLock()
|
||||
defer configLock.RUnlock()
|
||||
return GlobalConfig
|
||||
}
|
||||
|
||||
func SaveConfig(cfg *Config) error {
|
||||
v.Set("server", cfg.Server)
|
||||
v.Set("log", cfg.Log)
|
||||
v.Set("api", cfg.API)
|
||||
v.Set("cron", cfg.Cron)
|
||||
v.Set("retention", cfg.Retention)
|
||||
v.Set("db", cfg.DB)
|
||||
v.Set("storage", cfg.Storage)
|
||||
v.Set("admin", cfg.Admin)
|
||||
v.Set("token", cfg.Token)
|
||||
v.Set("feature", cfg.Feature)
|
||||
return v.WriteConfig()
|
||||
}
|
||||
|
||||
func GetRawViper() *viper.Viper {
|
||||
return v
|
||||
}
|
||||
|
||||
func GetTokenTTL() time.Duration {
|
||||
ttl, err := time.ParseDuration(GetConfig().Token.DefaultTTL)
|
||||
if err != nil {
|
||||
return 168 * time.Hour
|
||||
}
|
||||
return ttl
|
||||
}
|
||||
21
internal/config/config_test.go
Normal file
21
internal/config/config_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
err := Init("")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to init config: %v", err)
|
||||
}
|
||||
|
||||
cfg := GetConfig()
|
||||
if cfg.Server.Port != 8080 {
|
||||
t.Errorf("Expected port 8080, got %d", cfg.Server.Port)
|
||||
}
|
||||
|
||||
if cfg.DB.Type != "sqlite" {
|
||||
t.Errorf("Expected DB type sqlite, got %s", cfg.DB.Type)
|
||||
}
|
||||
}
|
||||
47
internal/cron/cron.go
Normal file
47
internal/cron/cron.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"BingDailyImage/internal/config"
|
||||
"BingDailyImage/internal/service/fetcher"
|
||||
"BingDailyImage/internal/service/image"
|
||||
"BingDailyImage/internal/util"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var GlobalCron *cron.Cron
|
||||
|
||||
func InitCron() {
|
||||
cfg := config.GetConfig()
|
||||
if !cfg.Cron.Enabled {
|
||||
util.Logger.Info("Cron is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
c := cron.New()
|
||||
|
||||
// 每日抓取任务
|
||||
_, err := c.AddFunc(cfg.Cron.DailySpec, func() {
|
||||
util.Logger.Info("Running scheduled daily fetch")
|
||||
f := fetcher.NewFetcher()
|
||||
if err := f.Fetch(context.Background(), 1); err != nil {
|
||||
util.Logger.Error("Scheduled fetch failed", zap.Error(err))
|
||||
}
|
||||
|
||||
// 抓取后顺便清理
|
||||
if err := image.CleanupOldImages(context.Background()); err != nil {
|
||||
util.Logger.Error("Scheduled cleanup failed", zap.Error(err))
|
||||
}
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
util.Logger.Fatal("Failed to setup cron", zap.Error(err))
|
||||
}
|
||||
|
||||
c.Start()
|
||||
GlobalCron = c
|
||||
util.Logger.Info("Cron service started", zap.String("spec", cfg.Cron.DailySpec))
|
||||
}
|
||||
243
internal/http/handlers/admin.go
Normal file
243
internal/http/handlers/admin.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"BingDailyImage/internal/config"
|
||||
"BingDailyImage/internal/service/fetcher"
|
||||
"BingDailyImage/internal/service/image"
|
||||
"BingDailyImage/internal/service/token"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type LoginRequest struct {
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// AdminLogin 管理员登录
|
||||
// @Summary 管理员登录
|
||||
// @Description 使用密码登录并获取临时 Token
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body LoginRequest true "登录请求"
|
||||
// @Success 200 {object} model.Token
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /admin/login [post]
|
||||
func AdminLogin(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
t, err := token.Login(req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, t)
|
||||
}
|
||||
|
||||
// ListTokens 获取 Token 列表
|
||||
// @Summary 获取 Token 列表
|
||||
// @Description 获取所有已创建的 API Token 列表
|
||||
// @Tags admin
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Success 200 {array} model.Token
|
||||
// @Router /admin/tokens [get]
|
||||
func ListTokens(c *gin.Context) {
|
||||
tokens, err := token.ListTokens()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, tokens)
|
||||
}
|
||||
|
||||
type CreateTokenRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
ExpiresAt string `json:"expires_at"` // optional
|
||||
ExpiresIn string `json:"expires_in"` // optional, e.g. 168h
|
||||
}
|
||||
|
||||
// CreateToken 创建 Token
|
||||
// @Summary 创建 Token
|
||||
// @Description 创建一个新的 API Token
|
||||
// @Tags admin
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body CreateTokenRequest true "创建请求"
|
||||
// @Success 200 {object} model.Token
|
||||
// @Router /admin/tokens [post]
|
||||
func CreateToken(c *gin.Context) {
|
||||
var req CreateTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(config.GetTokenTTL())
|
||||
if req.ExpiresAt != "" {
|
||||
t, err := time.Parse(time.RFC3339, req.ExpiresAt)
|
||||
if err == nil {
|
||||
expiresAt = t
|
||||
}
|
||||
} else if req.ExpiresIn != "" {
|
||||
d, err := time.ParseDuration(req.ExpiresIn)
|
||||
if err == nil {
|
||||
expiresAt = time.Now().Add(d)
|
||||
}
|
||||
}
|
||||
|
||||
t, err := token.CreateToken(req.Name, expiresAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, t)
|
||||
}
|
||||
|
||||
type UpdateTokenRequest struct {
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
// UpdateToken 更新 Token 状态
|
||||
// @Summary 更新 Token 状态
|
||||
// @Description 启用或禁用指定的 API Token
|
||||
// @Tags admin
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Token ID"
|
||||
// @Param request body UpdateTokenRequest true "更新请求"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /admin/tokens/{id} [patch]
|
||||
func UpdateToken(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, _ := strconv.ParseUint(idStr, 10, 32)
|
||||
var req UpdateTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := token.UpdateToken(uint(id), req.Disabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// DeleteToken 删除 Token
|
||||
// @Summary 删除 Token
|
||||
// @Description 永久删除指定的 API Token
|
||||
// @Tags admin
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Token ID"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /admin/tokens/{id} [delete]
|
||||
func DeleteToken(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, _ := strconv.ParseUint(idStr, 10, 32)
|
||||
if err := token.DeleteToken(uint(id)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// GetConfig 获取当前配置
|
||||
// @Summary 获取当前配置
|
||||
// @Description 获取服务的当前运行配置 (脱敏)
|
||||
// @Tags admin
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Success 200 {object} config.Config
|
||||
// @Router /admin/config [get]
|
||||
func GetConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, config.GetConfig())
|
||||
}
|
||||
|
||||
// UpdateConfig 更新配置
|
||||
// @Summary 更新配置
|
||||
// @Description 在线更新服务配置并保存
|
||||
// @Tags admin
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body config.Config true "配置对象"
|
||||
// @Success 200 {object} config.Config
|
||||
// @Router /admin/config [put]
|
||||
func UpdateConfig(c *gin.Context) {
|
||||
var cfg config.Config
|
||||
if err := c.ShouldBindJSON(&cfg); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.SaveConfig(&cfg); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if c.Query("reload") == "true" {
|
||||
// 实际上 viper 会 watch config,但这里可以触发一些重新初始化逻辑
|
||||
// 这里暂不实现复杂的 reload
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, config.GetConfig())
|
||||
}
|
||||
|
||||
type ManualFetchRequest struct {
|
||||
N int `json:"n"`
|
||||
}
|
||||
|
||||
// ManualFetch 手动触发抓取
|
||||
// @Summary 手动触发抓取
|
||||
// @Description 立即启动抓取 Bing 任务
|
||||
// @Tags admin
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body ManualFetchRequest false "抓取天数"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /admin/fetch [post]
|
||||
func ManualFetch(c *gin.Context) {
|
||||
var req ManualFetchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
req.N = config.BingFetchN
|
||||
}
|
||||
if req.N <= 0 {
|
||||
req.N = config.BingFetchN
|
||||
}
|
||||
|
||||
f := fetcher.NewFetcher()
|
||||
go func() {
|
||||
f.Fetch(context.Background(), req.N)
|
||||
}()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "task started"})
|
||||
}
|
||||
|
||||
// ManualCleanup 手动触发清理
|
||||
// @Summary 手动触发清理
|
||||
// @Description 立即启动旧图片清理任务
|
||||
// @Tags admin
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /admin/cleanup [post]
|
||||
func ManualCleanup(c *gin.Context) {
|
||||
go func() {
|
||||
image.CleanupOldImages(context.Background())
|
||||
}()
|
||||
c.JSON(http.StatusOK, gin.H{"status": "task started"})
|
||||
}
|
||||
223
internal/http/handlers/image.go
Normal file
223
internal/http/handlers/image.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"BingDailyImage/internal/config"
|
||||
"BingDailyImage/internal/model"
|
||||
"BingDailyImage/internal/service/image"
|
||||
"BingDailyImage/internal/storage"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetToday 获取今日图片
|
||||
// @Summary 获取今日图片
|
||||
// @Description 根据参数返回今日必应图片流或重定向
|
||||
// @Tags image
|
||||
// @Param variant query string false "分辨率 (UHD, 1920x1080, 1366x768)" default(UHD)
|
||||
// @Param format query string false "格式 (jpg, webp)" default(jpg)
|
||||
// @Produce image/jpeg,image/webp
|
||||
// @Success 200 {file} binary
|
||||
// @Router /image/today [get]
|
||||
func GetToday(c *gin.Context) {
|
||||
img, err := image.GetTodayImage()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
handleImageResponse(c, img)
|
||||
}
|
||||
|
||||
// GetTodayMeta 获取今日图片元数据
|
||||
// @Summary 获取今日图片元数据
|
||||
// @Description 获取今日必应图片的标题、版权等元数据
|
||||
// @Tags image
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /image/today/meta [get]
|
||||
func GetTodayMeta(c *gin.Context) {
|
||||
img, err := image.GetTodayImage()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, formatMeta(img))
|
||||
}
|
||||
|
||||
// GetRandom 获取随机图片
|
||||
// @Summary 获取随机图片
|
||||
// @Description 随机返回一张已抓取的图片流或重定向
|
||||
// @Tags image
|
||||
// @Param variant query string false "分辨率" default(UHD)
|
||||
// @Param format query string false "格式" default(jpg)
|
||||
// @Produce image/jpeg,image/webp
|
||||
// @Success 200 {file} binary
|
||||
// @Router /image/random [get]
|
||||
func GetRandom(c *gin.Context) {
|
||||
img, err := image.GetRandomImage()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
handleImageResponse(c, img)
|
||||
}
|
||||
|
||||
// GetRandomMeta 获取随机图片元数据
|
||||
// @Summary 获取随机图片元数据
|
||||
// @Description 随机获取一张已抓取图片的元数据
|
||||
// @Tags image
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /image/random/meta [get]
|
||||
func GetRandomMeta(c *gin.Context) {
|
||||
img, err := image.GetRandomImage()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, formatMeta(img))
|
||||
}
|
||||
|
||||
// GetByDate 获取指定日期图片
|
||||
// @Summary 获取指定日期图片
|
||||
// @Description 根据日期返回图片流或重定向 (yyyy-mm-dd)
|
||||
// @Tags image
|
||||
// @Param date path string true "日期 (yyyy-mm-dd)"
|
||||
// @Param variant query string false "分辨率" default(UHD)
|
||||
// @Param format query string false "格式" default(jpg)
|
||||
// @Produce image/jpeg,image/webp
|
||||
// @Success 200 {file} binary
|
||||
// @Router /image/date/{date} [get]
|
||||
func GetByDate(c *gin.Context) {
|
||||
date := c.Param("date")
|
||||
img, err := image.GetImageByDate(date)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
handleImageResponse(c, img)
|
||||
}
|
||||
|
||||
// GetByDateMeta 获取指定日期图片元数据
|
||||
// @Summary 获取指定日期图片元数据
|
||||
// @Description 根据日期获取图片元数据 (yyyy-mm-dd)
|
||||
// @Tags image
|
||||
// @Param date path string true "日期 (yyyy-mm-dd)"
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /image/date/{date}/meta [get]
|
||||
func GetByDateMeta(c *gin.Context) {
|
||||
date := c.Param("date")
|
||||
img, err := image.GetImageByDate(date)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, formatMeta(img))
|
||||
}
|
||||
|
||||
// ListImages 获取图片列表
|
||||
// @Summary 获取图片列表
|
||||
// @Description 分页获取已抓取的图片元数据列表
|
||||
// @Tags image
|
||||
// @Param limit query int false "限制数量" default(30)
|
||||
// @Produce json
|
||||
// @Success 200 {array} map[string]interface{}
|
||||
// @Router /images [get]
|
||||
func ListImages(c *gin.Context) {
|
||||
limitStr := c.DefaultQuery("limit", "30")
|
||||
var limit int
|
||||
fmt.Sscanf(limitStr, "%d", &limit)
|
||||
|
||||
images, err := image.GetImageList(limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result := []gin.H{}
|
||||
for _, img := range images {
|
||||
result = append(result, formatMeta(&img))
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func handleImageResponse(c *gin.Context, img *model.Image) {
|
||||
variant := c.DefaultQuery("variant", "UHD")
|
||||
format := c.DefaultQuery("format", "jpg")
|
||||
|
||||
var selected *model.ImageVariant
|
||||
for _, v := range img.Variants {
|
||||
if v.Variant == variant && v.Format == format {
|
||||
selected = &v
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if selected == nil && len(img.Variants) > 0 {
|
||||
// 回退逻辑
|
||||
selected = &img.Variants[0]
|
||||
}
|
||||
|
||||
if selected == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "variant not found"})
|
||||
return
|
||||
}
|
||||
|
||||
mode := config.GetConfig().API.Mode
|
||||
if mode == "redirect" {
|
||||
if selected.PublicURL != "" {
|
||||
c.Redirect(http.StatusFound, selected.PublicURL)
|
||||
} else {
|
||||
// 兜底重定向到原始 Bing (如果可能,但由于 URLBase 只有一部分,这里可能不工作)
|
||||
// 这里我们更倾向于 local 转发,如果 PublicURL 为空
|
||||
serveLocal(c, selected.StorageKey)
|
||||
}
|
||||
} else {
|
||||
serveLocal(c, selected.StorageKey)
|
||||
}
|
||||
}
|
||||
|
||||
func serveLocal(c *gin.Context, key string) {
|
||||
reader, contentType, err := storage.GlobalStorage.Get(context.Background(), key)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get image"})
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
if contentType != "" {
|
||||
c.Header("Content-Type", contentType)
|
||||
}
|
||||
io.Copy(c.Writer, reader)
|
||||
}
|
||||
|
||||
func formatMeta(img *model.Image) gin.H {
|
||||
cfg := config.GetConfig()
|
||||
variants := []gin.H{}
|
||||
for _, v := range img.Variants {
|
||||
url := v.PublicURL
|
||||
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)
|
||||
}
|
||||
variants = append(variants, gin.H{
|
||||
"variant": v.Variant,
|
||||
"format": v.Format,
|
||||
"size": v.Size,
|
||||
"url": url,
|
||||
"storage_key": v.StorageKey,
|
||||
})
|
||||
}
|
||||
|
||||
return gin.H{
|
||||
"date": img.Date,
|
||||
"title": img.Title,
|
||||
"copyright": img.Copyright,
|
||||
"quiz": img.Quiz,
|
||||
"variants": variants,
|
||||
}
|
||||
}
|
||||
38
internal/http/middleware/auth.go
Normal file
38
internal/http/middleware/auth.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"BingDailyImage/internal/service/token"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if !(len(parts) == 2 && parts[0] == "Bearer") {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
t, err := token.ValidateToken(parts[1])
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("token", t)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
63
internal/http/router.go
Normal file
63
internal/http/router.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
_ "BingDailyImage/docs"
|
||||
"BingDailyImage/internal/http/handlers"
|
||||
"BingDailyImage/internal/http/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
)
|
||||
|
||||
func SetupRouter() *gin.Engine {
|
||||
r := gin.Default()
|
||||
|
||||
// Swagger
|
||||
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||
|
||||
// 静态文件
|
||||
r.Static("/static", "./static")
|
||||
r.StaticFile("/", "./web/index.html")
|
||||
r.StaticFile("/admin", "./web/index.html")
|
||||
r.StaticFile("/login", "./web/index.html")
|
||||
|
||||
api := r.Group("/api/v1")
|
||||
{
|
||||
// 公共接口
|
||||
img := api.Group("/image")
|
||||
{
|
||||
img.GET("/today", handlers.GetToday)
|
||||
img.GET("/today/meta", handlers.GetTodayMeta)
|
||||
img.GET("/random", handlers.GetRandom)
|
||||
img.GET("/random/meta", handlers.GetRandomMeta)
|
||||
img.GET("/date/:date", handlers.GetByDate)
|
||||
img.GET("/date/:date/meta", handlers.GetByDateMeta)
|
||||
}
|
||||
api.GET("/images", handlers.ListImages)
|
||||
|
||||
// 管理接口
|
||||
admin := api.Group("/admin")
|
||||
{
|
||||
admin.POST("/login", handlers.AdminLogin)
|
||||
|
||||
// 需要验证的接口
|
||||
authorized := admin.Group("/")
|
||||
authorized.Use(middleware.AuthMiddleware())
|
||||
{
|
||||
authorized.GET("/tokens", handlers.ListTokens)
|
||||
authorized.POST("/tokens", handlers.CreateToken)
|
||||
authorized.PATCH("/tokens/:id", handlers.UpdateToken)
|
||||
authorized.DELETE("/tokens/:id", handlers.DeleteToken)
|
||||
|
||||
authorized.GET("/config", handlers.GetConfig)
|
||||
authorized.PUT("/config", handlers.UpdateConfig)
|
||||
|
||||
authorized.POST("/fetch", handlers.ManualFetch)
|
||||
authorized.POST("/cleanup", handlers.ManualCleanup)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
41
internal/model/models.go
Normal file
41
internal/model/models.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Image struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Date string `gorm:"uniqueIndex;type:varchar(10)" json:"date"` // YYYY-MM-DD
|
||||
Title string `json:"title"`
|
||||
Copyright string `json:"copyright"`
|
||||
URLBase string `json:"urlbase"`
|
||||
Quiz string `json:"quiz"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
Variants []ImageVariant `gorm:"foreignKey:ImageID" json:"variants"`
|
||||
}
|
||||
|
||||
type ImageVariant struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ImageID uint `gorm:"uniqueIndex:idx_image_variant_format" json:"image_id"`
|
||||
Variant string `gorm:"uniqueIndex:idx_image_variant_format;type:varchar(20)" json:"variant"` // UHD, 1920x1080, etc.
|
||||
Format string `gorm:"uniqueIndex:idx_image_variant_format;type:varchar(10)" json:"format"` // jpg, webp
|
||||
StorageKey string `json:"storage_key"`
|
||||
PublicURL string `json:"public_url"`
|
||||
Size int64 `json:"size"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Token string `gorm:"uniqueIndex;type:varchar(64)" json:"token"`
|
||||
Name string `json:"name"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Disabled bool `gorm:"default:false" json:"disabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
51
internal/repo/db.go
Normal file
51
internal/repo/db.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"BingDailyImage/internal/config"
|
||||
"BingDailyImage/internal/model"
|
||||
"BingDailyImage/internal/util"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
func InitDB() error {
|
||||
cfg := config.GetConfig()
|
||||
var dialector gorm.Dialector
|
||||
|
||||
switch cfg.DB.Type {
|
||||
case "mysql":
|
||||
dialector = mysql.Open(cfg.DB.DSN)
|
||||
case "postgres":
|
||||
dialector = postgres.Open(cfg.DB.DSN)
|
||||
case "sqlite":
|
||||
dialector = sqlite.Open(cfg.DB.DSN)
|
||||
default:
|
||||
return fmt.Errorf("unsupported db type: %s", cfg.DB.Type)
|
||||
}
|
||||
|
||||
gormConfig := &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
}
|
||||
|
||||
db, err := gorm.Open(dialector, gormConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 迁移
|
||||
if err := db.AutoMigrate(&model.Image{}, &model.ImageVariant{}, &model.Token{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
DB = db
|
||||
util.Logger.Info("Database initialized successfully", zap.String("type", cfg.DB.Type))
|
||||
return nil
|
||||
}
|
||||
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
|
||||
}
|
||||
65
internal/storage/local/local.go
Normal file
65
internal/storage/local/local.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"BingDailyImage/internal/storage"
|
||||
)
|
||||
|
||||
type LocalStorage struct {
|
||||
root string
|
||||
}
|
||||
|
||||
func NewLocalStorage(root string) (*LocalStorage, error) {
|
||||
if err := os.MkdirAll(root, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LocalStorage{root: root}, nil
|
||||
}
|
||||
|
||||
func (l *LocalStorage) Put(ctx context.Context, key string, r io.Reader, contentType string) (storage.StoredObject, error) {
|
||||
path := filepath.Join(l.root, key)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return storage.StoredObject{}, err
|
||||
}
|
||||
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return storage.StoredObject{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
n, err := io.Copy(f, r)
|
||||
if err != nil {
|
||||
return storage.StoredObject{}, err
|
||||
}
|
||||
|
||||
return storage.StoredObject{
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
Size: n,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (l *LocalStorage) Get(ctx context.Context, key string) (io.ReadCloser, string, error) {
|
||||
path := filepath.Join(l.root, key)
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
// 这里很难从文件扩展名以外的地方获得 contentType,除非存储时记录
|
||||
// 简单处理
|
||||
return f, "", nil
|
||||
}
|
||||
|
||||
func (l *LocalStorage) Delete(ctx context.Context, key string) error {
|
||||
path := filepath.Join(l.root, key)
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
func (l *LocalStorage) PublicURL(key string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
95
internal/storage/s3/s3.go
Normal file
95
internal/storage/s3/s3.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"BingDailyImage/internal/storage"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/aws/aws-sdk-go/service/s3/s3manager"
|
||||
)
|
||||
|
||||
type S3Storage struct {
|
||||
session *session.Session
|
||||
client *s3.S3
|
||||
bucket string
|
||||
publicURLPrefix string
|
||||
}
|
||||
|
||||
func NewS3Storage(endpoint, region, bucket, accessKey, secretKey, publicURLPrefix string, forcePathStyle bool) (*S3Storage, error) {
|
||||
config := &aws.Config{
|
||||
Region: aws.String(region),
|
||||
Credentials: credentials.NewStaticCredentials(accessKey, secretKey, ""),
|
||||
Endpoint: aws.String(endpoint),
|
||||
S3ForcePathStyle: aws.Bool(forcePathStyle),
|
||||
}
|
||||
sess, err := session.NewSession(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &S3Storage{
|
||||
session: sess,
|
||||
client: s3.New(sess),
|
||||
bucket: bucket,
|
||||
publicURLPrefix: publicURLPrefix,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *S3Storage) Put(ctx context.Context, key string, r io.Reader, contentType string) (storage.StoredObject, error) {
|
||||
uploader := s3manager.NewUploader(s.session)
|
||||
output, err := uploader.UploadWithContext(ctx, &s3manager.UploadInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
Body: r,
|
||||
ContentType: aws.String(contentType),
|
||||
})
|
||||
if err != nil {
|
||||
return storage.StoredObject{}, err
|
||||
}
|
||||
|
||||
publicURL := ""
|
||||
if s.publicURLPrefix != "" {
|
||||
publicURL = fmt.Sprintf("%s/%s", strings.TrimSuffix(s.publicURLPrefix, "/"), key)
|
||||
} else {
|
||||
publicURL = output.Location
|
||||
}
|
||||
|
||||
return storage.StoredObject{
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
PublicURL: publicURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *S3Storage) Get(ctx context.Context, key string) (io.ReadCloser, string, error) {
|
||||
output, err := s.client.GetObjectWithContext(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return output.Body, aws.StringValue(output.ContentType), nil
|
||||
}
|
||||
|
||||
func (s *S3Storage) Delete(ctx context.Context, key string) error {
|
||||
_, err := s.client.DeleteObjectWithContext(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *S3Storage) PublicURL(key string) (string, bool) {
|
||||
if s.publicURLPrefix != "" {
|
||||
return fmt.Sprintf("%s/%s", strings.TrimSuffix(s.publicURLPrefix, "/"), key), true
|
||||
}
|
||||
// 也可以生成签名 URL,但这里简单处理
|
||||
return "", false
|
||||
}
|
||||
27
internal/storage/storage.go
Normal file
27
internal/storage/storage.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
)
|
||||
|
||||
type StoredObject struct {
|
||||
Key string
|
||||
ContentType string
|
||||
Size int64
|
||||
PublicURL string
|
||||
}
|
||||
|
||||
type Storage interface {
|
||||
Put(ctx context.Context, key string, r io.Reader, contentType string) (StoredObject, error)
|
||||
Get(ctx context.Context, key string) (io.ReadCloser, string, error)
|
||||
Delete(ctx context.Context, key string) error
|
||||
PublicURL(key string) (string, bool)
|
||||
}
|
||||
|
||||
var GlobalStorage Storage
|
||||
|
||||
func InitStorage() error {
|
||||
// 实际初始化在 main.go 中根据配置调用对应的初始化函数
|
||||
return nil
|
||||
}
|
||||
74
internal/storage/webdav/webdav.go
Normal file
74
internal/storage/webdav/webdav.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"BingDailyImage/internal/storage"
|
||||
|
||||
"github.com/studio-b12/gowebdav"
|
||||
)
|
||||
|
||||
type WebDAVStorage struct {
|
||||
client *gowebdav.Client
|
||||
publicURLPrefix string
|
||||
}
|
||||
|
||||
func NewWebDAVStorage(url, username, password, publicURLPrefix string) (*WebDAVStorage, error) {
|
||||
client := gowebdav.NewClient(url, username, password)
|
||||
if err := client.Connect(); err != nil {
|
||||
// 有些 webdav 不支持 Connect,我们可以忽略错误或者做简单的探测
|
||||
}
|
||||
return &WebDAVStorage{
|
||||
client: client,
|
||||
publicURLPrefix: publicURLPrefix,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *WebDAVStorage) Put(ctx context.Context, key string, r io.Reader, contentType string) (storage.StoredObject, error) {
|
||||
// 确保目录存在
|
||||
dir := path.Dir(key)
|
||||
if dir != "." && dir != "/" {
|
||||
if err := w.client.MkdirAll(dir, 0755); err != nil {
|
||||
return storage.StoredObject{}, err
|
||||
}
|
||||
}
|
||||
|
||||
err := w.client.WriteStream(key, r, 0644)
|
||||
if err != nil {
|
||||
return storage.StoredObject{}, err
|
||||
}
|
||||
|
||||
publicURL := ""
|
||||
if w.publicURLPrefix != "" {
|
||||
publicURL = fmt.Sprintf("%s/%s", strings.TrimSuffix(w.publicURLPrefix, "/"), key)
|
||||
}
|
||||
|
||||
return storage.StoredObject{
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
PublicURL: publicURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *WebDAVStorage) Get(ctx context.Context, key string) (io.ReadCloser, string, error) {
|
||||
reader, err := w.client.ReadStream(key)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return reader, "", nil
|
||||
}
|
||||
|
||||
func (w *WebDAVStorage) Delete(ctx context.Context, key string) error {
|
||||
return w.client.Remove(key)
|
||||
}
|
||||
|
||||
func (w *WebDAVStorage) PublicURL(key string) (string, bool) {
|
||||
if w.publicURLPrefix != "" {
|
||||
return fmt.Sprintf("%s/%s", strings.TrimSuffix(w.publicURLPrefix, "/"), key), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
38
internal/util/logger.go
Normal file
38
internal/util/logger.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
var Logger *zap.Logger
|
||||
|
||||
func InitLogger(level string) {
|
||||
var zapLevel zapcore.Level
|
||||
switch level {
|
||||
case "debug":
|
||||
zapLevel = zap.DebugLevel
|
||||
case "info":
|
||||
zapLevel = zap.InfoLevel
|
||||
case "warn":
|
||||
zapLevel = zap.WarnLevel
|
||||
case "error":
|
||||
zapLevel = zap.ErrorLevel
|
||||
default:
|
||||
zapLevel = zap.InfoLevel
|
||||
}
|
||||
|
||||
encoderConfig := zap.NewProductionEncoderConfig()
|
||||
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
|
||||
|
||||
core := zapcore.NewCore(
|
||||
zapcore.NewConsoleEncoder(encoderConfig),
|
||||
zapcore.AddSync(os.Stdout),
|
||||
zapLevel,
|
||||
)
|
||||
|
||||
Logger = zap.New(core, zap.AddCaller())
|
||||
}
|
||||
Reference in New Issue
Block a user