基本功能实现

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