Build and Publish Server / build (push) Successful in 4m30s
- 新增商业化模块:Stripe/Binance Pay 支付、邮箱登录、订单管理 - 新增数据模型:ReportProduct, ReportEntitlement, ReportSnapshot, PaymentEvent, EmailLoginCode - 新增 API:/api/commerce/checkout, /api/commerce/webhooks/*, /api/commerce/orders/* - 新增管理后台:订单列表、统计图表、统一 AdminHeader 组件 - 新增中间件测试:RateLimit, Auth, CORS 共 7 个测试用例 - 新增 AGENTS.md 项目代理指令文件 - 新增 REMINDER.md 商业化功能开发规范 - 修复 JWT 解析:添加 WithValidMethods 限制 HS256 - 修复 GetPayStatus:增加用户权限校验 - 更新 .gitignore 和 README.md
180 lines
4.5 KiB
Go
180 lines
4.5 KiB
Go
package middleware
|
||
|
||
import (
|
||
"net/http"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/gouki/lunar-server/internal/config"
|
||
"github.com/gouki/lunar-server/internal/service"
|
||
)
|
||
|
||
// CORS 跨域中间件;可通过 CORS_ORIGINS 配置允许的源(逗号分隔),未配置时保持 *
|
||
func CORS() gin.HandlerFunc {
|
||
cfg := config.Load()
|
||
origins := strings.TrimSpace(cfg.Server.CORSOrigins)
|
||
allowAll := origins == ""
|
||
allowed := map[string]bool{}
|
||
if !allowAll {
|
||
for _, o := range strings.Split(origins, ",") {
|
||
o = strings.TrimSpace(o)
|
||
if o != "" {
|
||
allowed[o] = true
|
||
}
|
||
}
|
||
}
|
||
|
||
return func(c *gin.Context) {
|
||
origin := c.GetHeader("Origin")
|
||
if origin != "" {
|
||
if allowAll {
|
||
c.Header("Access-Control-Allow-Origin", "*")
|
||
} else if allowed[origin] {
|
||
c.Header("Access-Control-Allow-Origin", origin)
|
||
c.Header("Vary", "Origin")
|
||
} else {
|
||
// 非白名单源:不输出 CORS 头,浏览器会拦截跨域请求
|
||
if c.Request.Method == "OPTIONS" {
|
||
c.AbortWithStatus(http.StatusNoContent)
|
||
return
|
||
}
|
||
c.Next()
|
||
return
|
||
}
|
||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, X-Pay-Sign")
|
||
}
|
||
|
||
if c.Request.Method == "OPTIONS" {
|
||
c.AbortWithStatus(http.StatusNoContent)
|
||
return
|
||
}
|
||
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
// bearerToken 从 Authorization 头提取 Bearer token,格式错误时返回 ok=false 并已应答
|
||
func bearerToken(c *gin.Context) (string, bool) {
|
||
authHeader := c.GetHeader("Authorization")
|
||
if authHeader == "" {
|
||
c.JSON(http.StatusUnauthorized, gin.H{
|
||
"code": 401,
|
||
"msg": "未授权",
|
||
})
|
||
c.Abort()
|
||
return "", false
|
||
}
|
||
|
||
parts := strings.SplitN(authHeader, " ", 2)
|
||
if !(len(parts) == 2 && parts[0] == "Bearer") {
|
||
c.JSON(http.StatusUnauthorized, gin.H{
|
||
"code": 401,
|
||
"msg": "token格式错误",
|
||
})
|
||
c.Abort()
|
||
return "", false
|
||
}
|
||
return parts[1], true
|
||
}
|
||
|
||
// Auth JWT认证中间件
|
||
func Auth() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
tokenString, ok := bearerToken(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
cfg := config.Load()
|
||
userService := service.NewUserService()
|
||
userID, err := userService.ParseToken(tokenString, cfg.JWT.Secret)
|
||
if err != nil {
|
||
c.JSON(http.StatusUnauthorized, gin.H{
|
||
"code": 401,
|
||
"msg": "token无效",
|
||
})
|
||
c.Abort()
|
||
return
|
||
}
|
||
|
||
c.Set("userID", userID)
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
// AdminAuth 管理员认证中间件:要求携带 role=admin 的 JWT(Authorization 头或 HttpOnly Cookie)
|
||
// 浏览器导航(GET 页面请求)未登录时渲染登录页;API 请求未登录时返回 401 JSON
|
||
func AdminAuth() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
tokenString := ""
|
||
authHeader := c.GetHeader("Authorization")
|
||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
|
||
} else {
|
||
// 浏览器导航场景:登录时写入的 HttpOnly Cookie
|
||
tokenString, _ = c.Cookie(service.AdminTokenCookie)
|
||
}
|
||
|
||
authed := false
|
||
if tokenString != "" {
|
||
cfg := config.Load()
|
||
userService := service.NewUserService()
|
||
authed = userService.IsAdminToken(tokenString, cfg.JWT.Secret)
|
||
}
|
||
|
||
if !authed {
|
||
// 页面导航(GET 且非 /admin/api/):返回登录页
|
||
if c.Request.Method == http.MethodGet && !strings.HasPrefix(c.Request.URL.Path, "/admin/api/") {
|
||
c.HTML(http.StatusOK, "index.html", gin.H{
|
||
"title": "管理员登录",
|
||
"page": "login",
|
||
"url": c.Request.URL.Path,
|
||
})
|
||
c.Abort()
|
||
return
|
||
}
|
||
c.JSON(http.StatusUnauthorized, gin.H{
|
||
"code": 401,
|
||
"msg": "需要管理员权限",
|
||
})
|
||
c.Abort()
|
||
return
|
||
}
|
||
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
var rateLimitStore = struct {
|
||
sync.Mutex
|
||
requests map[string][]time.Time
|
||
}{requests: make(map[string][]time.Time)}
|
||
|
||
// RateLimit 限流中间件
|
||
func RateLimit() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
now := time.Now()
|
||
key := c.ClientIP() + "|" + c.FullPath()
|
||
rateLimitStore.Lock()
|
||
windowStart := now.Add(-time.Minute)
|
||
recent := rateLimitStore.requests[key][:0]
|
||
for _, requestTime := range rateLimitStore.requests[key] {
|
||
if requestTime.After(windowStart) {
|
||
recent = append(recent, requestTime)
|
||
}
|
||
}
|
||
if len(recent) >= 10 {
|
||
rateLimitStore.requests[key] = recent
|
||
rateLimitStore.Unlock()
|
||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"code": 429, "msg": "请求过于频繁"})
|
||
return
|
||
}
|
||
rateLimitStore.requests[key] = append(recent, now)
|
||
rateLimitStore.Unlock()
|
||
c.Next()
|
||
}
|
||
}
|