mirror of
https://git.fightbot.fun/hxuanyu/BingPaper.git
synced 2026-02-15 08:49:33 +08:00
基本功能实现
This commit is contained in:
243
internal/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
|
||||
}
|
||||
Reference in New Issue
Block a user