基本功能实现

This commit is contained in:
2026-01-26 21:53:34 +08:00
commit c6e5e655f9
28 changed files with 4803 additions and 0 deletions

View 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
View 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
}

View 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
View 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))
}

View 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"})
}

View 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,
}
}

View 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
View 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
View 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
View 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
}

View 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)
}

View 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
}

View 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
}

View 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
View 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
}

View 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
}

View 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
View 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())
}