调整日志和鉴权接管方案

This commit is contained in:
2026-04-04 20:50:45 +08:00
parent 15f72873db
commit 76f0d815aa
20 changed files with 944 additions and 402 deletions

View File

@@ -521,24 +521,42 @@ func AppDeleteHandler(c *gin.Context) {
return
}
// 删除相关的变量记录
if err := tx.Where("app_uuid = ?", app.UUID).Delete(&models.Variable{}).Error; err != nil {
// 检查是否有关联的变量
var varCount int64
if err := tx.Model(&models.Variable{}).Where("app_uuid = ?", app.UUID).Count(&varCount).Error; err != nil {
tx.Rollback()
logrus.WithError(err).Error("Failed to delete related variables")
logrus.WithError(err).Error("Failed to count related variables")
c.JSON(http.StatusInternalServerError, gin.H{
"code": 1,
"msg": "删除相关变量失败",
"msg": "检查关联变量失败",
})
return
}
if varCount > 0 {
tx.Rollback()
c.JSON(http.StatusBadRequest, gin.H{
"code": 1,
"msg": "该应用下存在关联变量,禁止删除",
})
return
}
// 删除相关的函数记录
if err := tx.Where("app_uuid = ?", app.UUID).Delete(&models.Function{}).Error; err != nil {
// 检查是否有关联的函数
var funcCount int64
if err := tx.Model(&models.Function{}).Where("app_uuid = ?", app.UUID).Count(&funcCount).Error; err != nil {
tx.Rollback()
logrus.WithError(err).Error("Failed to delete related functions")
logrus.WithError(err).Error("Failed to count related functions")
c.JSON(http.StatusInternalServerError, gin.H{
"code": 1,
"msg": "删除相关函数失败",
"msg": "检查关联函数失败",
})
return
}
if funcCount > 0 {
tx.Rollback()
c.JSON(http.StatusBadRequest, gin.H{
"code": 1,
"msg": "该应用下存在关联函数,禁止删除",
})
return
}
@@ -581,7 +599,7 @@ func AppDeleteHandler(c *gin.Context) {
logrus.WithFields(logrus.Fields{
"app_id": app.ID,
"app_uuid": app.UUID,
}).Debug("Successfully deleted app and related APIs, Variables and Functions")
}).Debug("Successfully deleted app and related APIs")
c.JSON(http.StatusOK, gin.H{
"code": 0,
@@ -1254,6 +1272,46 @@ func AppsBatchDeleteHandler(c *gin.Context) {
// 删除这些应用的所有相关接口
if len(appUUIDs) > 0 {
// 检查是否有关联的变量
var varCount int64
if err := tx.Model(&models.Variable{}).Where("app_uuid IN ?", appUUIDs).Count(&varCount).Error; err != nil {
tx.Rollback()
logrus.WithError(err).Error("Failed to count related variables")
c.JSON(http.StatusInternalServerError, gin.H{
"code": 1,
"msg": "检查关联变量失败",
})
return
}
if varCount > 0 {
tx.Rollback()
c.JSON(http.StatusBadRequest, gin.H{
"code": 1,
"msg": "所选应用中存在关联变量,禁止删除",
})
return
}
// 检查是否有关联的函数
var funcCount int64
if err := tx.Model(&models.Function{}).Where("app_uuid IN ?", appUUIDs).Count(&funcCount).Error; err != nil {
tx.Rollback()
logrus.WithError(err).Error("Failed to count related functions")
c.JSON(http.StatusInternalServerError, gin.H{
"code": 1,
"msg": "检查关联函数失败",
})
return
}
if funcCount > 0 {
tx.Rollback()
c.JSON(http.StatusBadRequest, gin.H{
"code": 1,
"msg": "所选应用中存在关联函数,禁止删除",
})
return
}
if err := tx.Where("app_uuid IN ?", appUUIDs).Delete(&models.API{}).Error; err != nil {
tx.Rollback()
logrus.WithError(err).Error("Failed to delete related APIs")

View File

@@ -13,6 +13,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
)
@@ -83,37 +84,51 @@ func LoginHandler(c *gin.Context) {
// 验证验证码
if !VerifyCaptcha(c, body.Captcha) {
recordLoginLog(c, body.Username, 0, "验证码错误")
authBaseController.HandleValidationError(c, "验证码错误")
recordLoginLog(c, "", body.Username, 0, "验证码错误或已过期")
authBaseController.HandleValidationError(c, "验证码错误或已过期")
return
}
// 从数据库中查找对应的用户
db, err := database.GetDB()
if err != nil {
recordLoginLog(c, body.Username, 0, "数据库连接失败")
recordLoginLog(c, "", body.Username, 0, "数据库连接失败")
authBaseController.HandleInternalError(c, "数据库连接失败", err)
return
}
var user models.User
if err := db.Where("username = ? AND role = ?", body.Username, 0).First(&user).Error; err != nil {
recordLoginLog(c, body.Username, 0, "用户不存在或非管理员")
if err := db.Where("username = ?", body.Username).First(&user).Error; err != nil {
recordLoginLog(c, user.UUID, body.Username, 0, "用户不存在")
authBaseController.HandleValidationError(c, "用户不存在或密码错误")
return
}
// 检查账号状态 (Status=1 表示启用,否则禁止登录)
if user.Status != 1 {
recordLoginLog(c, user.UUID, body.Username, 0, "账号已被禁用")
authBaseController.HandleValidationError(c, "该账号已被禁用,请联系超级管理员")
return
}
// 检查是否允许登录 (role=0 或 role=1 允许登录role=2 不允许)
if user.Role > 1 {
recordLoginLog(c, user.UUID, body.Username, 0, "权限不足")
authBaseController.HandleValidationError(c, "权限不足,禁止登录")
return
}
// 验证密码(使用盐值校验)
if !utils.VerifyPasswordWithSalt(body.Password, user.PasswordSalt, user.Password) {
recordLoginLog(c, body.Username, 0, "密码错误")
recordLoginLog(c, user.UUID, body.Username, 0, "密码错误")
authBaseController.HandleValidationError(c, "用户不存在或密码错误")
return
}
// 生成JWT令牌
token, err := generateJWTTokenForAdmin(user.Username, user.Password, user.UUID)
token, err := generateJWTTokenForAdmin(user.Username, user.Password, user.UUID, user.Role)
if err != nil {
recordLoginLog(c, body.Username, 0, "生成令牌失败")
recordLoginLog(c, user.UUID, body.Username, 0, "生成令牌失败")
authBaseController.HandleInternalError(c, "生成令牌失败", err)
return
}
@@ -125,19 +140,20 @@ func LoginHandler(c *gin.Context) {
cookie := utils.CreateSecureCookie("admin_session", token, maxAge, domain, secure, sameSite)
c.SetCookie(cookie.Name, cookie.Value, cookie.MaxAge, cookie.Path, cookie.Domain, cookie.Secure, cookie.HttpOnly)
recordLoginLog(c, body.Username, 1, "登录成功")
recordLoginLog(c, user.UUID, body.Username, 1, "登录成功")
authBaseController.HandleSuccess(c, "登录成功", gin.H{
"redirect": "/admin",
"avatar": user.Avatar,
"nickname": user.Nickname,
"username": user.Username,
"role": user.Role,
"token": token,
})
}
// recordLoginLog 记录登录日志
// status: 1-成功, 0-失败
func recordLoginLog(c *gin.Context, username string, status int, message string) {
func recordLoginLog(c *gin.Context, uuid string, username string, status int, message string) {
db, err := database.GetDB()
if err != nil {
// 记录日志失败不应影响主流程,但可以记录到系统日志
@@ -147,10 +163,11 @@ func recordLoginLog(c *gin.Context, username string, status int, message string)
log := models.LoginLog{
Type: "admin",
UUID: uuid,
Username: username,
IP: c.ClientIP(),
Status: status,
Message: message,
Message: "登录管理 - " + message,
UserAgent: c.Request.UserAgent(),
CreatedAt: time.Now(),
}
@@ -237,8 +254,9 @@ func getJWTSecret() []byte {
return []byte(secret)
}
// 3. 使用默认不安全密钥(仅开发环境)
return []byte("default-insecure-jwt-secret")
// 3. 如果仍未获取到,则记录严重错误并抛出 panic拒绝使用硬编码的不安全密钥
logrus.Fatal("致命安全错误: 无法获取有效的 JWT 密钥,请检查数据库设置或重新安装系统。系统拒绝以不安全模式运行。")
return nil
}
// ============================================================================
@@ -248,8 +266,8 @@ func getJWTSecret() []byte {
// JWTClaims JWT载荷结构体
type JWTClaims struct {
Username string `json:"username"`
UUID string `json:"uuid"` // 添加虚拟角色UUID
Role int `json:"role"` // 添加虚拟角色
UUID string `json:"uuid"` // 用户UUID
Role int `json:"role"` // 用户角色
PasswordHash string `json:"password_hash"` // 密码哈希摘要,用于验证密码是否被修改
jwt.RegisteredClaims
}
@@ -258,7 +276,7 @@ type JWTClaims struct {
// - 包含管理员用户名信息和密码哈希
// - 设置过期时间
// - 使用HMAC-SHA256签名
func generateJWTTokenForAdmin(username, passwordHash string, adminUUID string) (string, error) {
func generateJWTTokenForAdmin(username, passwordHash string, adminUUID string, role int) (string, error) {
// 生成密码哈希摘要使用SHA256
// 注意:传入的 passwordHash 已经是数据库存的 Hash这里我们再次 Hash 还是直接用?
// atomicLibrary 的实现是: utils.GenerateSHA256Hash(adminUser.Password)
@@ -271,7 +289,7 @@ func generateJWTTokenForAdmin(username, passwordHash string, adminUUID string) (
claims := JWTClaims{
Username: username,
UUID: adminUUID,
Role: 0, // 0表示超级管理员
Role: role, // 用户真实角色
PasswordHash: passwordHashDigest, // 包含密码哈希摘要
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(services.GetSettingsService().GetJWTExpire()) * time.Hour)),
@@ -339,9 +357,23 @@ func validateAdminPasswordHash(claims *JWTClaims, c *gin.Context) bool {
// 获取当前数据库中的管理员用户
var adminUser models.User
if err := db.Where("username = ? AND role = ?", claims.Username, 0).First(&adminUser).Error; err != nil {
fmt.Printf("[SECURITY WARNING] Admin user not found in database - Username=%s, IP=%s\n",
claims.Username, c.ClientIP())
if err := db.Where("uuid = ?", claims.UUID).First(&adminUser).Error; err != nil {
fmt.Printf("[SECURITY WARNING] Admin user not found in database - UUID=%s, IP=%s\n",
claims.UUID, c.ClientIP())
return false
}
// 检查账号状态 (Status=1 表示启用,否则强制下线)
if adminUser.Status != 1 {
fmt.Printf("[SECURITY WARNING] Admin user is disabled - UUID=%s, IP=%s\n",
claims.UUID, c.ClientIP())
return false
}
// 检查是否允许登录 (role=0 或 role=1 允许role=2不允许访问admin后台)
if adminUser.Role > 1 {
fmt.Printf("[SECURITY WARNING] Admin user role > 1 - UUID=%s, IP=%s\n",
claims.UUID, c.ClientIP())
return false
}
@@ -413,7 +445,17 @@ func IsAdminAuthenticatedHttp(r *http.Request) bool {
}
var adminUser models.User
if err := db.Where("username = ? AND role = ?", claims.Username, 0).First(&adminUser).Error; err != nil {
if err := db.Where("uuid = ?", claims.UUID).First(&adminUser).Error; err != nil {
return false
}
// 检查账号状态 (Status=1 表示启用,否则强制下线)
if adminUser.Status != 1 {
return false
}
// 检查是否允许登录 (role=0 或 role=1 允许role=2不允许访问admin后台)
if adminUser.Role > 1 {
return false
}
@@ -525,10 +567,10 @@ func GetCurrentAdminUserWithRefresh(c *gin.Context) (*JWTClaims, bool, error) {
// 获取当前的 PasswordHash
db, _ := database.GetDB()
var adminUser models.User
db.Where("username = ? AND role = ?", claims.Username, 0).First(&adminUser)
db.Where("uuid = ? AND role = ?", claims.UUID, claims.Role).First(&adminUser)
// 使用新的有效期生成令牌
newToken, err := generateJWTTokenForAdmin(claims.Username, adminUser.Password, claims.UUID)
newToken, err := generateJWTTokenForAdmin(claims.Username, adminUser.Password, claims.UUID, adminUser.Role)
if err == nil {
tokenToSet = newToken
refreshed = true

View File

@@ -1,67 +1,33 @@
package admin
import (
"crypto/rand"
"encoding/base64"
"math/big"
"net/http"
"strings"
"NetworkAuth/middleware"
"NetworkAuth/utils"
"github.com/gin-gonic/gin"
"github.com/mojocn/base64Captcha"
"github.com/sirupsen/logrus"
)
// ============================================================================
// 全局变量
// ============================================================================
// 全局验证码存储器
var store = base64Captcha.DefaultMemStore
// ============================================================================
// 辅助函数
// ============================================================================
// secureRandomInt 生成安全的随机整数,范围 [0, max)
func secureRandomInt(max int) (int, error) {
n, err := rand.Int(rand.Reader, big.NewInt(int64(max)))
if err != nil {
return 0, err
}
return int(n.Int64()), nil
}
// ============================================================================
// API处理器
// ============================================================================
// CaptchaHandler 生成验证码图片
// GET /admin/captcha - 返回验证码图片
func CaptchaHandler(c *gin.Context) {
// 随机生成4-6位长度
// 使用crypto/rand生成安全的随机数
randomNum, err := secureRandomInt(3)
if err != nil {
c.String(http.StatusInternalServerError, "生成随机数失败")
return
}
captchaLength := 4 + randomNum // 4-6位随机长度
// 配置验证码参数 - 使用字母数字混合
// 配置与 User 端一致,采用较弱的验证码强度以提升正常用户体验
driver := base64Captcha.DriverString{
Height: 60,
Width: 200,
NoiseCount: 0,
ShowLineOptions: 2 | 4,
Length: captchaLength,
Source: "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789", // 混合大小写字母和数字,去除易混淆字符
Length: 4,
NoiseCount: 20, // 加点背景噪点干扰
ShowLineOptions: 2 | 4, // 加点干扰线
Source: "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789",
}
// 生成验证码
captcha := base64Captcha.NewCaptcha(&driver, store)
// 生成验证码,使用共享的 CaptchaStore
captcha := base64Captcha.NewCaptcha(&driver, utils.CaptchaStore)
id, b64s, _, err := captcha.Generate()
if err != nil {
c.String(http.StatusInternalServerError, "生成验证码失败")
@@ -105,24 +71,6 @@ func VerifyCaptcha(c *gin.Context, captchaValue string) bool {
return false
}
// 先尝试原始值验证
if store.Verify(captchaId, captchaValue, false) {
// 验证成功后删除验证码
store.Verify(captchaId, captchaValue, true)
return true
}
// 如果原始值验证失败,尝试小写验证
if store.Verify(captchaId, strings.ToLower(captchaValue), false) {
// 验证成功后删除验证码
store.Verify(captchaId, strings.ToLower(captchaValue), true)
return true
}
// 最后尝试大写验证
if store.Verify(captchaId, strings.ToUpper(captchaValue), true) {
return true
}
return false
// 调用共享的 VerifyCaptcha
return utils.VerifyCaptcha(captchaId, captchaValue)
}

View File

@@ -5,7 +5,6 @@ import (
"NetworkAuth/controllers"
"NetworkAuth/middleware"
"NetworkAuth/models"
"NetworkAuth/services"
"NetworkAuth/utils/timeutil"
"github.com/gin-gonic/gin"
@@ -43,8 +42,6 @@ func formatDBType(dbType string) string {
// ============================================================================
// API处理器
// ============================================================================
// SystemInfoHandler 系统信息API接口
// 返回系统运行状态的JSON数据用于前端定时刷新
func SystemInfoHandler(c *gin.Context) {
version := constants.AppVersion
@@ -107,41 +104,3 @@ func DashboardStatsHandler(c *gin.Context) {
handlersBaseController.HandleSuccess(c, "ok", data)
}
// DashboardLoginLogsHandler 获取管理员最近登录日志
func DashboardLoginLogsHandler(c *gin.Context) {
db, ok := handlersBaseController.GetDB(c)
if !ok {
return
}
// 获取分页参数
page, limit := handlersBaseController.GetPaginationParams(c)
// 获取当前管理员信息(可能是 username 或 admin_username具体取决于认证中间件设置的 key
username := c.GetString("admin_username")
if username == "" {
// 尝试获取其他可能的键名
username = c.GetString("username")
}
var total int64
query := db.Model(&models.LoginLog{}).Where("type = ?", "admin")
// 如果有用户名,则仅过滤该用户的日志
if username != "" {
query = query.Where("username = ?", username)
}
logs, total, err := services.Paginate[models.LoginLog](query, page, limit, "created_at desc")
if err != nil {
handlersBaseController.HandleInternalError(c, "获取登录日志失败", err)
return
}
data := gin.H{
"total": total,
"list": logs,
}
handlersBaseController.HandleSuccess(c, "获取登录日志成功", data)
}

287
controllers/admin/log.go Normal file
View File

@@ -0,0 +1,287 @@
package admin
import (
"NetworkAuth/controllers"
"NetworkAuth/models"
"NetworkAuth/services"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
// ============================================================================
// 全局变量
// ============================================================================
var logBaseController = controllers.NewBaseController()
// ============================================================================
// 登录日志 API处理器
// ============================================================================
// DashboardLoginLogsHandler 获取管理员最近登录日志
func DashboardLoginLogsHandler(c *gin.Context) {
db, ok := logBaseController.GetDB(c)
if !ok {
return
}
// 获取分页参数
page, limit := logBaseController.GetPaginationParams(c)
// 获取当前管理员信息
uuid := c.GetString("admin_uuid")
username := c.GetString("admin_username")
var total int64
query := db.Model(&models.LoginLog{})
// 如果有用户名,则仅过滤该用户的日志
if uuid != "" {
query = query.Where("uuid = ? OR (uuid = '' AND username = ?)", uuid, username)
}
logs, total, err := services.Paginate[models.LoginLog](query, page, limit, "created_at desc")
if err != nil {
logBaseController.HandleInternalError(c, "获取登录日志失败", err)
return
}
data := gin.H{
"total": total,
"list": logs,
}
logBaseController.HandleSuccess(c, "获取登录日志成功", data)
}
// LoginLogsListHandler 登录日志列表API处理器
func LoginLogsListHandler(c *gin.Context) {
// 获取分页参数
page, limit := logBaseController.GetPaginationParams(c)
// 获取数据库连接
db, ok := logBaseController.GetDB(c)
if !ok {
return
}
query := db.Model(&models.LoginLog{})
// 筛选条件账号或UUID合并搜索
if username := strings.TrimSpace(c.Query("username")); username != "" {
query = query.Where("username = ? OR uuid = ?", username, username)
}
// 筛选条件IP
if ip := strings.TrimSpace(c.Query("ip")); ip != "" {
query = query.Where("ip = ?", ip)
}
// 筛选条件:状态
if statusStr := strings.TrimSpace(c.Query("status")); statusStr != "" {
if status, err := strconv.Atoi(statusStr); err == nil {
query = query.Where("status = ?", status)
}
}
// 筛选条件:时间范围
query = logBaseController.ApplyTimeRangeQuery(c, query, "created_at")
// 泛型分页查询
logs, total, err := services.Paginate[models.LoginLog](query, page, limit, "created_at DESC")
if err != nil {
logBaseController.HandleInternalError(c, "获取日志列表失败", err)
return
}
// 转换数据格式
var list []map[string]interface{}
for _, log := range logs {
list = append(list, map[string]interface{}{
"id": log.ID,
"uuid": log.UUID,
"username": log.Username,
"ip": log.IP,
"status": log.Status,
"message": log.Message,
"user_agent": log.UserAgent,
"created_at": log.CreatedAt,
})
}
logBaseController.HandleSuccess(c, "ok", gin.H{
"list": list,
"total": total,
})
}
// LoginLogsClearHandler 清空登录日志API处理器
func LoginLogsClearHandler(c *gin.Context) {
// 鉴权拦截:仅超级管理员 (role=0) 允许清空日志
if role, exists := c.Get("admin_role"); !exists || role.(int) != 0 {
logBaseController.HandleValidationError(c, "权限不足,仅超级管理员可清空日志")
return
}
db, ok := logBaseController.GetDB(c)
if !ok {
return
}
// 检查数据库类型
dbType := db.Dialector.Name()
if dbType == "sqlite" {
// SQLite 不支持 TRUNCATE直接使用 DELETE 和重置自增序列
if err := db.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Where("1 = 1").Delete(&models.LoginLog{}).Error; err != nil {
logrus.WithError(err).Error("Failed to clear login logs")
logBaseController.HandleInternalError(c, "清空登录日志失败", err)
return
}
// 重置 sqlite 的自增序列
db.Exec("UPDATE sqlite_sequence SET seq = 0 WHERE name = 'login_logs'")
// 释放空间
db.Exec("VACUUM")
} else {
// 其他数据库(如 MySQL/PostgreSQL尝试使用 TRUNCATE
if err := db.Exec("TRUNCATE TABLE login_logs").Error; err != nil {
// 如果 TRUNCATE 失败,回退到 DELETE
if err := db.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Where("1 = 1").Delete(&models.LoginLog{}).Error; err != nil {
logrus.WithError(err).Error("Failed to clear login logs")
logBaseController.HandleInternalError(c, "清空登录日志失败", err)
return
}
}
}
// 记录操作日志
var operator, operatorUUID string
operator = c.GetString("admin_username")
operatorUUID = c.GetString("admin_uuid")
if operator == "" {
operator = "system"
operatorUUID = "system"
}
log := models.OperationLog{
OperationType: "清空登录日志",
Operator: operator,
OperatorUUID: operatorUUID,
Details: "管理员清空了所有登录日志",
CreatedAt: time.Now(),
}
db.Create(&log)
logBaseController.HandleSuccess(c, "登录日志已清空", nil)
}
// ============================================================================
// 操作日志 API处理器
// ============================================================================
// LogsListHandler 日志列表API处理器
func LogsListHandler(c *gin.Context) {
// 获取分页参数
page, limit := logBaseController.GetPaginationParams(c)
// 获取搜索参数
operationType := strings.TrimSpace(c.Query("operation_type"))
operator := strings.TrimSpace(c.Query("operator"))
// 获取数据库连接
db, ok := logBaseController.GetDB(c)
if !ok {
return
}
query := db.Model(&models.OperationLog{})
// 筛选条件
if operationType != "" {
query = query.Where("operation_type = ?", operationType)
}
if operator != "" {
// 支持按 UUID 或 用户名 筛选
query = query.Where("operator_uuid = ? OR operator = ?", operator, operator)
}
// 筛选条件:时间范围
query = logBaseController.ApplyTimeRangeQuery(c, query, "created_at")
// 泛型分页查询
logs, total, err := services.Paginate[models.OperationLog](query, page, limit, "created_at DESC")
if err != nil {
logrus.WithError(err).Error("查询日志列表失败")
logBaseController.HandleInternalError(c, "查询日志列表失败", err)
return
}
logBaseController.HandleSuccess(c, "获取日志列表成功", gin.H{
"list": logs,
"total": total,
})
}
// LogsClearHandler 清空日志API处理器
func LogsClearHandler(c *gin.Context) {
// 鉴权拦截:仅超级管理员 (role=0) 允许清空日志
if role, exists := c.Get("admin_role"); !exists || role.(int) != 0 {
logBaseController.HandleValidationError(c, "权限不足,仅超级管理员可清空日志")
return
}
db, ok := logBaseController.GetDB(c)
if !ok {
return
}
// 检查数据库类型
dbType := db.Dialector.Name()
if dbType == "sqlite" {
// SQLite 不支持 TRUNCATE直接使用 DELETE 和重置自增序列
if err := db.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Where("1 = 1").Delete(&models.OperationLog{}).Error; err != nil {
logrus.WithError(err).Error("清空操作日志失败")
logBaseController.HandleInternalError(c, "清空操作日志失败", err)
return
}
// 重置 sqlite 的自增序列
db.Exec("UPDATE sqlite_sequence SET seq = 0 WHERE name = 'operation_logs'")
// 释放空间
db.Exec("VACUUM")
} else {
// 其他数据库(如 MySQL/PostgreSQL尝试使用 TRUNCATE
if err := db.Exec("TRUNCATE TABLE operation_logs").Error; err != nil {
// 如果 TRUNCATE 失败,回退到 DELETE
if err := db.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Where("1 = 1").Delete(&models.OperationLog{}).Error; err != nil {
logrus.WithError(err).Error("清空操作日志失败")
logBaseController.HandleInternalError(c, "清空操作日志失败", err)
return
}
}
}
// 记录操作日志 (因为刚刚清空了,这条将是第一条)
var operator, operatorUUID string
operator = c.GetString("admin_username")
operatorUUID = c.GetString("admin_uuid")
if operator == "" {
operator = "system"
operatorUUID = "system"
}
log := models.OperationLog{
OperationType: "清空日志",
Operator: operator,
OperatorUUID: operatorUUID,
Details: "管理员清空了所有操作日志",
CreatedAt: time.Now(),
}
db.Create(&log)
logBaseController.HandleSuccess(c, "日志已清空", nil)
}

View File

@@ -1,147 +0,0 @@
package admin
import (
"NetworkAuth/controllers"
"NetworkAuth/models"
"NetworkAuth/services"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
// ============================================================================
// 全局变量
// ============================================================================
var loginLogBaseController = controllers.NewBaseController()
// ============================================================================
// 辅助函数
// ============================================================================
// RecordLoginLog 记录登录日志
func RecordLoginLog(c *gin.Context, username string, status int, message string) {
db, ok := loginLogBaseController.GetDB(c)
if !ok {
return
}
log := models.LoginLog{
Type: "admin",
Username: username,
IP: c.ClientIP(),
Status: status,
Message: message,
UserAgent: c.Request.UserAgent(),
CreatedAt: time.Now(),
}
if err := db.Create(&log).Error; err != nil {
logrus.WithError(err).Error("Failed to create login log")
}
}
// ============================================================================
// API处理器
// ============================================================================
// LoginLogsListHandler 登录日志列表API处理器
func LoginLogsListHandler(c *gin.Context) {
// 获取分页参数
page, limit := loginLogBaseController.GetPaginationParams(c)
// 获取数据库连接
db, ok := loginLogBaseController.GetDB(c)
if !ok {
return
}
// 兼容旧数据Type为空和新数据Type=admin
query := db.Model(&models.LoginLog{}).Where("type = ? OR type = ? OR type IS NULL", "admin", "")
// 筛选条件:用户名
if username := strings.TrimSpace(c.Query("username")); username != "" {
query = query.Where("username = ?", username)
}
// 筛选条件IP
if ip := strings.TrimSpace(c.Query("ip")); ip != "" {
query = query.Where("ip = ?", ip)
}
// 筛选条件:状态
if statusStr := strings.TrimSpace(c.Query("status")); statusStr != "" {
if status, err := strconv.Atoi(statusStr); err == nil {
query = query.Where("status = ?", status)
}
}
// 筛选条件:时间范围
query = loginLogBaseController.ApplyTimeRangeQuery(c, query, "created_at")
// 泛型分页查询
logs, total, err := services.Paginate[models.LoginLog](query, page, limit, "created_at DESC")
if err != nil {
loginLogBaseController.HandleInternalError(c, "获取日志列表失败", err)
return
}
// 转换数据格式
var list []map[string]interface{}
for _, log := range logs {
list = append(list, map[string]interface{}{
"id": log.ID,
"username": log.Username,
"ip": log.IP,
"status": log.Status,
"message": log.Message,
"user_agent": log.UserAgent,
"created_at": log.CreatedAt,
})
}
loginLogBaseController.HandleSuccess(c, "ok", gin.H{
"list": list,
"total": total,
})
}
// LoginLogsClearHandler 清空登录日志API处理器
func LoginLogsClearHandler(c *gin.Context) {
db, ok := loginLogBaseController.GetDB(c)
if !ok {
return
}
// 物理删除所有登录日志
if err := db.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Where("type = ? OR type = ? OR type IS NULL", "admin", "").Delete(&models.LoginLog{}).Error; err != nil {
logrus.WithError(err).Error("Failed to clear login logs")
loginLogBaseController.HandleInternalError(c, "清空登录日志失败", err)
return
}
// 记录操作日志
var operator, operatorUUID string
if claims, _, err := GetCurrentAdminUserWithRefresh(c); err == nil && claims != nil {
operator = claims.Username
operatorUUID = claims.UUID
} else {
operator = "admin"
operatorUUID = "00000000-0000-0000-0000-000000000000"
}
log := models.OperationLog{
OperationType: "清空登录日志",
Operator: operator,
OperatorUUID: operatorUUID,
Details: "管理员清空了所有登录日志",
CreatedAt: time.Now(),
}
db.Create(&log)
loginLogBaseController.HandleSuccess(c, "登录日志已清空", nil)
}

View File

@@ -1,102 +0,0 @@
package admin
import (
"NetworkAuth/controllers"
"NetworkAuth/models"
"NetworkAuth/services"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
// ============================================================================
// 全局变量
// ============================================================================
var logBaseController = controllers.NewBaseController()
// ============================================================================
// API处理器
// ============================================================================
// LogsListHandler 日志列表API处理器
func LogsListHandler(c *gin.Context) {
// 获取分页参数
page, limit := logBaseController.GetPaginationParams(c)
// 获取搜索参数
operationType := strings.TrimSpace(c.Query("operation_type"))
operator := strings.TrimSpace(c.Query("operator"))
// 获取数据库连接
db, ok := logBaseController.GetDB(c)
if !ok {
return
}
query := db.Model(&models.OperationLog{})
// 筛选条件
if operationType != "" {
query = query.Where("operation_type = ?", operationType)
}
if operator != "" {
// 支持按 UUID 或 用户名 筛选
query = query.Where("operator_uuid = ? OR operator = ?", operator, operator)
}
// 筛选条件:时间范围
query = logBaseController.ApplyTimeRangeQuery(c, query, "created_at")
// 泛型分页查询
logs, total, err := services.Paginate[models.OperationLog](query, page, limit, "created_at DESC")
if err != nil {
logrus.WithError(err).Error("查询日志列表失败")
logBaseController.HandleInternalError(c, "查询日志列表失败", err)
return
}
logBaseController.HandleSuccess(c, "获取日志列表成功", gin.H{
"list": logs,
"total": total,
})
}
// LogsClearHandler 清空日志API处理器
func LogsClearHandler(c *gin.Context) {
db, ok := logBaseController.GetDB(c)
if !ok {
return
}
// 开启事务进行清空
if err := db.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(&models.OperationLog{}).Error; err != nil {
logrus.WithError(err).Error("清空操作日志失败")
logBaseController.HandleInternalError(c, "清空操作日志失败", err)
return
}
// 记录操作日志 (因为刚刚清空了,这条将是第一条)
var operator, operatorUUID string
if claims, _, err := GetCurrentAdminUserWithRefresh(c); err == nil && claims != nil {
operator = claims.Username
operatorUUID = claims.UUID
} else {
operator = "admin"
operatorUUID = "00000000-0000-0000-0000-000000000000"
}
log := models.OperationLog{
OperationType: "清空日志",
Operator: operator,
OperatorUUID: operatorUUID,
Details: "管理员清空了所有操作日志",
CreatedAt: time.Now(),
}
db.Create(&log)
logBaseController.HandleSuccess(c, "日志已清空", nil)
}

View File

@@ -244,7 +244,7 @@ func SettingsPublicHandler(c *gin.Context) {
var list []models.Settings
// 查询公开的基本信息、维护模式和所有前端平台配置
if err := db.Where("name IN ? OR name LIKE ?", []string{"site_title", "site_description", "site_keywords", "site_logo", "contact_email", "maintenance_mode"}, "platform_%").Find(&list).Error; err != nil {
if err := db.Where("name IN ? OR name LIKE ?", []string{"site_title", "site_description", "site_keywords", "site_logo", "contact_email", "maintenance_mode", "hide_login_entrance"}, "platform_%").Find(&list).Error; err != nil {
authBaseController.HandleInternalError(c, "查询失败", err)
return
}

View File

@@ -18,6 +18,13 @@ import (
// InstallSubmitHandler 处理安装表单提交
func InstallSubmitHandler(c *gin.Context) {
// 二次安全校验:检查系统是否已经安装
isInstalledStr := services.GetSettingsService().GetString("is_installed", "0")
if isInstalledStr == "1" {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "msg": "系统已安装,禁止重复初始化"})
return
}
var req struct {
// 数据库配置
DbType string `json:"db_type" binding:"required,oneof=sqlite mysql"`
@@ -109,14 +116,13 @@ func InstallSubmitHandler(c *gin.Context) {
// 更新或创建超级管理员账号
var adminUser models.User
if err := tx.Where("uuid = ?", "00000000-0000-0000-0000-000000000000").First(&adminUser).Error; err != nil {
if err := tx.Where("role = ?", 0).First(&adminUser).Error; err != nil {
// 如果不存在则创建
adminUser = models.User{
UUID: "00000000-0000-0000-0000-000000000000",
Username: strings.TrimSpace(req.AdminUsername),
Password: adminPasswordHash,
PasswordSalt: adminSalt,
Nickname: "管理员",
Nickname: "超级管理员",
Avatar: "",
Role: 0,
Status: 1,
@@ -133,7 +139,7 @@ func InstallSubmitHandler(c *gin.Context) {
adminUser.Username = strings.TrimSpace(req.AdminUsername)
adminUser.Password = adminPasswordHash
adminUser.PasswordSalt = adminSalt
adminUser.Nickname = "管理员"
adminUser.Nickname = "超级管理员"
adminUser.Role = 0
if err := tx.Save(&adminUser).Error; err != nil {
tx.Rollback()

View File

@@ -51,6 +51,11 @@ func SeedDefaultSettings() error {
Value: "0",
Description: "维护模式0=关闭维护模式1=开启维护模式",
},
{
Name: "hide_login_entrance",
Value: "0",
Description: "隐藏登录入口0=显示1=隐藏(门户中不显示管理员或子账号登录入口)",
},
{
Name: "encryption_key",
Value: encryptionKey,

16
go.mod
View File

@@ -3,14 +3,17 @@ module NetworkAuth
go 1.25.0
require (
github.com/andybalholm/brotli v1.2.1
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/go-resty/resty/v2 v2.17.2
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/mojocn/base64Captcha v1.3.8
github.com/redis/go-redis/v9 v9.18.0
github.com/sirupsen/logrus v1.9.3
github.com/skycheung803/go-bypasser v0.0.0-20250704092818-4b1591564510
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.1
github.com/xuri/excelize/v2 v2.10.1
@@ -26,6 +29,7 @@ require (
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -36,15 +40,21 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-rod/rod v0.116.2 // indirect
github.com/go-rod/stealth v0.4.9 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/imroc/req/v3 v3.49.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -53,6 +63,7 @@ require (
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/refraction-networking/utls v1.6.7 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/richardlehane/mscfb v1.0.6 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
@@ -67,6 +78,11 @@ require (
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
github.com/ysmood/fetchup v0.2.3 // indirect
github.com/ysmood/goob v0.4.0 // indirect
github.com/ysmood/got v0.40.0 // indirect
github.com/ysmood/gson v0.7.3 // indirect
github.com/ysmood/leakless v0.9.0 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.9.0 // indirect

49
go.sum
View File

@@ -1,5 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -12,6 +14,8 @@ github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiD
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.5.0 h1:hxIWksrX6XN5a1L2TI/h53AGPhNHoUBo+TD1ms9+pys=
github.com/cloudflare/circl v1.5.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -46,6 +50,13 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
github.com/go-rod/rod v0.113.0/go.mod h1:aiedSEFg5DwG/fnNbUOTPMTTWX3MRj6vIs/a684Mthw=
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
github.com/go-rod/stealth v0.4.9 h1:X2PmQk4DUF2wzw6GOsWjW/glb8K5ebnftbEvLh7MlZ4=
github.com/go-rod/stealth v0.4.9/go.mod h1:eAzyvw8c0iAd5nJJsSWeh0fQ5z94vCIfdi1hUmYDimc=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
@@ -62,10 +73,17 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg=
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/imroc/req/v3 v3.49.0 h1:5Rac2qvz7Dq0E3PeBo/c2szV3hagPQIGLoHtfBmYhu4=
github.com/imroc/req/v3 v3.49.0/go.mod h1:XZf4t94DNJzcA0UOBlA68hmSrWsAyvN407ADdH4mzCA=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -74,6 +92,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -101,6 +121,8 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/refraction-networking/utls v1.6.7 h1:zVJ7sP1dJx/WtVuITug3qYUq034cDq9B2MR1K67ULZM=
github.com/refraction-networking/utls v1.6.7/go.mod h1:BC3O4vQzye5hqpmDTWUqi4P5DDhzJfkV1tdqtawQIH0=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
@@ -115,6 +137,8 @@ github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsF
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skycheung803/go-bypasser v0.0.0-20250704092818-4b1591564510 h1:4/6v3lBnoJOjc878II458DVLurtYS3H4S09TBT6PDf8=
github.com/skycheung803/go-bypasser v0.0.0-20250704092818-4b1591564510/go.mod h1:7moDdG5Uh/9NTDph5drq217W+drrX9MIa4obMx9o91A=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
@@ -153,6 +177,25 @@ github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzx
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ=
github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns=
github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
github.com/ysmood/gop v0.0.2/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg=
github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
github.com/ysmood/got v0.34.1/go.mod h1:yddyjq/PmAf08RMLSwDjPyCvHvYed+WjHnQxpH851LM=
github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q=
github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=
github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY=
github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM=
github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
github.com/ysmood/leakless v0.8.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
@@ -231,6 +274,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

58
middleware/ratelimit.go Normal file
View File

@@ -0,0 +1,58 @@
package middleware
import (
"NetworkAuth/utils"
"context"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// RateLimit 基于 Redis 的简单固定窗口限流中间件
// limit: 时间窗口内允许的最大请求数
// window: 时间窗口大小
func RateLimit(limit int, window time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
client := utils.GetRedis()
if client == nil {
// 如果 Redis 未配置或不可用,则放行(降级处理)
c.Next()
return
}
ip := c.ClientIP()
path := c.FullPath()
if path == "" {
path = c.Request.URL.Path
}
// 构建 Redis Key按 IP 和接口路径限制
key := fmt.Sprintf("ratelimit:%s:%s", path, ip)
ctx := context.Background()
// 使用 INCR 增加计数
count, err := client.Incr(ctx, key).Result()
if err != nil {
c.Next()
return
}
// 如果是第一次访问,设置过期时间
if count == 1 {
client.Expire(ctx, key, window)
}
if count > int64(limit) {
c.JSON(http.StatusTooManyRequests, gin.H{
"code": 429,
"msg": "请求过于频繁,请稍后再试",
})
c.Abort()
return
}
c.Next()
}
}

View File

@@ -24,8 +24,8 @@ const (
APITypeSingleLogin = 10 // 卡密登录
// 账号管理
APITypeUserLogin = 20 // 用户登录
APITypeUserRegin = 21 // 用户注册
APITypeUserLogin = 20 // 字系统登录
APITypeUserRegin = 21 // 字系统注册
APITypeUserRecharge = 22 // 用户充值
// 登出操作
@@ -188,8 +188,8 @@ func GetAPITypes(categorized bool) interface{} {
{
Name: "账号管理",
Types: []APITypeInfo{
{Type: APITypeUserLogin, Name: "用户登录"},
{Type: APITypeUserRegin, Name: "用户注册"},
{Type: APITypeUserLogin, Name: "子系统登录"},
{Type: APITypeUserRegin, Name: "子系统注册"},
{Type: APITypeUserRecharge, Name: "用户充值"},
},
},

View File

@@ -8,6 +8,7 @@ import (
type LoginLog struct {
ID uint `gorm:"primarykey" json:"id"`
Type string `gorm:"type:varchar(20);index;comment:日志类型(admin/user)" json:"type"`
UUID string `gorm:"type:char(36);index;comment:用户UUID" json:"uuid"`
Username string `gorm:"type:varchar(100);index;comment:登录用户名" json:"username"`
IP string `gorm:"type:varchar(50);comment:登录IP" json:"ip"`
Status int `gorm:"type:tinyint;comment:登录状态 1:成功 0:失败" json:"status"`

View File

@@ -22,7 +22,7 @@ type User struct {
Password string `gorm:"size:255;not null;comment:密码哈希值"`
PasswordSalt string `gorm:"size:64;not null;comment:密码加密盐值"`
Status int `gorm:"not null;default:1;comment:状态0禁用1启用" json:"status"`
Role int `gorm:"not null;default:2;comment:角色类型0超级管理员1代理成员2普通成员" json:"role"`
Role int `gorm:"not null;default:2;comment:角色类型0超级管理员1管理员2子账号" json:"role"`
Permissions string `gorm:"size:255;comment:权限列表,逗号分隔" json:"permissions"`
Nickname string `gorm:"size:64;comment:用户昵称" json:"nickname"`
Remark string `gorm:"size:255;comment:备注信息" json:"remark"`

View File

@@ -41,12 +41,12 @@ func RegisterAdminRoutes(rg *gin.RouterGroup) {
authorized.POST("/settings/generate-key", adminctl.SettingsGenerateKeyHandler)
// 操作日志API
authorized.GET("/logs", adminctl.LogsListHandler)
authorized.POST("/logs/clear", adminctl.LogsClearHandler)
authorized.GET("/logs", adminctl.LogsListHandler) // 获取操作日志列表
authorized.POST("/logs/clear", adminctl.LogsClearHandler) // 清空操作日志
// 登录日志API
authorized.GET("/login_logs", adminctl.LoginLogsListHandler)
authorized.POST("/login_logs/clear", adminctl.LoginLogsClearHandler)
authorized.GET("/login_logs", adminctl.LoginLogsListHandler) // 获取登录日志列表
authorized.POST("/login_logs/clear", adminctl.LoginLogsClearHandler) // 清空登录日志
// 子账号相关API (Mock)
authorized.GET("/subaccounts/simple", adminctl.SubAccountSimpleListHandler)

View File

@@ -2,6 +2,8 @@ package server
import (
defaultctrl "NetworkAuth/controllers/default"
"NetworkAuth/middleware"
"time"
"github.com/gin-gonic/gin"
)
@@ -11,6 +13,6 @@ import (
func RegisterDefaultRoutes(rg *gin.RouterGroup) {
homeGroup := rg.Group("/home")
// 根路径
homeGroup.GET("", defaultctrl.RootHandler)
// 根路径 (限制:每分钟最多 60 次请求,防止 CC)
homeGroup.GET("", middleware.RateLimit(60, time.Minute), defaultctrl.RootHandler)
}

View File

@@ -0,0 +1,326 @@
package request
import (
"bytes"
"compress/flate"
"compress/gzip"
"encoding/json"
"io"
"net/http"
"net/http/cookiejar"
"reflect"
"strings"
"time"
"unsafe"
"github.com/andybalholm/brotli"
"github.com/go-resty/resty/v2"
"github.com/skycheung803/go-bypasser"
)
type RestyClient struct {
client *resty.Client
}
func (request *RestyClient) Resty() *resty.Client {
return request.client
}
// NewClient 创建一个基于 uTLS 指纹与 HTTP/2 指纹的 Resty 客户端
// baseURL 不为空则设置默认 BaseURLproxyStr 不为空则启用 HTTP 代理(仅 HTTP/1.1
// persistCookies 启用持久化 CookiefollowRedirect 启用重定向跟随timeout 设置超时时间0 或负数则默认 60 秒)
func NewClient(baseURL string, proxyStr string, persistCookies bool, timeout int) *RestyClient {
rc := resty.New()
if baseURL != "" {
rc.SetBaseURL(baseURL)
}
if persistCookies {
jar, _ := cookiejar.New(nil)
rc.SetCookieJar(jar)
}
// 设置请求超时时间,如果传入 0 或负数则默认 60 秒
if timeout <= 0 {
timeout = 60
}
rc.SetTimeout(time.Duration(timeout) * time.Second)
// 统一设置客户端默认请求头(调用级 headers 可覆盖),字段按字母顺序排列
rc.SetHeader("accept", "*/*")
rc.SetHeader("accept-language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6")
rc.SetHeader("connection", "keep-alive")
rc.SetHeader("pragma", "no-cache")
rc.SetHeader("priority", "u=1,i")
rc.SetHeader("sec-ch-ua", "\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"")
rc.SetHeader("sec-ch-ua-mobile", "?0")
rc.SetHeader("sec-ch-ua-platform", "\"macOS\"")
rc.SetHeader("sec-fetch-dest", "empty")
rc.SetHeader("sec-fetch-mode", "cors")
rc.SetHeader("sec-fetch-site", "same-origin")
rc.SetHeader("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36")
// 初始化 go-bypasser 替代原有的 spoofed-round-tripper
opts := []bypasser.BypasserOption{
bypasser.WithInsecureSkipVerify(true),
}
if proxyStr != "" {
opts = append(opts, bypasser.WithProxy(proxyStr))
}
bypass, err := bypasser.NewBypasser(opts...)
if err != nil {
panic(err)
}
rc.SetTransport(bypass.Transport)
return &RestyClient{client: rc}
}
// fillResponseBody 使用反射强制填充响应体
// 当 Resty 因为重定向策略错误而提前返回时,它可能不会读取 Body
// 此方法手动读取 RawResponse.Body 并回填到 resty.Response 的私有 body 字段中
func (request *RestyClient) fillResponseBody(resp *resty.Response) {
if resp == nil || resp.RawResponse == nil {
return
}
// 如果已经有 body 内容,则不处理
if len(resp.Body()) > 0 {
return
}
// 读取底层 Body
bodyBytes, err := io.ReadAll(resp.RawResponse.Body)
if err != nil {
return
}
resp.RawResponse.Body.Close()
// 重置 Body 以便后续可能得读取
resp.RawResponse.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
// 使用反射设置私有字段 body
v := reflect.ValueOf(resp).Elem()
f := v.FieldByName("body")
if f.IsValid() {
// 必须使用 UnsafeAddr 获取未导出字段的地址
rf := reflect.NewAt(f.Type(), unsafe.Pointer(f.UnsafeAddr())).Elem()
rf.SetBytes(bodyBytes)
}
// 设置 size 字段
s := v.FieldByName("size")
if s.IsValid() {
rs := reflect.NewAt(s.Type(), unsafe.Pointer(s.UnsafeAddr())).Elem()
rs.SetInt(int64(len(bodyBytes)))
}
}
// makeReq 构造带可选请求头的 resty.Request
// 功能:基于客户端创建请求对象,并在传入 headers 时进行设置
// 返回:带有请求头的请求对象
func (request *RestyClient) makeReq(headers map[string]string, cookies []*http.Cookie) *resty.Request {
req := request.client.R()
if len(headers) > 0 {
req = req.SetHeaders(headers)
}
if len(cookies) > 0 {
req = req.SetCookies(cookies)
}
return req
}
// doWithEncodingFallback 封装请求发送并在出现压缩相关错误时进行一次降级重试
// 逻辑:首次请求失败且错误包含 gzip/zstd/brotli/magic number mismatch 时,设置 accept-encoding 为 identity 重试一次
func (request *RestyClient) doWithEncodingFallback(headers map[string]string, cookies []*http.Cookie, allowRedirect bool, do func(*resty.Request) (*resty.Response, error)) (*resty.Response, error) {
req := request.makeReq(headers, cookies)
if allowRedirect {
request.client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(10))
} else {
// 使用 http.ErrUseLastResponse 确保 302 响应被返回且 Body 可读,而不是报错
request.client.SetRedirectPolicy(resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}))
}
resp, err := do(req)
// 尝试补救响应体(特别是当重定向被禁用导致报错时)
request.fillResponseBody(resp)
if err == nil {
return resp, nil
}
s := err.Error()
if strings.Contains(s, "gzip: invalid header") || strings.Contains(s, "magic number mismatch") || strings.Contains(s, "zstd") || strings.Contains(s, "brotli") {
h2 := map[string]string{}
for k, v := range headers {
if strings.ToLower(k) != "accept-encoding" {
h2[k] = v
}
}
h2["Accept-Encoding"] = "identity"
req2 := request.makeReq(h2, cookies)
resp2, err2 := do(req2)
request.fillResponseBody(resp2)
if err2 == nil {
return resp2, nil
}
}
return resp, err
}
// decodeResponse 处理响应解压与 JSON 解析
// 功能:自动识别 gzip 压缩并解压;在 result 非空时按 JSON 解析到 result
// 返回:解析错误(成功时为 nil
func (request *RestyClient) decodeResponse(resp *resty.Response, result interface{}) error {
if resp == nil {
return nil
}
ct := strings.ToLower(resp.Header().Get("Content-Type"))
ce := strings.ToLower(resp.Header().Get("Content-Encoding"))
body := resp.Body()
if strings.Contains(ce, "gzip") && len(body) > 0 {
gr, gerr := gzip.NewReader(bytes.NewReader(body))
if gerr == nil {
defer gr.Close()
if dec, derr := io.ReadAll(gr); derr == nil {
body = dec
resp.SetBody(body)
}
}
} else if strings.Contains(ce, "deflate") && len(body) > 0 {
// 处理 deflate 压缩
dr := flate.NewReader(bytes.NewReader(body))
defer dr.Close()
if dec, derr := io.ReadAll(dr); derr == nil {
body = dec
resp.SetBody(body)
}
} else if strings.Contains(ce, "br") && len(body) > 0 {
// 处理 brotli 压缩
br := brotli.NewReader(bytes.NewReader(body))
if dec, derr := io.ReadAll(br); derr == nil {
body = dec
resp.SetBody(body) // 将解压后的 body 写回 response
}
}
if result != nil && (strings.Contains(ct, "application/json") || json.Valid(body)) {
if err := json.Unmarshal(body, result); err != nil {
return err
}
}
return nil
}
// RestyGet 发送 GET 请求
func (request *RestyClient) RestyGet(path string, result interface{}, headers map[string]string, cookies []*http.Cookie, allowRedirect bool) (*resty.Response, error) {
resp, err := request.doWithEncodingFallback(headers, cookies, allowRedirect, func(r *resty.Request) (*resty.Response, error) {
return r.Get(path)
})
if resp == nil && err != nil {
return nil, err
}
if err := request.decodeResponse(resp, result); err != nil {
return nil, err
}
return resp, err
}
// RestyPost 发送 POST 请求
func (request *RestyClient) RestyPost(path string, data any, result interface{}, headers map[string]string, cookies []*http.Cookie, allowRedirect bool) (*resty.Response, error) {
resp, err := request.doWithEncodingFallback(headers, cookies, allowRedirect, func(r *resty.Request) (*resty.Response, error) {
return r.SetBody(data).Post(path)
})
if resp == nil && err != nil {
return nil, err
}
if err := request.decodeResponse(resp, result); err != nil {
return nil, err
}
return resp, err
}
// RestyPut 发送 PUT 请求
// 功能:发送 PUT支持请求级 headers 覆盖客户端默认,自动识别 gzip 并解析 JSON
// 返回:响应对象与错误信息
func (request *RestyClient) RestyPut(path string, data any, result interface{}, headers map[string]string, cookies []*http.Cookie, allowRedirect bool) (*resty.Response, error) {
resp, err := request.doWithEncodingFallback(headers, cookies, allowRedirect, func(r *resty.Request) (*resty.Response, error) {
return r.SetBody(data).Put(path)
})
if resp == nil && err != nil {
return nil, err
}
if err := request.decodeResponse(resp, result); err != nil {
return nil, err
}
return resp, err
}
// RestyPatch 发送 PATCH 请求
// 功能:发送 PATCH支持请求级 headers 覆盖客户端默认,自动识别 gzip 并解析 JSON
// 返回:响应对象与错误信息
func (request *RestyClient) RestyPatch(path string, data any, result interface{}, headers map[string]string, cookies []*http.Cookie, allowRedirect bool) (*resty.Response, error) {
resp, err := request.doWithEncodingFallback(headers, cookies, allowRedirect, func(r *resty.Request) (*resty.Response, error) {
return r.SetBody(data).Patch(path)
})
if resp == nil && err != nil {
return nil, err
}
if err := request.decodeResponse(resp, result); err != nil {
return nil, err
}
return resp, err
}
// RestyDelete 发送 DELETE 请求
// 功能:发送 DELETE支持请求级 headers 覆盖客户端默认,自动识别 gzip 并解析 JSON
// 返回:响应对象与错误信息
func (request *RestyClient) RestyDelete(path string, result interface{}, headers map[string]string, cookies []*http.Cookie, allowRedirect bool) (*resty.Response, error) {
resp, err := request.doWithEncodingFallback(headers, cookies, allowRedirect, func(r *resty.Request) (*resty.Response, error) {
return r.Delete(path)
})
if resp == nil && err != nil {
return nil, err
}
if err := request.decodeResponse(resp, result); err != nil {
return nil, err
}
return resp, err
}
// RestyHead 发送 HEAD 请求
// 功能:发送 HEAD支持请求级 headers 覆盖客户端默认HEAD 通常无正文
// 返回:响应对象与错误信息
func (request *RestyClient) RestyHead(path string, headers map[string]string, cookies []*http.Cookie, allowRedirect bool) (*resty.Response, error) {
resp, err := request.doWithEncodingFallback(headers, cookies, allowRedirect, func(r *resty.Request) (*resty.Response, error) {
return r.Head(path)
})
if resp == nil && err != nil {
return nil, err
}
return resp, err
}
// RestyOptions 发送 OPTIONS 请求
// 功能:发送 OPTIONS支持请求级 headers 覆盖客户端默认
// 返回:响应对象与错误信息
func (request *RestyClient) RestyOptions(path string, headers map[string]string, cookies []*http.Cookie, allowRedirect bool) (*resty.Response, error) {
resp, err := request.doWithEncodingFallback(headers, cookies, allowRedirect, func(r *resty.Request) (*resty.Response, error) {
return r.Options(path)
})
if resp == nil && err != nil {
return nil, err
}
return resp, err
}

38
utils/captcha.go Normal file
View File

@@ -0,0 +1,38 @@
package utils
import (
"strings"
"github.com/mojocn/base64Captcha"
)
// CaptchaStore 全局验证码存储器
// 使用 base64Captcha 提供的默认内存存储,确保 admin 和 user 端可以共享验证码状态
var CaptchaStore = base64Captcha.DefaultMemStore
// VerifyCaptcha 验证验证码的有效性
// captchaId: 验证码的唯一标识符
// captchaValue: 用户输入的验证码内容
// 返回值: 验证是否通过
// 该函数提供函数级注释,支持大小写不敏感匹配,验证通过后会自动删除验证码
func VerifyCaptcha(captchaId, captchaValue string) bool {
if captchaId == "" || captchaValue == "" {
return false
}
// 使用 switch 进行连续逻辑判断,尝试不同的大小写组合
switch {
case CaptchaStore.Verify(captchaId, captchaValue, true):
// 原始值匹配成功
return true
case CaptchaStore.Verify(captchaId, strings.ToLower(captchaValue), true):
// 小写匹配成功
return true
case CaptchaStore.Verify(captchaId, strings.ToUpper(captchaValue), true):
// 大写匹配成功
return true
default:
// 匹配失败
return false
}
}