更新底层架构

This commit is contained in:
2026-03-18 21:51:17 +08:00
parent b69c6ccbca
commit 68bea98b81
71 changed files with 5220 additions and 7619 deletions

4
.gitignore vendored
View File

@@ -1,6 +1,6 @@
# 编译后的二进制文件
networkDev
networkDev.exe
NetworkAuth
NetworkAuth.exe
*.exe
*.exe~
*.dll

View File

@@ -1,142 +1,128 @@
package cmd
import (
"io"
"networkDev/config"
"networkDev/utils/logger"
"os"
"path/filepath"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"gopkg.in/natefinch/lumberjack.v2"
)
// ============================================================================
// 全局变量
// ============================================================================
var cfgFile string
// ============================================================================
// 命令定义
// ============================================================================
// rootCmd 代表没有调用子命令时的基础命令
var rootCmd = &cobra.Command{
Use: "networkDev",
Short: "一个基于Cobra的网络验证服务器应用",
Long: `networkDev是一个使用Cobra CLI框架构建的网络验证服务器应用
集成了Viper配置管理、Logrus日志记录和embed静态资源嵌入功能。`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
// 在加载配置前配置logrus用于非HTTP日志
setupLogrusForNonHTTP()
},
}
// ============================================================================
// 公共函数
// ============================================================================
// Execute 添加所有子命令到根命令并设置适当的标志
// 这由main.main()调用。只需要对rootCmd执行一次。
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
cobra.OnInitialize(initConfig)
// 在这里定义标志和配置设置
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "配置文件路径 (默认为 config.json)")
}
// ============================================================================
// 私有函数
// ============================================================================
// setupLogrusForNonHTTP 配置logrus用于非HTTP日志
// 在加载配置文件之前进行基本的logrus设置
func setupLogrusForNonHTTP() {
// 设置日志格式
logrus.SetFormatter(&logrus.TextFormatter{
TimestampFormat: "2006-01-02 15:04:05",
FullTimestamp: true,
ForceColors: false,
DisableColors: true,
})
// 设置默认日志级别
logrus.SetLevel(logrus.InfoLevel)
// 设置输出目标(稍后会根据配置文件调整)
logrus.SetOutput(os.Stdout)
if cfgFile != "" {
config.Init(cfgFile)
} else {
config.Init("./config.json")
}
// 根据配置文件进一步配置logrus
setupLogrusFromConfig()
// 初始化HTTP日志处理器
logger.InitLogger()
// 记录配置加载完成
logrus.Info("配置文件加载完成")
}
// initConfig 读取配置文件和环境变量
func initConfig() {
}
// setupLogrusFromConfig 根据配置文件进一步配置logrus
// 设置日志级别和输出目标,支持日志切割功能
func setupLogrusFromConfig() {
// 设置日志级别
if level := viper.GetString("log.level"); level != "" {
if logLevel, err := logrus.ParseLevel(level); err == nil {
logrus.SetLevel(logLevel)
}
}
// 设置日志输出目标
logFile := viper.GetString("log.file")
if logFile != "" {
// 确保日志目录存在
logDir := filepath.Dir(logFile)
if err := os.MkdirAll(logDir, 0755); err != nil {
logrus.WithError(err).Error("创建日志目录失败")
return
}
// 配置lumberjack日志轮转
lumberjackLogger := &lumberjack.Logger{
Filename: logFile,
MaxSize: viper.GetInt("log.max_size"), // MB
MaxBackups: viper.GetInt("log.max_backups"), // 保留的旧日志文件数量
MaxAge: viper.GetInt("log.max_age"), // 天数
Compress: true, // 压缩旧日志文件
}
// 同时输出到控制台和文件(带日志切割)
multiWriter := io.MultiWriter(os.Stdout, lumberjackLogger)
logrus.SetOutput(multiWriter)
logrus.WithFields(logrus.Fields{
"file": logFile,
"max_size": viper.GetInt("log.max_size"),
"max_backups": viper.GetInt("log.max_backups"),
"max_age": viper.GetInt("log.max_age"),
}).Info("日志切割功能已启用")
}
// 当日志文件路径为空时,保持默认输出到控制台,不创建任何目录
}
package cmd
import (
"NetworkAuth/config"
"NetworkAuth/utils/logger"
"io"
"os"
"path/filepath"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"gopkg.in/natefinch/lumberjack.v2"
)
var cfgFile string
// rootCmd 代表没有调用子命令时的基础命令
var rootCmd = &cobra.Command{
Use: "NetworkAuth",
Short: "网络授权服务命令行工具",
Long: `网络授权服务 (NetworkAuth) 是一个专注于应用鉴权、接口管理和动态逻辑分发的后端系统。
本命令行工具用于启动服务器、管理配置和执行维护任务。`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
// 在加载配置前配置logrus用于非HTTP日志
setupLogrusForNonHTTP()
},
}
// Execute 添加所有子命令到根命令并设置适当的标志
// 这由main.main()调用。只需要对rootCmd执行一次。
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
cobra.OnInitialize(initConfig)
// 在这里定义标志和配置设置
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "配置文件路径 (默认为 config.json)")
}
// setupLogrusForNonHTTP 配置logrus用于非HTTP日志
// 在加载配置文件之前进行基本的logrus设置
func setupLogrusForNonHTTP() {
// 设置日志格式
logrus.SetFormatter(&logrus.TextFormatter{
TimestampFormat: "2006-01-02 15:04:05",
FullTimestamp: true,
ForceColors: false,
DisableColors: true,
})
// 设置默认日志级别
logrus.SetLevel(logrus.InfoLevel)
// 设置输出目标(稍后会根据配置文件调整)
logrus.SetOutput(os.Stdout)
if cfgFile != "" {
// 使用命令行指定的配置文件
config.Init(cfgFile)
} else {
// 使用默认配置文件路径
config.Init("./config.json")
}
// 根据配置文件进一步配置logrus
setupLogrusFromConfig()
// 初始化HTTP日志处理器
logger.InitLogger()
// 记录配置加载完成
logrus.WithField("file", viper.ConfigFileUsed()).Info("配置文件加载完成")
}
// initConfig 读取配置文件和环境变量
func initConfig() {
}
// setupLogrusFromConfig 根据配置文件进一步配置logrus
// 设置日志级别和输出目标,支持日志切割功能
func setupLogrusFromConfig() {
// 设置日志级别
if level := viper.GetString("log.level"); level != "" {
if logLevel, err := logrus.ParseLevel(level); err == nil {
logrus.SetLevel(logLevel)
}
}
// 设置日志输出目标
logFile := viper.GetString("log.file")
if logFile != "" {
// 确保日志目录存在
logDir := filepath.Dir(logFile)
if err := os.MkdirAll(logDir, 0755); err != nil {
logrus.WithError(err).Error("创建日志目录失败")
return
}
// 配置lumberjack日志轮转
lumberjackLogger := &lumberjack.Logger{
Filename: logFile,
MaxSize: viper.GetInt("log.max_size"), // MB
MaxBackups: viper.GetInt("log.max_backups"), // 保留的旧日志文件数量
MaxAge: viper.GetInt("log.max_age"), // 天数
Compress: true, // 压缩旧日志文件
}
// 同时输出到控制台和文件(带日志切割)
multiWriter := io.MultiWriter(os.Stdout, lumberjackLogger)
logrus.SetOutput(multiWriter)
logrus.WithFields(logrus.Fields{
"file": logFile,
"max_size": viper.GetInt("log.max_size"),
"max_backups": viper.GetInt("log.max_backups"),
"max_age": viper.GetInt("log.max_age"),
}).Info("日志切割功能已启用")
}
// 日志文件路径为空时,保持默认输出到控制台,不创建任何目录
}

View File

@@ -3,19 +3,19 @@ package cmd
import (
"context"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"networkDev/database"
"networkDev/middleware"
"networkDev/server"
"networkDev/utils"
"networkDev/utils/logger"
"networkDev/web"
"NetworkAuth/database"
"NetworkAuth/middleware"
"NetworkAuth/server"
"NetworkAuth/services"
"NetworkAuth/utils"
"NetworkAuth/utils/logger"
"NetworkAuth/web"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
@@ -23,22 +23,14 @@ import (
"github.com/spf13/viper"
)
// ============================================================================
// 命令定义
// ============================================================================
// serverCmd 代表服务器命令
var serverCmd = &cobra.Command{
Use: "server",
Short: "启动HTTP服务",
Long: `启动一个简单的HTTP服务器监听配置文件中指定的端口。`,
Short: "启动网络授权服务",
Long: `启动 NetworkAuth HTTP 服务器,监听配置文件中指定的端口,提供 Web 管理界面和 API 服务`,
Run: runServer,
}
// ============================================================================
// 初始化函数
// ============================================================================
func init() {
// 将服务器命令添加到根命令
rootCmd.AddCommand(serverCmd)
@@ -48,10 +40,6 @@ func init() {
serverCmd.Flags().IntP("port", "p", 0, "服务器监听端口 (覆盖配置文件)")
}
// ============================================================================
// 主要函数
// ============================================================================
// runServer 运行HTTP服务器
func runServer(cmd *cobra.Command, args []string) {
// 获取配置
@@ -63,21 +51,47 @@ func runServer(cmd *cobra.Command, args []string) {
logger := logger.GetLogger()
logger.LogServerStart(host, port)
// 重定向 Gin 框架内部日志到 Logrus
// 这将捕获 [GIN-debug] 路由注册日志和其他框架级输出
gin.DefaultWriter = logger.WriterLevel(logrus.DebugLevel)
gin.DefaultErrorWriter = logger.WriterLevel(logrus.ErrorLevel)
// 设置 Gin 模式
if !viper.GetBool("server.dev_mode") {
gin.SetMode(gin.ReleaseMode)
}
// 初始化Redis如果配置存在失败不致命
utils.InitRedis()
// 初始化数据库(根据 viper 配置选择 SQLite 或 MySQL
// 如果初始化失败则回退并退出
if _, err := database.Init(); err != nil {
db, err := database.Init()
if err != nil {
logrus.WithError(err).Fatal("数据库初始化失败")
}
// 执行自动迁移(确保表结构存在)
if err := database.AutoMigrate(); err != nil {
logrus.WithError(err).Fatal("数据库自动迁移失败")
}
// 初始化默认系统设置(包含管理员账号)
if err := database.SeedDefaultSettings(); err != nil {
logrus.WithError(err).Fatal("默认系统设置初始化失败")
if db != nil {
// 执行自动迁移(确保表结构存在)
if err := database.AutoMigrate(); err != nil {
logrus.WithError(err).Fatal("数据库自动迁移失败")
}
// 初始化默认系统设置
if err := database.SeedDefaultSettings(); err != nil {
logrus.WithError(err).Fatal("默认系统设置初始化失败")
}
// 初始化加密管理器
// 从数据库设置中获取加密密钥
encryptionKey := services.GetSettingsService().GetEncryptionKey()
if err := utils.InitEncryption(encryptionKey); err != nil {
logrus.WithError(err).Fatal("加密管理器初始化失败")
}
// 启动日志清理定时任务
services.StartLogCleanupTask()
} else {
logrus.Info("系统处于未初始化状态,跳过数据库自动迁移和设置加载")
}
// 创建HTTP服务器
@@ -87,10 +101,6 @@ func runServer(cmd *cobra.Command, args []string) {
startServer(server)
}
// ============================================================================
// 辅助函数
// ============================================================================
// getServerHost 获取服务器监听地址
func getServerHost(cmd *cobra.Command) string {
if host, _ := cmd.Flags().GetString("host"); host != "" {
@@ -109,49 +119,51 @@ func getServerPort(cmd *cobra.Command) int {
// createHTTPServer 创建HTTP服务器
func createHTTPServer(addr string) *http.Server {
// 配置Gin模式和日志
configureGin()
// 创建 Gin 引擎
r := gin.New()
// 创建Gin引擎
router := gin.New()
// 添加恢复中间件
router.Use(gin.Recovery())
// 使用默认的 Recovery 中间件
r.Use(gin.Recovery())
// 添加日志中间件
router.Use(middleware.WrapHandler())
// 默认为 true只有显式设置为 false 才关闭
enableAccessLog := true
if viper.IsSet("server.access_log") {
enableAccessLog = viper.GetBool("server.access_log")
}
if enableAccessLog {
r.Use(middleware.WrapHandler())
}
// 添加开发模式中间件(统一管理开发模式功能)
router.Use(middleware.DevModeMiddleware(router))
// 添加安装检查中间件
r.Use(middleware.InstallCheckMiddleware())
// 加载模板
if err := loadTemplates(router); err != nil {
logrus.WithError(err).Fatal("模板加载失败")
// 添加维护模式中间件
r.Use(middleware.MaintenanceMiddleware())
// 添加开发模式中间件(统一管理开发模式功能:模板热重载等)
r.Use(middleware.DevModeMiddleware(r))
// 加载并设置 HTML 模板
if tmpl, err := web.ParseTemplates(); err == nil {
r.SetHTMLTemplate(tmpl)
} else {
logrus.WithError(err).Error("HTML模板加载失败")
}
// 注册路由
registerRoutes(router)
registerRoutes(r)
return &http.Server{
Addr: addr,
Handler: router,
Handler: r,
}
}
// loadTemplates 加载模板到Gin引擎
func loadTemplates(router *gin.Engine) error {
tmpl, err := web.ParseTemplates()
if err != nil {
return err
}
router.SetHTMLTemplate(tmpl)
return nil
}
// registerRoutes 注册HTTP路由
func registerRoutes(router *gin.Engine) {
func registerRoutes(r *gin.Engine) {
// 使用server包中的路由注册函数
server.RegisterRoutes(router)
server.RegisterRoutes(r)
}
// startServer 启动服务器并处理优雅关闭
@@ -174,8 +186,6 @@ func startServer(server *http.Server) {
// 等待中断信号
<-sigChan
// 清除终端上的 ^C 字符并移动光标到行首
fmt.Print("\r\033[K")
logger.Info("收到关闭信号,正在优雅关闭服务器...")
// 创建一个带超时的上下文
@@ -189,20 +199,3 @@ func startServer(server *http.Server) {
logger.LogServerStop()
}
}
// configureGin 配置Gin的全局设置
func configureGin() {
// 禁用Gin的颜色输出提高控制台兼容性
gin.DisableConsoleColor()
// 设置Gin的输出为丢弃因为我们使用自定义日志中间件
gin.DefaultWriter = io.Discard
gin.DefaultErrorWriter = io.Discard
// 根据配置设置Gin模式
if viper.GetString("app.mode") == "production" {
gin.SetMode(gin.ReleaseMode)
} else {
gin.SetMode(gin.DebugMode)
}
}

View File

@@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"io/fs"
"os"
"path/filepath"
log "github.com/sirupsen/logrus"
@@ -19,10 +18,11 @@ import (
// ServerConfig 服务器配置结构体
// 包含服务器运行相关的配置信息
type ServerConfig struct {
Host string `json:"host" mapstructure:"host"` // 服务器监听地址
Port int `json:"port" mapstructure:"port"` // 服务器监听端口
Dist string `json:"dist" mapstructure:"dist"` // 静态文件目录
DevMode bool `json:"dev_mode" mapstructure:"dev_mode"` // 开发模式(跳过验证码等)
Host string `json:"host" mapstructure:"host"` // 服务器监听地址
Port int `json:"port" mapstructure:"port"` // 服务器监听端口
Dist string `json:"dist" mapstructure:"dist"` // 静态文件目录
DevMode bool `json:"dev_mode" mapstructure:"dev_mode"` // 开发模式(跳过验证码等)
AccessLog bool `json:"access_log" mapstructure:"access_log"` // 是否输出访问日志
}
// DatabaseConfig 数据库配置结构体
@@ -71,31 +71,12 @@ type LogConfig struct {
MaxAge int `json:"max_age" mapstructure:"max_age"` // 日志文件保留天数
}
// CookieConfig Cookie配置结构体
// 包含Cookie相关的安全配置信息
type CookieConfig struct {
Secure bool `json:"secure" mapstructure:"secure"` // 是否只在HTTPS下发送Cookie
SameSite string `json:"same_site" mapstructure:"same_site"` // SameSite属性Strict/Lax/None
Domain string `json:"domain" mapstructure:"domain"` // Cookie域名
MaxAge int `json:"max_age" mapstructure:"max_age"` // Cookie最大存活时间
}
// SecurityConfig 安全配置结构体
// 包含应用程序安全相关的配置信息
type SecurityConfig struct {
JWTSecret string `json:"jwt_secret" mapstructure:"jwt_secret"` // JWT签名密钥
EncryptionKey string `json:"encryption_key" mapstructure:"encryption_key"` // 数据加密密钥
JWTRefresh int `json:"jwt_refresh" mapstructure:"jwt_refresh"` // JWT令牌刷新阈值小时
Cookie CookieConfig `json:"cookie" mapstructure:"cookie"` // Cookie配置
}
// AppConfig 应用配置结构体
type AppConfig struct {
Server ServerConfig `json:"server" mapstructure:"server"`
Database DatabaseConfig `json:"database" mapstructure:"database"`
Redis RedisConfig `json:"redis" mapstructure:"redis"`
Log LogConfig `json:"log" mapstructure:"log"`
Security SecurityConfig `json:"security" mapstructure:"security"`
}
// ============================================================================
@@ -106,10 +87,11 @@ type AppConfig struct {
func GetDefaultAppConfig() *AppConfig {
return &AppConfig{
Server: ServerConfig{
Host: "0.0.0.0",
Port: 8080,
Dist: "",
DevMode: false,
Host: "0.0.0.0",
Port: 8080,
Dist: "",
DevMode: false,
AccessLog: true,
},
Database: DatabaseConfig{
Type: "sqlite",
@@ -140,37 +122,9 @@ func GetDefaultAppConfig() *AppConfig {
MaxBackups: 5,
MaxAge: 30,
},
Security: SecurityConfig{
JWTSecret: "",
EncryptionKey: "",
JWTRefresh: 6,
Cookie: CookieConfig{
Secure: true,
SameSite: "Lax",
Domain: "",
MaxAge: 86400,
},
},
}
}
// GetSecureDefaultAppConfig 获取带有安全密钥的默认应用配置
func GetSecureDefaultAppConfig() (*AppConfig, error) {
config := GetDefaultAppConfig()
// 生成安全密钥
jwtSecret, encryptionKey, err := GenerateSecureKeys()
if err != nil {
return nil, err
}
// 设置安全密钥
config.Security.JWTSecret = jwtSecret
config.Security.EncryptionKey = encryptionKey
return config, nil
}
// Init 初始化配置文件
func Init(cfgFilePath string) {
viper.SetConfigFile(cfgFilePath)
@@ -180,18 +134,10 @@ func Init(cfgFilePath string) {
if err := viper.ReadInConfig(); err != nil {
var pathError *fs.PathError
if errors.As(err, &pathError) {
log.Warn("未找到配置文件,使用默认配置")
log.Warn("未找到配置文件,使用默认配置在内存中运行(需通过安装页面初始化)")
// 生成带有安全密钥的默认配置
defaultConfig, configErr := GetSecureDefaultAppConfig()
if configErr != nil {
log.WithFields(
log.Fields{
"err": configErr,
},
).Error("生成安全配置失败,使用基础默认配置")
defaultConfig = GetDefaultAppConfig()
}
// 使用默认配置
defaultConfig := GetDefaultAppConfig()
// 将配置结构体转换为JSON
configBytes, marshalErr := json.MarshalIndent(defaultConfig, "", " ")
@@ -204,25 +150,7 @@ func Init(cfgFilePath string) {
return
}
// 写入配置文件
err = os.WriteFile(cfgFilePath, configBytes, 0o644)
if err != nil {
log.WithFields(
log.Fields{
"err": err,
},
).Error("写入默认配置文件失败")
} else {
// 只显示配置文件名,不显示完整路径
fileName := filepath.Base(cfgFilePath)
log.WithFields(
log.Fields{
"file": fileName,
},
).Info("写入默认配置文件成功(已生成安全密钥)")
}
// 将配置加载到viper中
// 将配置加载到viper中但不写入文件
err = viper.ReadConfig(bytes.NewBuffer(configBytes))
if err != nil {
log.WithFields(
@@ -231,8 +159,10 @@ func Init(cfgFilePath string) {
},
).Error("读取默认配置失败")
} else {
log.Info("已成功读取默认配置")
log.Info("已成功在内存中加载默认配置")
}
// 不在这里写入文件了,安装完成后通过 UpdateConfig 写入
} else {
log.WithFields(
log.Fields{
@@ -241,7 +171,7 @@ func Init(cfgFilePath string) {
).Fatal("配置文件解析错误")
}
}
// 只显示配置文件名,不显示完整路径
configFile := viper.ConfigFileUsed()
if configFile != "" {
@@ -266,24 +196,62 @@ func Init(cfgFilePath string) {
}
}
// CreateDefaultConfig 创建默认配置文件
func CreateDefaultConfig(filePath string) error {
// 生成带有安全密钥的默认配置
defaultConfig, err := GetSecureDefaultAppConfig()
if err != nil {
log.WithFields(
log.Fields{
"err": err,
},
).Error("生成安全配置失败,使用基础默认配置")
defaultConfig = GetDefaultAppConfig()
}
// 将配置结构体转换为JSON
configBytes, err := json.MarshalIndent(defaultConfig, "", " ")
if err != nil {
// UpdateConfig 更新配置文件
// 接收一个回调函数,在回调函数中修改配置对象,然后保存到文件
func UpdateConfig(updateFn func(*AppConfig)) error {
// 1. 获取当前配置
var currentConfig AppConfig
if err := viper.Unmarshal(&currentConfig); err != nil {
return err
}
return os.WriteFile(filePath, configBytes, 0o644)
// 2. 执行更新回调
updateFn(&currentConfig)
// 3. 将更新后的配置写回 Viper
// 注意:这里需要手动设置回 viper否则 viper.WriteConfig() 写入的还是旧配置
// 也可以直接序列化 currentConfig 写入文件
// 更新 Server 配置
viper.Set("server.host", currentConfig.Server.Host)
viper.Set("server.port", currentConfig.Server.Port)
viper.Set("server.dist", currentConfig.Server.Dist)
viper.Set("server.dev_mode", currentConfig.Server.DevMode)
viper.Set("server.access_log", currentConfig.Server.AccessLog)
// 更新 Database 配置
viper.Set("database.type", currentConfig.Database.Type)
viper.Set("database.mysql.host", currentConfig.Database.MySQL.Host)
viper.Set("database.mysql.port", currentConfig.Database.MySQL.Port)
viper.Set("database.mysql.username", currentConfig.Database.MySQL.Username)
viper.Set("database.mysql.password", currentConfig.Database.MySQL.Password)
viper.Set("database.mysql.database", currentConfig.Database.MySQL.Database)
viper.Set("database.mysql.charset", currentConfig.Database.MySQL.Charset)
viper.Set("database.mysql.max_idle_conns", currentConfig.Database.MySQL.MaxIdleConns)
viper.Set("database.mysql.max_open_conns", currentConfig.Database.MySQL.MaxOpenConns)
viper.Set("database.sqlite.path", currentConfig.Database.SQLite.Path)
// 更新 Redis 配置
viper.Set("redis.host", currentConfig.Redis.Host)
viper.Set("redis.port", currentConfig.Redis.Port)
viper.Set("redis.password", currentConfig.Redis.Password)
viper.Set("redis.db", currentConfig.Redis.DB)
// 更新 Log 配置
viper.Set("log.level", currentConfig.Log.Level)
viper.Set("log.file", currentConfig.Log.File)
viper.Set("log.max_size", currentConfig.Log.MaxSize)
viper.Set("log.max_backups", currentConfig.Log.MaxBackups)
viper.Set("log.max_age", currentConfig.Log.MaxAge)
// 4. 保存到文件
if err := viper.WriteConfig(); err != nil {
// 如果配置文件不存在(比如只用了默认配置没写文件),则尝试 SafeWriteConfig
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
return viper.SafeWriteConfig()
}
return err
}
return nil
}

View File

@@ -61,11 +61,6 @@ func validateConfig(config *AppConfig) error {
return fmt.Errorf("日志配置错误: %w", err)
}
// 验证安全配置
if err := validateSecurityConfig(&config.Security); err != nil {
return fmt.Errorf("安全配置错误: %w", err)
}
return nil
}
@@ -176,7 +171,6 @@ func validateLogConfig(config *LogConfig) error {
}
}
}
// 当日志文件路径为空时,不进行目录检查和创建
// 验证日志轮转配置
if config.MaxSize <= 0 {
@@ -192,32 +186,6 @@ func validateLogConfig(config *LogConfig) error {
return nil
}
// validateSecurityConfig 验证安全配置
func validateSecurityConfig(config *SecurityConfig) error {
if len(config.JWTSecret) < 16 {
return errors.New("JWT密钥长度不能少于16个字符")
}
if len(config.EncryptionKey) < 16 {
return errors.New("加密密钥长度不能少于16个字符")
}
if config.JWTRefresh < 1 || config.JWTRefresh > 23 {
return errors.New("JWT令牌刷新阈值必须在1-23小时之间")
}
// 检查是否使用默认值(生产环境警告)
if strings.Contains(config.JWTSecret, "default") {
log.Warn("检测到使用默认JWT密钥生产环境请更换为安全的密钥")
}
if strings.Contains(config.EncryptionKey, "default") {
log.Warn("检测到使用默认加密密钥,生产环境请更换为安全的密钥")
}
return nil
}
// contains 检查切片是否包含指定元素
func contains(slice []string, item string) bool {
for _, s := range slice {

View File

@@ -7,5 +7,5 @@ package constants
// 应用程序版本信息
const (
// AppVersion 应用程序版本号
AppVersion = "0.3.0"
AppVersion = "1.0.3"
)

View File

@@ -3,9 +3,9 @@ package admin
import (
"encoding/hex"
"net/http"
"networkDev/controllers"
"networkDev/models"
"networkDev/utils/encrypt"
"NetworkAuth/controllers"
"NetworkAuth/models"
"NetworkAuth/utils/encrypt"
"strconv"
"strings"

View File

@@ -1,12 +1,12 @@
package admin
import (
"NetworkAuth/controllers"
"NetworkAuth/models"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"net/http"
"networkDev/controllers"
"networkDev/models"
"strconv"
"strings"
@@ -247,7 +247,7 @@ func AppResetSecretHandler(c *gin.Context) {
return
}
logrus.WithField("app_uuid", app.UUID).Info("Successfully reset app secret")
logrus.WithField("app_uuid", app.UUID).Debug("Successfully reset app secret")
c.JSON(http.StatusOK, gin.H{
"code": 0,
@@ -295,7 +295,7 @@ func AppCreateHandler(c *gin.Context) {
"download_type": req.DownloadType,
"download_url": req.DownloadURL,
"force_update": req.ForceUpdate,
}).Info("Received app create request")
}).Debug("Received app create request")
// 创建应用
app := models.App{
@@ -344,8 +344,9 @@ func AppCreateHandler(c *gin.Context) {
tx.Rollback()
logrus.WithError(err).Error("Failed to create app")
c.JSON(http.StatusInternalServerError, gin.H{
"code": 1,
"msg": "创建应用失败",
"code": 1,
"msg": "创建应用失败: " + err.Error(),
"error": err.Error(),
})
return
}
@@ -384,7 +385,7 @@ func AppCreateHandler(c *gin.Context) {
return
}
logrus.WithField("app_uuid", app.UUID).Info("Successfully created app with default APIs")
logrus.WithField("app_uuid", app.UUID).Debug("Successfully created app with default APIs")
c.JSON(http.StatusOK, gin.H{
"code": 0,
@@ -460,7 +461,7 @@ func AppUpdateHandler(c *gin.Context) {
return
}
logrus.WithField("app_id", app.ID).Info("Successfully updated app")
logrus.WithField("app_id", app.ID).Debug("Successfully updated app")
c.JSON(http.StatusOK, gin.H{
"code": 0,
@@ -557,7 +558,7 @@ func AppDeleteHandler(c *gin.Context) {
logrus.WithFields(logrus.Fields{
"app_id": app.ID,
"app_uuid": app.UUID,
}).Info("Successfully deleted app and related APIs")
}).Debug("Successfully deleted app and related APIs")
c.JSON(http.StatusOK, gin.H{
"code": 0,
@@ -629,7 +630,7 @@ func AppUpdateAppDataHandler(c *gin.Context) {
logrus.WithFields(logrus.Fields{
"app_uuid": req.UUID,
"app_name": app.Name,
}).Info("App data updated successfully")
}).Debug("App data updated successfully")
c.JSON(http.StatusOK, gin.H{
"code": 0,
@@ -701,7 +702,7 @@ func AppUpdateAnnouncementHandler(c *gin.Context) {
logrus.WithFields(logrus.Fields{
"app_uuid": req.UUID,
"app_name": app.Name,
}).Info("App announcement updated successfully")
}).Debug("App announcement updated successfully")
c.JSON(http.StatusOK, gin.H{
"code": 0,
@@ -870,7 +871,7 @@ func AppUpdateMultiConfigHandler(c *gin.Context) {
logrus.WithFields(logrus.Fields{
"app_uuid": req.UUID,
"app_name": app.Name,
}).Info("App multi config updated successfully")
}).Debug("App multi config updated successfully")
c.JSON(http.StatusOK, gin.H{
"code": 0,
@@ -1023,7 +1024,7 @@ func AppUpdateBindConfigHandler(c *gin.Context) {
logrus.WithFields(logrus.Fields{
"app_uuid": req.UUID,
"app_name": app.Name,
}).Info("App bind config updated successfully")
}).Debug("App bind config updated successfully")
c.JSON(http.StatusOK, gin.H{
"code": 0,
@@ -1161,7 +1162,7 @@ func AppUpdateRegisterConfigHandler(c *gin.Context) {
logrus.WithFields(logrus.Fields{
"app_uuid": req.UUID,
"app_name": app.Name,
}).Info("App register config updated successfully")
}).Debug("App register config updated successfully")
c.JSON(http.StatusOK, gin.H{
"code": 0,
@@ -1265,7 +1266,7 @@ func AppsBatchDeleteHandler(c *gin.Context) {
logrus.WithFields(logrus.Fields{
"app_ids": req.IDs,
"app_uuids": appUUIDs,
}).Info("Successfully batch deleted apps and related APIs")
}).Debug("Successfully batch deleted apps and related APIs")
c.JSON(http.StatusOK, gin.H{
"code": 0,

View File

@@ -1,16 +1,16 @@
package admin
import (
"NetworkAuth/controllers"
"NetworkAuth/database"
"NetworkAuth/models"
"NetworkAuth/services"
"NetworkAuth/utils"
"fmt"
"net/http"
"strings"
"time"
"networkDev/controllers"
"networkDev/database"
"networkDev/models"
"networkDev/utils"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/spf13/viper"
@@ -40,9 +40,9 @@ func LoginPageHandler(c *gin.Context) {
// 获取或生成CSRF令牌
var token string
if existingToken := utils.GetCSRFTokenFromCookie(c); existingToken != "" {
// 重用现有的Cookie令牌
token = existingToken
// 尝试从Cookie获取
if cookie, err := c.Cookie(CSRFCookieName); err == nil && cookie != "" {
token = cookie
} else {
// 生成新的CSRF令牌并设置到Cookie
newToken, err := utils.GenerateCSRFToken()
@@ -53,21 +53,14 @@ func LoginPageHandler(c *gin.Context) {
return
}
token = newToken
utils.SetCSRFToken(c, token)
setCSRFToken(c, token)
}
// 准备模板数据
extraData := gin.H{
"Title": "管理员登录",
}
data := authBaseController.GetDefaultTemplateData()
data["Title"] = "管理员登录"
data["CSRFToken"] = token
// 合并额外数据
for key, value := range extraData {
data[key] = value
}
c.HTML(http.StatusOK, "login.html", data)
}
@@ -76,21 +69,30 @@ func LoginPageHandler(c *gin.Context) {
// ============================================================================
// LoginHandler 管理员登录接口
// - 接收JSON: {username, password}
// - 接收JSON: {username, password, captcha, csrf_token}
// - 验证CSRF令牌
// - 验证验证码
// - 验证用户存在与密码正确性
// - 仅允许 Role=0 的管理员登录
// - 成功后设置简单的会话Cookie后续可切换为JWT或更完善的Session
// - 仅允许管理员登录
// - 成功后设置JWT Cookie
func LoginHandler(c *gin.Context) {
var body struct {
Username string `json:"username"`
Password string `json:"password"`
Captcha string `json:"captcha"`
Username string `json:"username"`
Password string `json:"password"`
Captcha string `json:"captcha"`
CSRFToken string `json:"csrf_token"`
}
if !authBaseController.BindJSON(c, &body) {
return
}
// 1. 验证CSRF令牌 (Gin 方式)
if !validateCSRFToken(c, body.CSRFToken) {
authBaseController.HandleValidationError(c, "CSRF令牌验证失败")
return
}
if !authBaseController.ValidateRequired(c, map[string]interface{}{
"用户名": body.Username,
"密码": body.Password,
@@ -101,95 +103,133 @@ func LoginHandler(c *gin.Context) {
// 验证验证码
if !VerifyCaptcha(c, body.Captcha) {
recordLoginLog(c, body.Username, 0, "验证码错误")
authBaseController.HandleValidationError(c, "验证码错误")
return
}
// 获取数据库连接
db, ok := authBaseController.GetDB(c)
if !ok {
return
}
// 获取系统设置服务
settingsService := services.GetSettingsService()
adminUsername := settingsService.GetString("admin_username", "admin")
adminPasswordHash := settingsService.GetString("admin_password", "")
adminPasswordSalt := settingsService.GetString("admin_password_salt", "")
// 通过前缀匹配一次性获取所有管理员相关设置
var adminSettings []models.Settings
if err := db.Where("name LIKE ?", "admin_%").Find(&adminSettings).Error; err != nil {
authBaseController.HandleValidationError(c, "用户不存在或密码错误")
return
}
// 将设置转换为map便于查找
settingsMap := make(map[string]string)
for _, setting := range adminSettings {
settingsMap[setting.Name] = setting.Value
}
// 检查必要的设置是否存在
adminUsername, hasUsername := settingsMap["admin_username"]
adminPassword, hasPassword := settingsMap["admin_password"]
adminPasswordSalt, hasSalt := settingsMap["admin_password_salt"]
if !hasUsername || !hasPassword || !hasSalt {
authBaseController.HandleValidationError(c, "用户不存在或密码错误")
// 验证密码为空的情况(首次登录需要初始化)
if adminPasswordHash == "" || adminPasswordSalt == "" {
recordLoginLog(c, body.Username, 0, "管理员账号未初始化")
authBaseController.HandleInternalError(c, "管理员账号未初始化,请联系系统管理员", nil)
return
}
// 验证用户名
if body.Username != adminUsername {
recordLoginLog(c, body.Username, 0, "用户名错误")
authBaseController.HandleValidationError(c, "用户不存在或密码错误")
return
}
// 验证密码为空的情况(首次登录需要初始化
if adminPassword == "" || adminPasswordSalt == "" {
authBaseController.HandleInternalError(c, "管理员账号未初始化,请联系系统管理员", nil)
return
}
// 使用盐值验证密码
if !utils.VerifyPasswordWithSalt(body.Password, adminPasswordSalt, adminPassword) {
// 验证密码(使用盐值校验
if !utils.VerifyPasswordWithSalt(body.Password, adminPasswordSalt, adminPasswordHash) {
recordLoginLog(c, body.Username, 0, "密码错误")
authBaseController.HandleValidationError(c, "用户不存在或密码错误")
return
}
// 创建虚拟用户对象用于生成JWT令牌
adminUser := models.User{
Username: adminUsername,
Password: adminPassword,
PasswordSalt: adminPasswordSalt,
}
// 生成JWT令牌
token, err := generateJWTTokenForAdmin(adminUser)
token, err := generateJWTTokenForAdmin(body.Username, adminPasswordHash)
if err != nil {
recordLoginLog(c, body.Username, 0, "生成令牌失败")
authBaseController.HandleInternalError(c, "生成令牌失败", err)
return
}
// 设置JWT Cookie使用安全配置
cookie := utils.CreateSecureCookie("admin_session", token, utils.GetDefaultCookieMaxAge())
// 设置JWT CookieHttpOnly安全
// 使用系统配置的Cookie参数
secure, sameSite, domain, maxAge := settingsService.GetCookieConfig()
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, "登录成功")
authBaseController.HandleSuccess(c, "登录成功", gin.H{
"redirect": "/admin",
})
}
// recordLoginLog 记录登录日志
// status: 1-成功, 0-失败
func recordLoginLog(c *gin.Context, username string, status int, message string) {
db, err := database.GetDB()
if err != nil {
// 记录日志失败不应影响主流程,但可以记录到系统日志
fmt.Printf("Failed to connect to database for login log: %v\n", err)
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 {
fmt.Printf("Failed to create login log: %v\n", err)
}
}
// LogoutHandler 管理员登出
// - 清理JWT Cookie
// - 清理JWT Cookie会话
// - 确保令牌完全失效
func LogoutHandler(c *gin.Context) {
// 清理JWT Cookie
clearInvalidJWTCookie(c)
// 可选将JWT令牌加入黑名单需要Redis或数据库支持
// 这里可以实现JWT黑名单机制
authBaseController.HandleSuccess(c, "已退出登录", gin.H{
"redirect": "/admin/login",
})
}
// ============================================================================
// CSRF 相关辅助函数
// ============================================================================
const (
CSRFCookieName = "csrf_token"
CSRFHeaderName = "X-CSRF-Token"
CSRFFormField = "csrf_token"
)
// setCSRFToken 设置CSRF令牌到Cookie (Gin适配)
func setCSRFToken(c *gin.Context, token string) {
c.SetCookie(CSRFCookieName, token, 3600*24, "/", "", false, false)
c.Header(CSRFHeaderName, token)
}
// validateCSRFToken 验证CSRF令牌 (Gin适配)
func validateCSRFToken(c *gin.Context, requestToken string) bool {
// 获取Cookie中的令牌
cookie, err := c.Cookie(CSRFCookieName)
if err != nil || cookie == "" {
return false
}
cookieToken := cookie
// 如果请求体中没有提供token尝试从Header获取
if requestToken == "" {
requestToken = c.GetHeader(CSRFHeaderName)
}
if requestToken == "" {
return false
}
// 使用常量时间比较
return strings.Compare(cookieToken, requestToken) == 0
}
// ============================================================================
// 辅助函数
// ============================================================================
@@ -198,14 +238,27 @@ func LogoutHandler(c *gin.Context) {
// - 统一的Cookie清理函数确保一致性
// - 在JWT校验失败时自动调用提升安全性和用户体验
func clearInvalidJWTCookie(c *gin.Context) {
cookie := utils.CreateExpiredCookie("admin_session")
_, _, domain, _ := services.GetSettingsService().GetCookieConfig()
cookie := utils.CreateExpiredCookie("admin_session", domain)
c.SetCookie(cookie.Name, cookie.Value, cookie.MaxAge, cookie.Path, cookie.Domain, cookie.Secure, cookie.HttpOnly)
}
// getJWTSecret 动态获取当前的JWT密钥
// 修复安全漏洞:确保每次都从最新配置中获取密钥,而不是使用启动时的全局变量
func getJWTSecret() []byte {
return []byte(viper.GetString("security.jwt_secret"))
// 1. 尝试从数据库设置获取
settingsService := services.GetSettingsService()
if secret := settingsService.GetJWTSecret(); secret != "" {
return []byte(secret)
}
// 2. 尝试从配置文件获取(兼容旧配置)
if secret := viper.GetString("security.jwt_secret"); secret != "" {
return []byte(secret)
}
// 3. 使用默认不安全密钥(仅开发环境)
return []byte("default-insecure-jwt-secret")
}
// ============================================================================
@@ -215,27 +268,40 @@ func getJWTSecret() []byte {
// JWTClaims JWT载荷结构体
type JWTClaims struct {
Username string `json:"username"`
UUID string `json:"uuid"` // 添加虚拟角色UUID
Role int `json:"role"` // 添加虚拟角色
PasswordHash string `json:"password_hash"` // 密码哈希摘要,用于验证密码是否被修改
jwt.RegisteredClaims
}
// generateJWTTokenForAdmin 生成管理员JWT令牌
// - 包含管理员UUID、用户名信息
// - 设置24小时过期时间
// - 包含管理员用户名信息和密码哈希
// - 设置过期时间
// - 使用HMAC-SHA256签名
func generateJWTTokenForAdmin(adminUser models.User) (string, error) {
func generateJWTTokenForAdmin(username, passwordHash string) (string, error) {
// 生成密码哈希摘要使用SHA256
passwordHashDigest := utils.GenerateSHA256Hash(adminUser.Password)
// 注意:传入的 passwordHash 已经是数据库存的 Hash这里我们再次 Hash 还是直接用?
// atomicLibrary 的实现是: utils.GenerateSHA256Hash(adminUser.Password)
// 这里我们直接用数据库里的 Hash 值作为 Token 的一部分即可,或者对它再 Hash 一次。
// 为了与 validateAdminPasswordHash 对应,我们需要知道验证时怎么比对。
// validateAdminPasswordHash: currentPasswordHash := utils.GenerateSHA256Hash(adminPassword.Value)
// 所以这里也应该对数据库里的值进行 Hash。
passwordHashDigest := utils.GenerateSHA256Hash(passwordHash)
// 获取虚拟管理员UUID (NetworkAuth 项目默认为 admin-uuid-001)
adminUUID := services.GetSettingsService().GetString("admin_uuid", "admin-uuid-001")
claims := JWTClaims{
Username: adminUser.Username,
Username: username,
UUID: adminUUID,
Role: 0, // 0表示超级管理员
PasswordHash: passwordHashDigest, // 包含密码哈希摘要
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(services.GetSettingsService().GetJWTExpire()) * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "凌动技术",
Subject: adminUser.Username,
Issuer: "NetworkAuth",
Subject: username,
},
}
@@ -303,7 +369,7 @@ func validateAdminPasswordHash(claims *JWTClaims, c *gin.Context) bool {
return true
}
// IsAdminAuthenticated 判断管理员是否已认证(导出
// IsAdminAuthenticated 判断管理员是否已认证(Gin版本
// - 检查admin_session Cookie中的JWT令牌
// - 验证令牌签名、过期时间和用户角色
func IsAdminAuthenticated(c *gin.Context) bool {
@@ -318,12 +384,48 @@ func IsAdminAuthenticated(c *gin.Context) bool {
return false
}
// 注释:由于这是管理员专用认证函数,不需要额外的角色验证
// 验证密码哈希
return validateAdminPasswordHash(claims, c)
}
// IsAdminAuthenticatedHttp 判断管理员是否已认证HTTP兼容版本
// 保留此方法以兼容未迁移的 Handler
func IsAdminAuthenticatedHttp(r *http.Request) bool {
cookie, err := r.Cookie("admin_session")
if err != nil || cookie.Value == "" {
return false
}
// 解析并验证JWT令牌
claims, err := parseJWTToken(cookie.Value)
if err != nil {
return false
}
// 注意HTTP 版本无法方便地获取 ClientIP 用于日志,且无法使用 Gin Context 的功能
// 这里仅做基本的 Token 验证。如果 Token 包含了 PasswordHash这里也会解析出来。
// 但验证 PasswordHash 需要 DB 访问。
// 为了完整性,我们应该也验证 PasswordHash。
// 这里的 ClientIP 只能从 r.RemoteAddr 获取。
db, err := database.GetDB()
if err != nil {
return false
}
var adminPassword models.Settings
if err := db.Where("name = ?", "admin_password").First(&adminPassword).Error; err != nil {
return false
}
currentPasswordHash := utils.GenerateSHA256Hash(adminPassword.Value)
if claims.PasswordHash != currentPasswordHash {
return false
}
return true
}
// IsAdminAuthenticatedWithCleanup 带自动清理功能的JWT校验函数
// - 当JWT校验失败时自动清理失效的Cookie
// - 适用于API接口等需要清理失效令牌的场景
@@ -341,8 +443,6 @@ func IsAdminAuthenticatedWithCleanup(c *gin.Context) bool {
return false
}
// 注释:由于这是管理员专用认证函数,不需要额外的角色验证
// 验证密码哈希
if !validateAdminPasswordHash(claims, c) {
clearInvalidJWTCookie(c)
@@ -352,23 +452,18 @@ func IsAdminAuthenticatedWithCleanup(c *gin.Context) bool {
return true
}
// GetCurrentAdminUser 获取当前登录的管理员用户信息
// - 从JWT令牌中提取用户信息
// - 自动刷新接近过期的令牌剩余时间少于6小时时刷新
// - 返回用户ID、用户名和角色
func GetCurrentAdminUser(c *gin.Context) (*JWTClaims, error) {
cookie, err := getJWTCookie(c)
// GetCurrentAdminUser 获取当前登录的管理员用户信息 (HTTP 兼容版)
func GetCurrentAdminUser(r *http.Request) (*JWTClaims, error) {
cookie, err := r.Cookie("admin_session")
if err != nil {
return nil, fmt.Errorf("未找到会话信息")
}
claims, err := parseJWTToken(cookie)
claims, err := parseJWTToken(cookie.Value)
if err != nil {
return nil, fmt.Errorf("无效的会话信息")
}
// 注释:由于这是管理员专用函数,不需要额外的角色验证
return claims, nil
}
@@ -394,25 +489,56 @@ func GetCurrentAdminUserWithRefresh(c *gin.Context) (*JWTClaims, bool, error) {
// 检查是否需要刷新令牌
refreshed := false
refreshThreshold := time.Duration(viper.GetInt("security.jwt_refresh")) * time.Hour
// 动态获取刷新阈值默认剩余时间少于6小时刷新
refreshThresholdHours := services.GetSettingsService().GetJWTRefresh()
if refreshThresholdHours <= 0 {
refreshThresholdHours = 6 // 默认值
}
refreshThreshold := time.Duration(refreshThresholdHours) * time.Hour
// 动态获取JWT总有效期
expireHours := services.GetSettingsService().GetJWTExpire()
if expireHours <= 0 {
expireHours = 24 // 默认值
}
// 动态获取Cookie配置用于更新Cookie过期时间
secure, sameSite, domain, maxAge := services.GetSettingsService().GetCookieConfig()
// 1. 默认情况下每次请求都更新Cookie的过期时间滑动过期
tokenToSet := cookie
shouldUpdateCookie := true
// 2. 检查是否需要刷新JWT令牌生成新的Token
if time.Until(claims.ExpiresAt.Time) < refreshThreshold {
adminUser := models.User{
Username: claims.Username,
}
newToken, err := generateJWTTokenForAdmin(adminUser)
// 获取当前的 PasswordHash
db, _ := database.GetDB()
var adminPassword models.Settings
db.Where("name = ?", "admin_password").First(&adminPassword)
// 使用新的有效期生成令牌
newToken, err := generateJWTTokenForAdmin(claims.Username, adminPassword.Value)
if err == nil {
c.SetCookie("admin_session", newToken, utils.GetDefaultCookieMaxAge(), "/", "", false, true)
tokenToSet = newToken
refreshed = true
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(24 * time.Hour))
// 更新当前claims的过期时间
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Duration(expireHours) * time.Hour))
claims.IssuedAt = jwt.NewNumericDate(time.Now())
}
}
// 3. 执行Cookie更新
if shouldUpdateCookie {
cookieObj := utils.CreateSecureCookie("admin_session", tokenToSet, maxAge, domain, secure, sameSite)
c.SetCookie(cookieObj.Name, cookieObj.Value, cookieObj.MaxAge, cookieObj.Path, cookieObj.Domain, cookieObj.Secure, cookieObj.HttpOnly)
}
return claims, refreshed, nil
}
// AdminAuthRequired 管理员认证拦截中间件
// AdminAuthRequired 管理员认证拦截中间件 (Gin Middleware)
// - 未登录:重定向到 /admin/login
// - 已登录:自动刷新接近过期的令牌,然后放行到后续处理器
func AdminAuthRequired() gin.HandlerFunc {
@@ -424,8 +550,6 @@ func AdminAuthRequired() gin.HandlerFunc {
clearInvalidJWTCookie(c)
// 中文注释区分普通页面请求与AJAX/JSON请求
// - 对 AJAX/JSON直接返回 401 JSON便于前端处理如提示重新登录
// - 对普通页面:保持原有重定向到登录页
accept := c.GetHeader("Accept")
xrw := strings.ToLower(strings.TrimSpace(c.GetHeader("X-Requested-With")))
if strings.Contains(accept, "application/json") || xrw == "xmlhttprequest" {
@@ -448,6 +572,11 @@ func AdminAuthRequired() gin.HandlerFunc {
_ = claims // 避免未使用变量警告
}
// 将解析出的用户信息存入上下文,供后续处理使用
c.Set("admin_uuid", claims.UUID)
c.Set("admin_username", claims.Username)
c.Set("admin_role", claims.Role)
c.Next()
}
}

View File

@@ -7,11 +7,9 @@ import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"networkDev/controllers"
"networkDev/middleware"
"networkDev/utils"
"NetworkAuth/middleware"
"github.com/gin-gonic/gin"
"github.com/mojocn/base64Captcha"
)
@@ -19,9 +17,6 @@ import (
// 全局变量
// ============================================================================
// 创建基础控制器实例
var captchaBaseController = controllers.NewBaseController()
// 全局验证码存储器
var store = base64Captcha.DefaultMemStore
@@ -49,7 +44,7 @@ func CaptchaHandler(c *gin.Context) {
// 使用crypto/rand生成安全的随机数
randomNum, err := secureRandomInt(3)
if err != nil {
captchaBaseController.HandleInternalError(c, "生成随机数失败", err)
c.String(http.StatusInternalServerError, "生成随机数失败")
return
}
captchaLength := 4 + randomNum // 4-6位随机长度
@@ -62,37 +57,31 @@ func CaptchaHandler(c *gin.Context) {
ShowLineOptions: 2 | 4,
Length: captchaLength,
Source: "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789", // 混合大小写字母和数字,去除易混淆字符
Fonts: []string{"wqy-microhei.ttc"},
}
// 生成验证码
captcha := base64Captcha.NewCaptcha(&driver, store)
id, b64s, _, err := captcha.Generate()
if err != nil {
captchaBaseController.HandleInternalError(c, "生成验证码失败", err)
c.String(http.StatusInternalServerError, "生成验证码失败")
return
}
// 将验证码ID存储到session中这里简化处理实际项目中应该使用更安全的方式
// 设置cookie来存储验证码ID
cookie := utils.CreateSecureCookie("captcha_id", id, 300) // 5分钟过期
c.SetCookie(cookie.Name, cookie.Value, cookie.MaxAge, cookie.Path, cookie.Domain, cookie.Secure, cookie.HttpOnly)
// 将验证码ID存储到Cookie中
c.SetCookie("captcha_id", id, 300, "/", "", false, true)
// 解码base64图片数据并返回
// 设置响应头
c.Header("Content-Type", "image/png")
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")
// 直接返回base64编码的图片数据让浏览器解析
// 但是我们需要返回实际的图片数据所以需要解码base64
// 去掉data:image/png;base64,前缀
b64s = strings.TrimPrefix(b64s, "data:image/png;base64,")
imgData, err := base64.StdEncoding.DecodeString(b64s)
if err != nil {
captchaBaseController.HandleInternalError(c, "解码验证码图片失败", err)
c.String(http.StatusInternalServerError, "解码验证码图片失败")
return
}
@@ -110,11 +99,7 @@ func VerifyCaptcha(c *gin.Context, captchaValue string) bool {
// 从cookie中获取验证码ID
captchaId, err := c.Cookie("captcha_id")
if err != nil {
return false
}
if captchaId == "" {
if err != nil || captchaId == "" {
return false
}
@@ -125,7 +110,7 @@ func VerifyCaptcha(c *gin.Context, captchaValue string) bool {
return true
}
// 如果原始值验证失败,尝试小写验证(因为显示的是大小写混合,但允许用户输入小写)
// 如果原始值验证失败,尝试小写验证
if store.Verify(captchaId, strings.ToLower(captchaValue), false) {
// 验证成功后删除验证码
store.Verify(captchaId, strings.ToLower(captchaValue), true)
@@ -139,22 +124,3 @@ func VerifyCaptcha(c *gin.Context, captchaValue string) bool {
return false
}
// CaptchaAPIHandler 验证码API接口可选用于AJAX验证
// POST /admin/api/captcha/verify - 验证验证码
func CaptchaAPIHandler(c *gin.Context) {
var body struct {
Captcha string `json:"captcha"`
}
if !captchaBaseController.BindJSON(c, &body) {
return
}
isValid := VerifyCaptcha(c, body.Captcha)
if isValid {
captchaBaseController.HandleSuccess(c, "验证码正确", nil)
} else {
captchaBaseController.HandleValidationError(c, "验证码错误")
}
}

View File

@@ -1,9 +1,9 @@
package admin
import (
"NetworkAuth/controllers"
"NetworkAuth/models"
"net/http"
"networkDev/controllers"
"networkDev/models"
"regexp"
"strconv"
"strings"
@@ -299,7 +299,7 @@ func FunctionDeleteHandler(c *gin.Context) {
return
}
logrus.WithField("function_id", req.ID).Info("Successfully deleted function")
logrus.WithField("function_id", req.ID).Debug("Successfully deleted function")
functionBaseController.HandleSuccess(c, "删除成功", nil)
}
@@ -331,7 +331,7 @@ func FunctionsBatchDeleteHandler(c *gin.Context) {
return
}
logrus.WithField("function_ids", req.IDs).Info("Successfully batch deleted functions")
logrus.WithField("function_ids", req.IDs).Debug("Successfully batch deleted functions")
functionBaseController.HandleSuccess(c, "批量删除成功", nil)
}

View File

@@ -1,14 +1,15 @@
package admin
import (
"NetworkAuth/constants"
"NetworkAuth/controllers"
"NetworkAuth/middleware"
"NetworkAuth/models"
"NetworkAuth/services"
"NetworkAuth/utils"
"NetworkAuth/utils/timeutil"
"net/http"
"networkDev/constants"
"networkDev/controllers"
"networkDev/middleware"
"networkDev/models"
"networkDev/services"
"networkDev/utils"
"networkDev/utils/timeutil"
"strconv"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
@@ -83,11 +84,8 @@ func AdminLayoutHandler(c *gin.Context) {
data["CSRFToken"] = token
// 从数据库读取站点标题,如果失败则使用默认值
if db, ok := handlersBaseController.GetDB(c); ok {
if siteTitle, err := services.FindSettingByName("site_title", db); err == nil && siteTitle != nil {
data["Title"] = siteTitle.Value
}
}
settingsSvc := services.GetSettingsService()
data["Title"] = settingsSvc.GetString("site_title", "后台管理")
// 合并其他数据(如果有的话)
extraData := gin.H{}
@@ -192,3 +190,45 @@ func DashboardStatsHandler(c *gin.Context) {
handlersBaseController.HandleSuccess(c, "ok", data)
}
// DashboardLoginLogsHandler 获取管理员最近登录日志
func DashboardLoginLogsHandler(c *gin.Context) {
db, ok := handlersBaseController.GetDB(c)
if !ok {
return
}
// 获取分页参数
pageStr := c.DefaultQuery("page", "1")
limitStr := c.DefaultQuery("limit", "10")
page, _ := strconv.Atoi(pageStr)
limit, _ := strconv.Atoi(limitStr)
if page < 1 {
page = 1
}
if limit < 1 || limit > 100 {
limit = 10
}
offset := (page - 1) * limit
var total int64
// 当前模型的 LoginLog 本身就是专用于 admin 的登录日志模型(没有 type 字段),所以直接查询全部即可
query := db.Model(&models.LoginLog{})
if err := query.Count(&total).Error; err != nil {
handlersBaseController.HandleInternalError(c, "获取登录日志总数失败", err)
return
}
var logs []models.LoginLog
if err := query.Order("created_at desc").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
handlersBaseController.HandleInternalError(c, "获取登录日志列表失败", err)
return
}
data := gin.H{
"total": total,
"list": logs,
}
handlersBaseController.HandleSuccess(c, "获取登录日志成功", data)
}

View File

@@ -0,0 +1,174 @@
package admin
import (
"NetworkAuth/controllers"
"NetworkAuth/models"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
)
// ============================================================================
// 全局变量
// ============================================================================
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")
}
}
// LoginLogsFragmentHandler 登录日志页面片段处理器
func LoginLogsFragmentHandler(c *gin.Context) {
c.HTML(http.StatusOK, "login_logs.html", gin.H{
"Title": "登录日志",
})
}
// ============================================================================
// API处理器
// ============================================================================
// LoginLogsListHandler 登录日志列表API处理器
func LoginLogsListHandler(c *gin.Context) {
// 获取分页参数
page, _ := strconv.Atoi(c.Query("page"))
if page <= 0 {
page = 1
}
limit, _ := strconv.Atoi(c.Query("limit"))
if limit <= 0 {
limit = 10
}
// 构建查询
db, ok := loginLogBaseController.GetDB(c)
if !ok {
return
}
var logs []models.LoginLog
var total int64
// 兼容旧数据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)
}
}
// 筛选条件:时间范围
startTime := strings.TrimSpace(c.Query("start_time"))
endTime := strings.TrimSpace(c.Query("end_time"))
if startTime != "" && endTime != "" {
query = query.Where("created_at BETWEEN ? AND ?", startTime, endTime)
}
// 统计总数
if err := query.Count(&total).Error; err != nil {
loginLogBaseController.HandleInternalError(c, "获取日志总数失败", err)
return
}
// 查询数据(时间倒序,从新到旧)
offset := (page - 1) * limit
if err := query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&logs).Error; 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.Where("type = ?", "admin").Delete(&models.LoginLog{}).Error; err != nil {
logrus.WithError(err).Error("Failed to clear login logs")
loginLogBaseController.HandleInternalError(c, "清空登录日志失败", err)
return
}
// 记录操作日志
// 由于 NetworkAuth 中没有 SystemAdminUser 全局变量,这里暂时使用 "admin"
operator := "admin"
// 尝试从上下文获取用户名(如果中间件设置了的话)
// if user, exists := c.Get("username"); exists {
// operator = user.(string)
// }
log := models.OperationLog{
OperationType: "清空登录日志",
Operator: operator,
OperatorUUID: "", // NetworkAuth 中暂时无法获取 UUID
AppName: "-",
ProductName: "-",
TransactionID: "-",
Details: "管理员清空了所有登录日志",
CreatedAt: time.Now(),
}
db.Create(&log)
loginLogBaseController.HandleSuccess(c, "登录日志已清空", nil)
}

View File

@@ -0,0 +1,152 @@
package admin
import (
"NetworkAuth/controllers"
"NetworkAuth/models"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
// ============================================================================
// 全局变量
// ============================================================================
var logBaseController = controllers.NewBaseController()
// ============================================================================
// 页面处理器
// ============================================================================
// LogsFragmentHandler 日志操作页面片段处理器
func LogsFragmentHandler(c *gin.Context) {
c.HTML(http.StatusOK, "operation_logs.html", gin.H{
"Title": "操作日志",
})
}
// ============================================================================
// API处理器
// ============================================================================
// LogsListHandler 日志列表API处理器
func LogsListHandler(c *gin.Context) {
// 获取分页参数
page, _ := strconv.Atoi(c.Query("page"))
if page <= 0 {
page = 1
}
limit, _ := strconv.Atoi(c.Query("limit"))
if limit <= 0 {
limit = 10
}
// 获取搜索参数
startTimeStr := strings.TrimSpace(c.Query("start_time"))
endTimeStr := strings.TrimSpace(c.Query("end_time"))
operationType := strings.TrimSpace(c.Query("operation_type"))
operator := strings.TrimSpace(c.Query("operator"))
transactionID := strings.TrimSpace(c.Query("transaction_id"))
// 构建查询
db, ok := logBaseController.GetDB(c)
if !ok {
return
}
var logs []models.OperationLog
var total int64
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)
}
if transactionID != "" {
// 优化:使用精确匹配提升查询性能
query = query.Where("transaction_id = ?", transactionID)
}
if startTimeStr != "" {
if t, err := time.ParseInLocation("2006-01-02", startTimeStr, time.Local); err == nil {
query = query.Where("created_at >= ?", t)
} else if t, err := time.ParseInLocation("2006-01-02 15:04:05", startTimeStr, time.Local); err == nil {
query = query.Where("created_at >= ?", t)
} else {
query = query.Where("created_at >= ?", startTimeStr)
}
}
if endTimeStr != "" {
if t, err := time.ParseInLocation("2006-01-02", endTimeStr, time.Local); err == nil {
t = t.Add(24*time.Hour - time.Nanosecond)
query = query.Where("created_at <= ?", t)
} else if t, err := time.ParseInLocation("2006-01-02 15:04:05", endTimeStr, time.Local); err == nil {
query = query.Where("created_at <= ?", t)
} else {
if len(endTimeStr) == 10 { // yyyy-MM-dd
endTimeStr += " 23:59:59"
}
query = query.Where("created_at <= ?", endTimeStr)
}
}
// 获取总数
if err := query.Count(&total).Error; err != nil {
logrus.WithError(err).Error("获取日志总数失败")
logBaseController.HandleInternalError(c, "获取日志总数失败", err)
return
}
// 分页查询(时间倒序,从新到旧)
offset := (page - 1) * limit
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error; 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
}
// 记录操作日志 (因为刚刚清空了,这条将是第一条)
operator := "admin"
log := models.OperationLog{
OperationType: "清空日志",
Operator: operator,
OperatorUUID: "",
AppName: "-",
ProductName: "-",
TransactionID: "-",
Details: "管理员清空了所有操作日志",
CreatedAt: time.Now(),
}
db.Create(&log)
logBaseController.HandleSuccess(c, "日志已清空", nil)
}

View File

@@ -0,0 +1,262 @@
package admin
import (
"NetworkAuth/database"
"NetworkAuth/models"
"NetworkAuth/services"
"NetworkAuth/utils"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ProfileFragmentHandler 个人资料片段渲染
// - 渲染个人资料与修改密码表单
func ProfileFragmentHandler(c *gin.Context) {
c.HTML(http.StatusOK, "profile.html", map[string]interface{}{})
}
// ProfileInfoHandler 查询当前登录管理员的基本信息
// - 返回 username 字段
func ProfileInfoHandler(c *gin.Context) {
_, _, err := GetCurrentAdminUserWithRefresh(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 1,
"msg": "未登录或会话已过期",
"data": nil,
})
return
}
// 获取最新设置
settingsService := services.GetSettingsService()
username := settingsService.GetString("admin_username", "admin")
authBaseController.HandleSuccess(c, "ok", map[string]interface{}{
"username": username,
})
}
// ProfilePasswordUpdateHandler 修改当前登录管理员的密码
// - 接收 JSON: {old_password, new_password, confirm_password}
// - 校验旧密码正确性、新密码与确认一致性
// - 成功后更新密码哈希
func ProfilePasswordUpdateHandler(c *gin.Context) {
_, _, err := GetCurrentAdminUserWithRefresh(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 1,
"msg": "未登录或会话已过期",
"data": nil,
})
return
}
var body struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
ConfirmPassword string `json:"confirm_password"`
}
if !authBaseController.BindJSON(c, &body) {
return
}
// 基础校验
if body.OldPassword == "" || body.NewPassword == "" || body.ConfirmPassword == "" {
authBaseController.HandleValidationError(c, "旧密码/新密码/确认密码均不能为空")
return
}
if len(body.NewPassword) < 6 {
authBaseController.HandleValidationError(c, "新密码长度不能少于6位")
return
}
if body.NewPassword != body.ConfirmPassword {
authBaseController.HandleValidationError(c, "两次输入的新密码不一致")
return
}
if body.NewPassword == body.OldPassword {
authBaseController.HandleValidationError(c, "新密码不能与旧密码相同")
return
}
// 获取当前密码设置
settingsService := services.GetSettingsService()
currentHash := settingsService.GetString("admin_password", "")
currentSalt := settingsService.GetString("admin_password_salt", "")
// 校验旧密码
if !utils.VerifyPasswordWithSalt(body.OldPassword, currentSalt, currentHash) {
authBaseController.HandleValidationError(c, "旧密码不正确")
return
}
// 生成新盐值和哈希
newSalt, err := utils.GenerateRandomSalt()
if err != nil {
authBaseController.HandleInternalError(c, "生成盐值失败", err)
return
}
newHash, err := utils.HashPasswordWithSalt(body.NewPassword, newSalt)
if err != nil {
authBaseController.HandleInternalError(c, "生成密码哈希失败", err)
return
}
// 更新数据库
db, ok := authBaseController.GetDB(c)
if !ok {
return
}
// 更新 admin_password
if err := updateSetting(db, "admin_password", newHash); err != nil {
authBaseController.HandleInternalError(c, "更新密码失败", err)
return
}
// 更新 admin_password_salt
if err := updateSetting(db, "admin_password_salt", newSalt); err != nil {
authBaseController.HandleInternalError(c, "更新盐值失败", err)
return
}
// 刷新缓存
settingsService.RefreshCache()
// 清除相关缓存键
_ = utils.RedisDel(c.Request.Context(), "setting:admin_password", "setting:admin_password_salt")
// 获取当前用户名
currentUsername := settingsService.GetString("admin_username", "admin")
// 重新签发JWT并写入Cookie
token, err := generateJWTTokenForAdmin(currentUsername, newHash)
if err != nil {
authBaseController.HandleInternalError(c, "生成新令牌失败", err)
return
}
secure, sameSite, domain, maxAge := settingsService.GetCookieConfig()
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)
authBaseController.HandleSuccess(c, "密码修改成功", nil)
}
// ProfileUpdateHandler 修改当前登录管理员的用户名
// - 接收 JSON: {username}
// - 校验用户名非空、长度
// - 更新数据库后重新签发JWT并写入 Cookie保持前端展示的一致性
func ProfileUpdateHandler(c *gin.Context) {
_, _, err := GetCurrentAdminUserWithRefresh(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 1,
"msg": "未登录或会话已过期",
"data": nil,
})
return
}
var body struct {
Username string `json:"username"`
OldPassword string `json:"old_password"`
}
if !authBaseController.BindJSON(c, &body) {
return
}
username := strings.TrimSpace(body.Username)
if username == "" {
authBaseController.HandleValidationError(c, "用户名不能为空")
return
}
if len(username) > 64 {
authBaseController.HandleValidationError(c, "用户名长度不能超过64字符")
return
}
settingsService := services.GetSettingsService()
currentUsername := settingsService.GetString("admin_username", "admin")
// 如果未变化则直接返回成功
if strings.EqualFold(username, currentUsername) {
authBaseController.HandleSuccess(c, "保存成功", map[string]interface{}{
"username": username,
})
return
}
// 修改用户名需要进行当前密码校验
if strings.TrimSpace(body.OldPassword) == "" {
authBaseController.HandleValidationError(c, "修改用户名需要提供当前密码")
return
}
currentHash := settingsService.GetString("admin_password", "")
currentSalt := settingsService.GetString("admin_password_salt", "")
// 校验旧密码
if !utils.VerifyPasswordWithSalt(body.OldPassword, currentSalt, currentHash) {
authBaseController.HandleValidationError(c, "当前密码不正确")
return
}
// 更新数据库
db, ok := authBaseController.GetDB(c)
if !ok {
return
}
if err := updateSetting(db, "admin_username", username); err != nil {
authBaseController.HandleInternalError(c, "更新用户名失败", err)
return
}
// 重新签发JWT并写入Cookie
token, err := generateJWTTokenForAdmin(username, currentHash)
if err != nil {
authBaseController.HandleInternalError(c, "生成新令牌失败", err)
return
}
secure, sameSite, domain, maxAge := settingsService.GetCookieConfig()
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)
// 刷新缓存
settingsService.RefreshCache()
_ = utils.RedisDel(c.Request.Context(), "setting:admin_username")
authBaseController.HandleSuccess(c, "用户名修改成功", map[string]interface{}{
"username": username,
})
}
// 辅助函数:更新设置项
func updateSetting(db interface{}, name, value string) error {
// 类型断言
gormDB, ok := db.(*gorm.DB)
if !ok {
// 如果断言失败,尝试重新获取连接
var err error
gormDB, err = database.GetDB()
if err != nil {
return err
}
}
var setting models.Settings
if err := gormDB.Where("name = ?", name).First(&setting).Error; err != nil {
// 如果不存在则创建
setting = models.Settings{Name: name, Value: value}
return gormDB.Create(&setting).Error
}
// 存在则更新
return gormDB.Model(&setting).Update("value", value).Error
}

View File

@@ -1,55 +1,52 @@
package admin
import (
"context"
"NetworkAuth/config"
"NetworkAuth/models"
"NetworkAuth/services"
"NetworkAuth/utils"
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"net/http"
"networkDev/controllers"
"networkDev/models"
"networkDev/services"
"networkDev/utils"
)
// ============================================================================
// 全局变量
// ============================================================================
// 创建基础控制器实例
var settingsBaseController = controllers.NewBaseController()
// ============================================================================
// 页面处理器
// ============================================================================
// SettingsFragmentHandler 设置片段渲染
// - 渲染设置表单通过前端JS调用API加载/保存)
func SettingsFragmentHandler(c *gin.Context) {
c.HTML(http.StatusOK, "settings.html", gin.H{})
c.HTML(http.StatusOK, "settings.html", map[string]interface{}{})
}
// ============================================================================
// API处理器
// ============================================================================
// SubAccountSimpleListHandler 子账号简单列表API处理器 (Mock)
func SubAccountSimpleListHandler(c *gin.Context) {
// Mock implementation for NetworkAuth which has no subaccounts
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "success",
"data": []interface{}{},
})
}
// SettingsQueryHandler 设置查询API
// - 返回所有设置项的 name:value 映射
func SettingsQueryHandler(c *gin.Context) {
db, ok := settingsBaseController.GetDB(c)
db, ok := authBaseController.GetDB(c)
if !ok {
return
}
var list []models.Settings
if err := db.Find(&list).Error; err != nil {
settingsBaseController.HandleInternalError(c, "查询失败", err)
authBaseController.HandleInternalError(c, "查询失败", err)
return
}
res := map[string]string{}
for _, s := range list {
res[s.Name] = s.Value
}
settingsBaseController.HandleSuccess(c, "ok", res)
authBaseController.HandleSuccess(c, "ok", res)
}
// SettingsUpdateHandler 更新系统设置处理器
@@ -65,7 +62,8 @@ func SettingsQueryHandler(c *gin.Context) {
func SettingsUpdateHandler(c *gin.Context) {
// 先尝试解析为直接字段格式
var directBody map[string]interface{}
if !settingsBaseController.BindJSON(c, &directBody) {
if err := c.ShouldBindJSON(&directBody); err != nil {
authBaseController.HandleValidationError(c, "请求体错误")
return
}
@@ -82,7 +80,7 @@ func SettingsUpdateHandler(c *gin.Context) {
}
}
} else {
settingsBaseController.HandleValidationError(c, "settings字段格式错误")
authBaseController.HandleValidationError(c, "settings字段格式错误")
return
}
} else {
@@ -99,11 +97,19 @@ func SettingsUpdateHandler(c *gin.Context) {
}
if len(settingsData) == 0 {
settingsBaseController.HandleValidationError(c, "无设置项")
authBaseController.HandleValidationError(c, "无设置项")
return
}
db, ok := settingsBaseController.GetDB(c)
// 验证设置项值
for k, v := range settingsData {
if err := validateSettingValue(k, v); err != nil {
authBaseController.HandleValidationError(c, err.Error())
return
}
}
db, ok := authBaseController.GetDB(c)
if !ok {
return
}
@@ -113,13 +119,66 @@ func SettingsUpdateHandler(c *gin.Context) {
// 批量处理设置项
for k, v := range settingsData {
// 特殊处理 admin_password
if k == "admin_password" {
// 如果密码为空,跳过更新(保留原密码)
if v == "" {
continue
}
// 记录操作日志
// 由于 NetworkAuth 中没有 SystemAdminUser 全局变量,这里暂时使用 "admin"
// operator := "admin"
// 尝试从上下文获取用户名(如果中间件设置了的话)
// if user, exists := c.Get("username"); exists {
// operator = user.(string)
// }
// 生成随机盐值
salt, err := utils.GenerateRandomSalt()
if err != nil {
authBaseController.HandleInternalError(c, "生成盐值失败", err)
return
}
// 使用盐值哈希密码
hash, err := utils.HashPasswordWithSalt(v, salt)
if err != nil {
authBaseController.HandleInternalError(c, "密码哈希失败", err)
return
}
// 更新 salt 设置项(如果不存在则创建)
var saltSetting models.Settings
if err := db.Where("name = ?", "admin_password_salt").First(&saltSetting).Error; err != nil {
saltSetting = models.Settings{Name: "admin_password_salt", Value: salt}
if err := db.Create(&saltSetting).Error; err != nil {
logrus.WithError(err).Error("创建admin_password_salt失败")
authBaseController.HandleInternalError(c, "保存盐值失败", err)
return
}
} else {
if err := db.Model(&saltSetting).Update("value", salt).Error; err != nil {
logrus.WithError(err).Error("更新admin_password_salt失败")
authBaseController.HandleInternalError(c, "更新盐值失败", err)
return
}
}
// 将盐值相关的缓存键加入清理列表
keysToDel = append(keysToDel, "setting:admin_password_salt")
// 将当前处理的值替换为哈希后的密码
v = hash
}
var s models.Settings
if err := db.Where("name = ?", k).First(&s).Error; err != nil {
// 不存在则创建
s = models.Settings{Name: k, Value: v}
if err := db.Create(&s).Error; err != nil {
logrus.WithError(err).WithField("setting_name", k).Error("创建设置失败")
settingsBaseController.HandleInternalError(c, fmt.Sprintf("保存设置 %s 失败", k), err)
authBaseController.HandleInternalError(c, fmt.Sprintf("保存设置 %s 失败", k), err)
return
}
@@ -127,7 +186,7 @@ func SettingsUpdateHandler(c *gin.Context) {
// 存在则更新
if err := db.Model(&models.Settings{}).Where("id = ?", s.ID).Update("value", v).Error; err != nil {
logrus.WithError(err).WithField("setting_name", k).Error("更新设置失败")
settingsBaseController.HandleInternalError(c, fmt.Sprintf("更新设置 %s 失败", k), err)
authBaseController.HandleInternalError(c, fmt.Sprintf("更新设置 %s 失败", k), err)
return
}
@@ -137,10 +196,60 @@ func SettingsUpdateHandler(c *gin.Context) {
}
// 删除Redis缓存键如果Redis不可用则静默跳过
_ = utils.RedisDel(context.Background(), keysToDel...)
_ = utils.RedisDel(c.Request.Context(), keysToDel...)
// 刷新内存中的设置缓存,保证后续读取一致
services.GetSettingsService().RefreshCache()
settingsBaseController.HandleSuccess(c, "保存成功", nil)
authBaseController.HandleSuccess(c, "保存成功", nil)
}
// validateSettingValue 验证设置项值的合法性
func validateSettingValue(key, value string) error {
switch key {
case "jwt_refresh":
// 验证JWT刷新时间至少1小时
hours, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("JWT刷新阈值必须是整数")
}
if hours < 1 {
return fmt.Errorf("JWT刷新阈值必须至少为1小时")
}
case "jwt_expire":
// 验证JWT有效期至少1小时
hours, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("JWT有效期必须是整数")
}
if hours < 1 {
return fmt.Errorf("JWT有效期必须至少为1小时")
}
}
return nil
}
// SettingsGenerateKeyHandler 生成安全密钥API
// - type: "jwt" 或 "encryption"
func SettingsGenerateKeyHandler(c *gin.Context) {
keyType := c.Query("type")
var key string
var err error
switch keyType {
case "jwt":
key, err = config.GenerateSecureJWTSecret()
case "encryption":
key, err = config.GenerateSecureEncryptionKey()
default:
authBaseController.HandleValidationError(c, "无效的密钥类型")
return
}
if err != nil {
authBaseController.HandleInternalError(c, "生成密钥失败: "+err.Error(), err)
return
}
authBaseController.HandleSuccess(c, "生成成功", map[string]string{"key": key})
}

View File

@@ -1,282 +0,0 @@
package admin
import (
"net/http"
"networkDev/controllers"
"networkDev/models"
"networkDev/utils"
"strings"
"github.com/gin-gonic/gin"
)
// ============================================================================
// 全局变量
// ============================================================================
// 创建基础控制器实例
var baseController = controllers.NewBaseController()
// ============================================================================
// 页面处理器
// ============================================================================
// UserFragmentHandler 个人资料片段渲染
// - 渲染个人资料与修改密码表单
func UserFragmentHandler(c *gin.Context) {
c.HTML(http.StatusOK, "user.html", gin.H{})
}
// ============================================================================
// API处理器
// ============================================================================
// UserProfileQueryHandler 获取当前登录管理员的用户名
// - 返回 JSON: {username}
// - 直接从JWT获取用户名信息
func UserProfileQueryHandler(c *gin.Context) {
claims, _, err := GetCurrentAdminUserWithRefresh(c)
if err != nil {
baseController.HandleValidationError(c, "未登录或会话已过期")
return
}
baseController.HandleSuccess(c, "ok", gin.H{
"username": claims.Username,
})
}
// UserPasswordUpdateHandler 修改当前登录管理员的密码
// - 接收 JSON: {old_password, new_password, confirm_password}
// - 校验旧密码正确性、新密码与确认一致性
// - 成功后更新密码哈希
// - 自动刷新接近过期的JWT令牌
func UserPasswordUpdateHandler(c *gin.Context) {
claims, _, err := GetCurrentAdminUserWithRefresh(c)
if err != nil {
baseController.HandleValidationError(c, "未登录或会话已过期")
return
}
var body struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
ConfirmPassword string `json:"confirm_password"`
}
if !baseController.BindJSON(c, &body) {
return
}
// 基础校验
if !baseController.ValidateRequired(c, map[string]interface{}{
"旧密码": body.OldPassword,
"新密码": body.NewPassword,
"确认密码": body.ConfirmPassword,
}) {
return
}
if len(body.NewPassword) < 6 {
baseController.HandleValidationError(c, "新密码长度不能少于6位")
return
}
if body.NewPassword != body.ConfirmPassword {
baseController.HandleValidationError(c, "两次输入的新密码不一致")
return
}
if body.NewPassword == body.OldPassword {
baseController.HandleValidationError(c, "新密码不能与旧密码相同")
return
}
// 注释由于使用了AdminAuthRequired中间件已确保是管理员用户
// 获取数据库连接
db, ok := baseController.GetDB(c)
if !ok {
return
}
// 通过前缀匹配一次性获取所有管理员相关设置
var adminSettings []models.Settings
if err = db.Where("name LIKE ?", "admin_%").Find(&adminSettings).Error; err != nil {
baseController.HandleInternalError(c, "获取管理员设置失败", err)
return
}
// 将设置转换为map便于查找
settingsMap := make(map[string]string)
for _, setting := range adminSettings {
settingsMap[setting.Name] = setting.Value
}
// 检查必要的设置是否存在
adminPassword, hasPassword := settingsMap["admin_password"]
adminPasswordSalt, hasSalt := settingsMap["admin_password_salt"]
if !hasPassword || !hasSalt {
baseController.HandleInternalError(c, "管理员密码设置不完整", nil)
return
}
// 校验旧密码
if !utils.VerifyPasswordWithSalt(body.OldPassword, adminPasswordSalt, adminPassword) {
baseController.HandleValidationError(c, "旧密码不正确")
return
}
// 生成新的密码盐值
newSalt, err := utils.GenerateRandomSalt()
if err != nil {
baseController.HandleInternalError(c, "生成密码盐失败", err)
return
}
// 生成新密码哈希
newPasswordHash, err := utils.HashPasswordWithSalt(body.NewPassword, newSalt)
if err != nil {
baseController.HandleInternalError(c, "生成密码哈希失败", err)
return
}
// 更新settings中的管理员密码和盐值
if err = db.Model(&models.Settings{}).Where("name = ?", "admin_password").Update("value", newPasswordHash).Error; err != nil {
baseController.HandleInternalError(c, "更新密码失败", err)
return
}
if err = db.Model(&models.Settings{}).Where("name = ?", "admin_password_salt").Update("value", newSalt).Error; err != nil {
baseController.HandleInternalError(c, "更新密码盐值失败", err)
return
}
// 重新生成JWT令牌包含新的密码哈希摘要
adminUser := models.User{
Username: claims.Username,
Password: newPasswordHash,
PasswordSalt: newSalt,
}
newToken, err := generateJWTTokenForAdmin(adminUser)
if err != nil {
baseController.HandleInternalError(c, "生成新令牌失败", err)
return
}
// 更新Cookie使用安全配置
c.SetCookie("admin_session", newToken, utils.GetDefaultCookieMaxAge(), "/", "", false, true)
// 密码修改成功已重新生成JWT令牌
baseController.HandleSuccess(c, "密码修改成功", nil)
}
// UserProfileUpdateHandler 修改当前登录管理员的用户名
// - 接收 JSON: {username}
// - 校验用户名非空、长度与唯一性
// - 更新数据库后重新签发JWT并写入 Cookie保持前端展示的一致性
// - 自动刷新接近过期的JWT令牌
func UserProfileUpdateHandler(c *gin.Context) {
_, _, err := GetCurrentAdminUserWithRefresh(c)
if err != nil {
baseController.HandleValidationError(c, "未登录或会话已过期")
return
}
var body struct {
Username string `json:"username"`
OldPassword string `json:"old_password"`
}
if !baseController.BindJSON(c, &body) {
return
}
username := strings.TrimSpace(body.Username)
if username == "" {
baseController.HandleValidationError(c, "用户名不能为空")
return
}
if len(username) > 64 {
baseController.HandleValidationError(c, "用户名长度不能超过64字符")
return
}
db, ok := baseController.GetDB(c)
if !ok {
return
}
// 注释由于使用了AdminAuthRequired中间件已确保是管理员用户
// 获取所有管理员相关设置
var adminSettings []models.Settings
if dbErr := db.Where("name LIKE ?", "admin_%").Find(&adminSettings).Error; dbErr != nil {
baseController.HandleInternalError(c, "获取管理员设置失败", dbErr)
return
}
// 转换为map便于查找
settingsMap := make(map[string]string)
for _, setting := range adminSettings {
settingsMap[setting.Name] = setting.Value
}
adminUsername, exists := settingsMap["admin_username"]
if !exists {
baseController.HandleInternalError(c, "管理员用户名设置不存在", nil)
return
}
adminPassword, exists := settingsMap["admin_password"]
if !exists {
baseController.HandleInternalError(c, "管理员密码设置不存在", nil)
return
}
adminPasswordSalt, exists := settingsMap["admin_password_salt"]
if !exists {
baseController.HandleInternalError(c, "管理员密码盐值设置不存在", nil)
return
}
// 如果用户名未变化则直接返回成功(无需校验旧密码)
if strings.EqualFold(username, adminUsername) {
baseController.HandleSuccess(c, "保存成功", gin.H{
"username": username,
})
return
}
// 修改用户名需要进行当前密码校验
if strings.TrimSpace(body.OldPassword) == "" {
baseController.HandleValidationError(c, "修改用户名需要提供当前密码")
return
}
// 使用盐值验证当前密码
if !utils.VerifyPasswordWithSalt(body.OldPassword, adminPasswordSalt, adminPassword) {
baseController.HandleValidationError(c, "当前密码不正确")
return
}
// 更新管理员用户名设置
if dbErr := db.Model(&models.Settings{}).Where("name = ?", "admin_username").Update("value", username).Error; dbErr != nil {
baseController.HandleInternalError(c, "更新管理员用户名失败", dbErr)
return
}
// 重新签发JWT并写入Cookie
// 创建虚拟用户对象用于生成JWT令牌
adminUser := models.User{
Username: username, // 使用新的用户名
Password: adminPassword,
PasswordSalt: adminPasswordSalt,
}
token, err := generateJWTTokenForAdmin(adminUser)
if err != nil {
baseController.HandleInternalError(c, "生成新令牌失败", err)
return
}
c.SetCookie("admin_session", token, utils.GetDefaultCookieMaxAge(), "/", "", false, true)
baseController.HandleSuccess(c, "保存成功", gin.H{
"username": username,
})
}

View File

@@ -0,0 +1,7 @@
package admin
import (
"NetworkAuth/controllers"
)
var base = controllers.NewBaseController()

View File

@@ -1,9 +1,9 @@
package admin
import (
"NetworkAuth/controllers"
"NetworkAuth/models"
"net/http"
"networkDev/controllers"
"networkDev/models"
"regexp"
"strconv"
"strings"
@@ -319,7 +319,7 @@ func VariableDeleteHandler(c *gin.Context) {
return
}
logrus.WithField("variable_id", req.ID).Info("Successfully deleted variable")
logrus.WithField("variable_id", req.ID).Debug("Successfully deleted variable")
variableBaseController.HandleSuccess(c, "删除成功", nil)
}
@@ -351,7 +351,7 @@ func VariablesBatchDeleteHandler(c *gin.Context) {
return
}
logrus.WithField("variable_ids", req.IDs).Info("Successfully batch deleted variables")
logrus.WithField("variable_ids", req.IDs).Debug("Successfully batch deleted variables")
variableBaseController.HandleSuccess(c, "批量删除成功", nil)
}

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"networkDev/database"
"NetworkAuth/database"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -15,6 +15,7 @@ import (
// ============================================================================
// BaseController 基础控制器结构体
// 提供通用的数据库访问和响应处理方法
type BaseController struct{}
// ============================================================================
@@ -86,11 +87,18 @@ func (bc *BaseController) HandleInternalError(c *gin.Context, message string, er
// HandleSuccess 统一处理成功响应
func (bc *BaseController) HandleSuccess(c *gin.Context, message string, data interface{}) {
c.JSON(http.StatusOK, gin.H{
resp := gin.H{
"code": 0,
"msg": message,
"data": data,
})
}
// 检查是否有刷新的Token
if newToken, exists := c.Get("new_token"); exists {
resp["token"] = newToken
}
c.JSON(http.StatusOK, resp)
}
// HandleCreated 统一处理创建成功响应
@@ -173,9 +181,9 @@ func (bc *BaseController) BindURI(c *gin.Context, obj interface{}) bool {
// 返回包含系统基础信息的数据映射,包括站点标题、页脚文本、备案信息等
func (bc *BaseController) GetDefaultTemplateData() gin.H {
return gin.H{
"Title": "凌动技术",
"SystemName": "网络验证系统",
"FooterText": "© 2025 凌动技术 保留所有权利",
"Title": "NetworkAuth",
"SystemName": "NetworkAuth",
"FooterText": "© 2026 NetworkAuth 保留所有权利",
"ICPRecord": "",
"ICPRecordLink": "https://beian.miit.gov.cn",
"PSBRecord": "",

View File

@@ -0,0 +1,32 @@
package default_ctrl
import (
"NetworkAuth/services"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// RootHandler 根路径处理器
// 使用模板渲染服务器信息页面
func RootHandler(c *gin.Context) {
// 获取设置服务
settings := services.GetSettingsService()
// 传递模板数据
data := map[string]interface{}{
"Title": settings.GetString("site_title", "NetworkAuth Server"),
"Keywords": settings.GetString("site_keywords", ""),
"Description": settings.GetString("site_description", ""),
"SystemName": "系统提醒", // 对应 H1
"WarningText": "🚫 未授权,拒绝访问",
"InfoText": "💬 如有问题,请联系网站管理员",
"FooterText": settings.GetString("footer_text", "Copyright © 2026 NetworkAuth. All Rights Reserved."),
"ICPRecord": settings.GetString("icp_record", ""),
"ICPRecordLink": settings.GetString("icp_record_link", "https://beian.miit.gov.cn"),
"CurrentYear": time.Now().Year(),
}
c.HTML(http.StatusOK, "index.html", data)
}

View File

@@ -1,59 +0,0 @@
package home
import (
"net/http"
"networkDev/controllers"
"networkDev/database"
"networkDev/services"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ============================================================================
// 全局变量
// ============================================================================
var homeBaseController = controllers.NewBaseController()
// ============================================================================
// 辅助函数
// ============================================================================
// getSettingValue 获取配置值,优先从数据库获取,不存在时使用默认值
func getSettingValue(settingName string, defaultValue string, db *gorm.DB) string {
if setting, err := services.FindSettingByName(settingName, db); err == nil {
return setting.Value
}
return defaultValue
}
// ============================================================================
// 页面处理器
// ============================================================================
// RootHandler 主页处理器
func RootHandler(c *gin.Context) {
// 获取数据库连接
db, err := database.GetDB()
if err != nil {
c.HTML(http.StatusInternalServerError, "error.html", gin.H{
"error": "数据库连接失败",
})
return
}
// 获取默认模板数据
data := homeBaseController.GetDefaultTemplateData()
// 从数据库读取设置,优先使用数据库配置,不存在时使用默认值
data["SystemName"] = getSettingValue("site_title", data["SystemName"].(string), db)
data["FooterText"] = getSettingValue("footer_text", data["FooterText"].(string), db)
data["ICPRecord"] = getSettingValue("icp_record", data["ICPRecord"].(string), db)
data["ICPRecordLink"] = getSettingValue("icp_record_link", data["ICPRecordLink"].(string), db)
data["PSBRecord"] = getSettingValue("psb_record", data["PSBRecord"].(string), db)
data["PSBRecordLink"] = getSettingValue("psb_record_link", data["PSBRecordLink"].(string), db)
data["title"] = "主页"
c.HTML(http.StatusOK, "index.html", data)
}

View File

@@ -0,0 +1,122 @@
package install
import (
"NetworkAuth/config"
"NetworkAuth/database"
"NetworkAuth/models"
"NetworkAuth/services"
"NetworkAuth/utils"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// InstallPageHandler 渲染安装页面
func InstallPageHandler(c *gin.Context) {
// 由于前端是通过模板渲染的,我们返回一个安装页面
c.HTML(http.StatusOK, "install.html", gin.H{
"title": "NetworkAuth 系统初始化",
})
}
// InstallSubmitHandler 处理安装表单提交
func InstallSubmitHandler(c *gin.Context) {
var req struct {
// 数据库配置
DbType string `json:"db_type" binding:"required,oneof=sqlite mysql"`
DbHost string `json:"db_host"`
DbPort int `json:"db_port"`
DbName string `json:"db_name"`
DbUser string `json:"db_user"`
DbPass string `json:"db_pass"`
// 站点和管理员配置
SiteTitle string `json:"site_title" binding:"required"`
AdminUsername string `json:"admin_username" binding:"required"`
AdminPassword string `json:"admin_password" binding:"required,min=6"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "参数错误: " + err.Error()})
return
}
// 1. 更新配置文件
err := config.UpdateConfig(func(cfg *config.AppConfig) {
cfg.Database.Type = req.DbType
if req.DbType == "mysql" {
cfg.Database.MySQL.Host = req.DbHost
cfg.Database.MySQL.Port = req.DbPort
cfg.Database.MySQL.Database = req.DbName
cfg.Database.MySQL.Username = req.DbUser
cfg.Database.MySQL.Password = req.DbPass
}
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "msg": "更新配置文件失败: " + err.Error()})
return
}
// 2. 重新初始化数据库连接并执行迁移
db, err := database.ReInit()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "msg": "连接数据库失败: " + err.Error()})
return
}
if db == nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "msg": "获取数据库实例失败"})
return
}
// 强制执行迁移确保表存在
if err := database.AutoMigrate(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "msg": "初始化数据表失败: " + err.Error()})
return
}
// 初始化系统默认设置
database.SeedDefaultSettings()
// 3. 生成新的管理员密码哈希和盐值
adminSalt, err := utils.GenerateRandomSalt()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "msg": "生成盐值失败"})
return
}
adminPasswordHash, err := utils.HashPasswordWithSalt(req.AdminPassword, adminSalt)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "msg": "加密密码失败"})
return
}
// 4. 更新设置表
settingsToUpdate := map[string]string{
"site_title": req.SiteTitle,
"admin_username": strings.TrimSpace(req.AdminUsername),
"admin_password": adminPasswordHash,
"admin_password_salt": adminSalt,
"is_installed": "1", // 标记为已安装
}
// 开启事务进行更新
tx := db.Begin()
for name, value := range settingsToUpdate {
// 先尝试更新,如果没有该记录,则忽略(因为 AutoMigrate 已经创建了默认记录)
if err := tx.Model(&models.Settings{}).Where("name = ?", name).Update("value", value).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "msg": "保存设置失败: " + name})
return
}
}
tx.Commit()
// 5. 更新内存缓存
settingsService := services.GetSettingsService()
for name, value := range settingsToUpdate {
settingsService.Set(name, value)
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "安装成功"})
}

View File

@@ -1,15 +1,19 @@
package database
import (
"NetworkAuth/utils"
"fmt"
"networkDev/utils"
"log"
"os"
"sync"
"time"
"github.com/glebarez/sqlite"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
"gorm.io/driver/mysql"
"gorm.io/gorm"
gLogger "gorm.io/gorm/logger"
)
// ============================================================================
@@ -33,40 +37,7 @@ var (
func Init() (*gorm.DB, error) {
var initErr error
once.Do(func() {
dbType := viper.GetString("database.type")
switch dbType {
case "mysql":
initErr = initMySQL()
default:
initErr = initSQLite()
}
// 如果数据库初始化成功,配置连接池和启动健康检查
if initErr == nil && dbInstance != nil {
// 加载数据库配置
var configPrefix string
if dbType == "mysql" {
configPrefix = "database.mysql"
} else {
configPrefix = "database.sqlite"
}
dbConfig := utils.LoadDatabaseConfig(configPrefix)
// 验证配置
if err := utils.ValidateDatabaseConfig(dbConfig); err != nil {
logrus.WithError(err).Warn("数据库配置验证失败,使用默认配置")
dbConfig = utils.GetDefaultDatabaseConfig()
}
// 配置连接池
if err := utils.ConfigureConnectionPool(dbInstance, dbConfig); err != nil {
logrus.WithError(err).Error("配置数据库连接池失败")
}
// 启动健康检查
utils.StartHealthCheck(dbInstance, dbConfig)
}
initErr = performInit()
})
return dbInstance, initErr
}
@@ -80,6 +51,89 @@ func GetDB() (*gorm.DB, error) {
return Init()
}
// ReInit 重新初始化数据库连接
// 用于在修改配置后重新连接数据库
func ReInit() (*gorm.DB, error) {
// 如果已有连接,尝试关闭它
if dbInstance != nil {
if sqlDB, err := dbInstance.DB(); err == nil {
sqlDB.Close()
}
}
dbInstance = nil
// 重新执行初始化逻辑(不经过 once.Do
return dbInstance, performInit()
}
func performInit() error {
// 检查是否已经有配置文件(通过检查文件是否存在)
configFile := viper.ConfigFileUsed()
// 如果 viper 没有使用配置文件(可能是因为没找到文件而使用了默认配置),
// 或者配置文件路径为空,我们应该假设处于未安装状态。
// 但 viper.ConfigFileUsed() 在 ReadInConfig 成功后会返回文件名。
// 如果 ReadInConfig 失败因为文件不存在viper 可能会返回空或者我们在 config.go 中设置的路径。
// 在 config.go 中,如果文件不存在,我们加载了默认配置但没有写文件。
// 此时 viper.ConfigFileUsed() 可能是空的或者我们设置的路径。
// 让我们检查该路径对应的文件是否存在。
if configFile == "" {
configFile = "config.json"
}
_, err := os.Stat(configFile)
isConfigExists := !os.IsNotExist(err)
// 如果配置文件不存在,说明还没有经过安装初始化,暂时不连接数据库
if !isConfigExists {
logrus.Info("尚未初始化配置,跳过数据库连接")
return nil
}
var initErr error
dbType := viper.GetString("database.type")
switch dbType {
case "mysql":
initErr = initMySQL()
default:
initErr = initSQLite()
}
// 如果数据库初始化成功,配置连接池和启动健康检查
if initErr == nil && dbInstance != nil {
// 加载数据库配置
var configPrefix string
if dbType == "mysql" {
configPrefix = "database.mysql"
} else {
configPrefix = "database.sqlite"
}
dbConfig := utils.LoadDatabaseConfig(configPrefix)
// 验证配置
if err := utils.ValidateDatabaseConfig(dbConfig); err != nil {
logrus.WithError(err).Warn("数据库配置验证失败,使用默认配置")
dbConfig = utils.GetDefaultDatabaseConfig()
}
// 配置连接池
if err := utils.ConfigureConnectionPool(dbInstance, dbConfig); err != nil {
logrus.WithError(err).Error("配置数据库连接池失败")
}
// 启动健康检查
utils.StartHealthCheck(dbInstance, dbConfig)
}
return initErr
}
// SetDB 设置全局 *gorm.DB 实例(用于测试)
func SetDB(db *gorm.DB) {
dbInstance = db
}
// ============================================================================
// 私有函数
// ============================================================================
@@ -89,10 +143,20 @@ func GetDB() (*gorm.DB, error) {
func initSQLite() error {
path := viper.GetString("database.sqlite.path")
if path == "" {
path = "./database.db"
path = "./recharge.db"
}
dsn := fmt.Sprintf("file:%s?cache=shared&_busy_timeout=5000&_fk=1", path)
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
var logLevel gLogger.LogLevel
switch viper.GetString("logger.level") {
case "debug":
logLevel = gLogger.Info
case "error":
logLevel = gLogger.Error
default:
logLevel = gLogger.Warn
}
gl := gLogger.New(log.New(os.Stdout, "\r\n", log.LstdFlags), gLogger.Config{SlowThreshold: 2 * time.Second, LogLevel: logLevel, IgnoreRecordNotFoundError: true, Colorful: false})
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: gl})
if err != nil {
logrus.WithError(err).Error("SQLite 初始化失败")
return err
@@ -124,7 +188,17 @@ func initMySQL() error {
}
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local", user, pass, host, port, dbname, charset)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
var logLevel gLogger.LogLevel
switch viper.GetString("logger.level") {
case "debug":
logLevel = gLogger.Info
case "error":
logLevel = gLogger.Error
default:
logLevel = gLogger.Warn
}
gl := gLogger.New(log.New(os.Stdout, "\r\n", log.LstdFlags), gLogger.Config{SlowThreshold: 2 * time.Second, LogLevel: logLevel, IgnoreRecordNotFoundError: true, Colorful: false})
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: gl})
if err != nil {
logrus.WithError(err).Error("MySQL 初始化失败")
return err

View File

@@ -1,180 +1,32 @@
package database
import (
"fmt"
"networkDev/models"
"strings"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
// ============================================================================
// 公共函数
// ============================================================================
// AutoMigrate 自动迁移数据库模型
// - 会确保必要的数据表结构存在
// - 不会破坏已有数据
func AutoMigrate() error {
db, err := GetDB()
if err != nil {
return err
}
if err := db.AutoMigrate(&models.User{}, &models.Settings{}, &models.App{}, &models.API{}, &models.Variable{}, &models.Function{}); err != nil {
logrus.WithError(err).Error("AutoMigrate 执行失败")
return err
}
// 兼容迁移:如果 users.password_salt 列长度 < 64则扩大到 64
if err := ensureUserPasswordSaltLength(db); err != nil {
logrus.WithError(err).Error("调整 users.password_salt 列长度失败")
return err
}
// 兼容迁移:确保 tasks.verification_code 字段类型为 LONGTEXT 以支持大图片数据
if err := ensureVerificationCodeType(db); err != nil {
logrus.WithError(err).Error("调整 tasks.verification_code 字段类型失败")
return err
}
logrus.Info("AutoMigrate 执行完成")
return nil
}
// ============================================================================
// 私有函数
// ============================================================================
// ensureVerificationCodeType 确保tasks.verification_code字段类型为LONGTEXT以支持大图片数据
// 中文注释检查并修改verification_code字段类型支持Base64编码的大图片数据存储
func ensureVerificationCodeType(db *gorm.DB) error {
// 获取数据库方言类型
dialector := db.Dialector.Name()
// 根据不同数据库类型执行不同的检查逻辑
switch dialector {
case "mysql":
// MySQL/MariaDB使用INFORMATION_SCHEMA
var result struct {
ColumnName string `gorm:"column:COLUMN_NAME"`
ColumnType string `gorm:"column:COLUMN_TYPE"`
}
err := db.Raw("SELECT COLUMN_NAME, COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1",
"tasks", "verification_code").Scan(&result).Error
if err != nil {
return nil // 查询失败则跳过
}
// 检查列类型如果不是LONGTEXT则修改
if !strings.Contains(strings.ToLower(result.ColumnType), "longtext") {
alterSQL := "ALTER TABLE tasks MODIFY COLUMN verification_code LONGTEXT"
if err := db.Exec(alterSQL).Error; err != nil {
return fmt.Errorf("修改verification_code字段类型失败: %v", err)
}
logrus.Info("verification_code字段类型已更新为LONGTEXT")
}
case "sqlite":
// SQLite使用pragma_table_info检查列信息
var columns []struct {
CID int `gorm:"column:cid"`
Name string `gorm:"column:name"`
Type string `gorm:"column:type"`
NotNull int `gorm:"column:notnull"`
DfltValue *string `gorm:"column:dflt_value"`
PK int `gorm:"column:pk"`
}
err := db.Raw("PRAGMA table_info(tasks)").Scan(&columns).Error
if err != nil {
return nil // 查询失败则跳过
}
// 查找verification_code列
for _, col := range columns {
if col.Name == "verification_code" {
// SQLite中如果列类型不是TEXT需要重建表
if !strings.Contains(strings.ToLower(col.Type), "text") {
// SQLite不支持直接修改列类型但GORM的AutoMigrate会处理这种情况
logrus.Info("SQLite检测到verification_code字段类型需要更新依赖GORM AutoMigrate处理")
}
break
}
}
default:
// 其他数据库类型暂不处理
logrus.Infof("数据库类型 %s 暂不支持verification_code字段类型检查", dialector)
}
return nil
}
// ensureUserPasswordSaltLength 确保users.password_salt列长度至少为64
// 中文注释检查并修改password_salt列长度兼容32字节64十六进制字符的盐值
func ensureUserPasswordSaltLength(db *gorm.DB) error {
// 获取数据库方言类型
dialector := db.Dialector.Name()
// 根据不同数据库类型执行不同的检查逻辑
switch dialector {
case "mysql":
// MySQL/MariaDB使用INFORMATION_SCHEMA
var result struct {
ColumnName string `gorm:"column:COLUMN_NAME"`
ColumnType string `gorm:"column:COLUMN_TYPE"`
}
err := db.Raw("SELECT COLUMN_NAME, COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1",
"users", "password_salt").Scan(&result).Error
if err != nil {
return nil // 查询失败则跳过
}
// 检查列类型如果长度小于64则修改
if strings.Contains(strings.ToLower(result.ColumnType), "varchar") {
if strings.Contains(result.ColumnType, "(32)") || strings.Contains(result.ColumnType, "(16)") {
alterSQL := "ALTER TABLE users MODIFY COLUMN password_salt VARCHAR(64)"
if err := db.Exec(alterSQL).Error; err != nil {
return fmt.Errorf("修改password_salt列长度失败: %v", err)
}
logrus.Info("password_salt列长度已更新为64")
}
}
case "sqlite":
// SQLite使用pragma_table_info检查列信息
var columns []struct {
CID int `gorm:"column:cid"`
Name string `gorm:"column:name"`
Type string `gorm:"column:type"`
NotNull int `gorm:"column:notnull"`
DfltValue *string `gorm:"column:dflt_value"`
PK int `gorm:"column:pk"`
}
err := db.Raw("PRAGMA table_info(users)").Scan(&columns).Error
if err != nil {
return nil // 查询失败则跳过
}
// 查找password_salt列
for _, col := range columns {
if col.Name == "password_salt" {
// SQLite中如果列类型包含长度限制且小于64需要重建表
if strings.Contains(strings.ToLower(col.Type), "varchar(32)") ||
strings.Contains(strings.ToLower(col.Type), "varchar(16)") {
// SQLite不支持直接修改列类型但GORM的AutoMigrate会处理这种情况
logrus.Info("SQLite检测到password_salt列长度需要更新依赖GORM AutoMigrate处理")
}
break
}
}
default:
// 其他数据库类型暂不处理
logrus.Infof("数据库类型 %s 暂不支持password_salt列长度检查", dialector)
}
return nil
}
package database
import (
"NetworkAuth/models"
"github.com/sirupsen/logrus"
)
// AutoMigrate 自动迁移数据库模型
// - 会确保必要的数据表结构存在
// - 不会破坏已有数据
func AutoMigrate() error {
db, err := GetDB()
if err != nil {
return err
}
if err := db.AutoMigrate(
&models.Settings{},
&models.OperationLog{},
&models.LoginLog{},
&models.App{},
&models.API{},
&models.Function{},
&models.Variable{},
&models.User{},
); err != nil {
logrus.WithError(err).Error("AutoMigrate 执行失败")
return err
}
logrus.Info("AutoMigrate 执行完成")
return nil
}

View File

@@ -1,186 +1,232 @@
package database
import (
"networkDev/models"
"networkDev/utils"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
// ============================================================================
// 公共函数
// ============================================================================
// SeedDefaultSettings 初始化默认系统设置
// - 检查各项设置是否已存在,如不存在则创建默认值
// - 包含站点基本信息、SEO设置等常用配置项
func SeedDefaultSettings() error {
db, err := GetDB()
if err != nil {
return err
}
// 定义默认设置项
defaultSettings := []models.Settings{
{
Name: "site_title",
Value: "凌动技术",
Description: "网站标题,显示在浏览器标题栏和页面顶部",
},
{
Name: "site_keywords",
Value: "验证,网络,管理系统,网络验证,卡密管理,账户管理",
Description: "网站关键词用于SEO优化多个关键词用逗号分隔",
},
{
Name: "site_description",
Value: "专业的网络验证管理系统,提供便捷的在线网络验证服务和设备管理功能",
Description: "网站描述用于SEO优化和社交媒体分享",
},
{
Name: "site_logo",
Value: "/favicon.ico",
Description: "网站Logo图片路径",
},
{
Name: "contact_email",
Value: "admin@example.com",
Description: "联系邮箱,用于客服和业务咨询",
},
{
Name: "max_upload_size",
Value: "10485760",
Description: "文件上传最大尺寸字节默认10MB",
},
{
Name: "default_user_role",
Value: "1",
Description: "新用户默认角色0=管理员1=普通用户",
},
{
Name: "session_timeout",
Value: "3600",
Description: "会话超时时间默认1小时",
},
{
Name: "maintenance_mode",
Value: "0",
Description: "维护模式0=关闭维护模式1=开启维护模式",
},
// ===== 管理员账号相关默认项 =====
{
Name: "admin_username",
Value: "admin",
Description: "管理员用户名",
},
{
Name: "admin_password",
Value: "",
Description: "管理员密码哈希值",
},
{
Name: "admin_password_salt",
Value: "",
Description: "管理员密码加密盐值",
},
// ===== 页脚与备案相关默认项 =====
{
Name: "footer_text",
Value: "Copyright © 2025 凌动技术. All Rights Reserved.",
Description: "页脚展示的版权或说明信息",
},
{
Name: "icp_record",
Value: "京ICP备12345678号",
Description: "ICP备案号留空则不显示",
},
{
Name: "icp_record_link",
Value: "https://beian.miit.gov.cn",
Description: "工信部ICP备案查询链接留空则不显示",
},
{
Name: "psb_record",
Value: "京公网安备 11000002000001号",
Description: "公安备案号,留空则不显示",
},
{
Name: "psb_record_link",
Value: "https://www.beian.gov.cn/portal/registerSystemInfo?recordcode=11000002000001",
Description: "公安备案查询链接,留空则不显示",
},
}
// 逐个检查并创建不存在的设置项
for _, setting := range defaultSettings {
var count int64
if err := db.Model(&models.Settings{}).Where("name = ?", setting.Name).Count(&count).Error; err != nil {
return err
}
if count == 0 {
if err := db.Create(&setting).Error; err != nil {
logrus.WithError(err).WithField("name", setting.Name).Error("创建默认设置失败")
return err
}
logrus.WithField("name", setting.Name).WithField("value", setting.Value).Debug("创建默认设置项")
}
}
// 初始化默认管理员账号(如果密码为空)
if err := initDefaultAdmin(db); err != nil {
return err
}
logrus.Info("默认系统设置初始化完成")
return nil
}
// ============================================================================
// 私有函数
// ============================================================================
// initDefaultAdmin 初始化默认管理员账号
// 如果admin_password为空则生成默认密码admin123的哈希值
func initDefaultAdmin(db *gorm.DB) error {
var passwordSetting models.Settings
if err := db.Where("name = ?", "admin_password").First(&passwordSetting).Error; err != nil {
logrus.WithError(err).Error("获取管理员密码设置失败")
return err
}
// 如果密码已设置,跳过初始化
if passwordSetting.Value != "" {
logrus.Debug("管理员密码已设置,跳过默认密码初始化")
return nil
}
// 生成密码盐值
salt, err := utils.GenerateRandomSalt()
if err != nil {
logrus.WithError(err).Error("生成密码盐值失败")
return err
}
// 使用盐值生成密码哈希默认密码admin123
hash, err := utils.HashPasswordWithSalt("admin123", salt)
if err != nil {
logrus.WithError(err).Error("生成密码哈希失败")
return err
}
// 更新密码和盐值
if err := db.Model(&models.Settings{}).Where("name = ?", "admin_password").Update("value", hash).Error; err != nil {
logrus.WithError(err).Error("更新管理员密码失败")
return err
}
if err := db.Model(&models.Settings{}).Where("name = ?", "admin_password_salt").Update("value", salt).Error; err != nil {
logrus.WithError(err).Error("更新管理员密码盐值失败")
return err
}
logrus.Info("默认管理员账号初始化完成,用户名: admin, 密码: admin123")
return nil
}
package database
import (
"NetworkAuth/config"
"NetworkAuth/models"
"NetworkAuth/utils"
"github.com/sirupsen/logrus"
)
// ============================================================================
// 公共函数
// ============================================================================
// SeedDefaultSettings 初始化默认系统设置
// - 检查各项设置是否已存在,如不存在则创建默认值
// - 包含站点基本信息、SEO设置等常用配置项
func SeedDefaultSettings() error {
db, err := GetDB()
if err != nil {
return err
}
// 生成安全的随机密钥
jwtSecret, err := config.GenerateSecureJWTSecret()
if err != nil {
return err
}
encryptionKey, err := config.GenerateSecureEncryptionKey()
if err != nil {
return err
}
// 生成默认管理员密码admin123的盐值和哈希
// 这样可以确保admin_password和admin_password_salt在初始化时就有值
adminSalt, err := utils.GenerateRandomSalt()
if err != nil {
return err
}
adminPasswordHash, err := utils.HashPasswordWithSalt("admin123", adminSalt)
if err != nil {
return err
}
// 检查是否已有 admin_password如果有说明是旧版本升级应该把 is_installed 默认设为 1
var adminPwdCount int64
db.Model(&models.Settings{}).Where("name = ?", "admin_password").Count(&adminPwdCount)
isInstalledDefault := "0"
if adminPwdCount > 0 {
isInstalledDefault = "1"
}
// 定义默认设置项
defaultSettings := []models.Settings{
// ===== 系统安装状态 =====
{
Name: "is_installed",
Value: isInstalledDefault,
Description: "系统是否已初始化安装0=未安装1=已安装",
},
// ===== 管理员账号相关默认项 =====
{
Name: "admin_username",
Value: "admin",
Description: "管理员用户名",
},
{
Name: "admin_password",
Value: adminPasswordHash,
Description: "管理员密码哈希值",
},
{
Name: "admin_password_salt",
Value: adminSalt,
Description: "管理员密码加密盐值",
},
// ===== 系统和安全相关默认项 =====
{
Name: "maintenance_mode",
Value: "0",
Description: "维护模式0=关闭维护模式1=开启维护模式",
},
{
Name: "encryption_key",
Value: encryptionKey,
Description: "数据加密密钥",
},
{
Name: "jwt_secret",
Value: jwtSecret,
Description: "JWT签名密钥",
},
{
Name: "jwt_refresh",
Value: "6",
Description: "JWT令牌刷新阈值小时",
},
{
Name: "jwt_expire",
Value: "24",
Description: "JWT令牌有效期小时",
},
{
Name: "session_timeout",
Value: "3600",
Description: "会话超时时间默认1小时",
},
{
Name: "max_upload_size",
Value: "10485760",
Description: "文件上传最大尺寸字节默认10MB",
},
{
Name: "default_user_role",
Value: "1",
Description: "新用户默认角色0=管理员1=普通用户",
},
// ===== 日志清理策略默认项 =====
{
Name: "login_log_cleanup_days",
Value: "30",
Description: "登录日志保留天数0表示不按天清理",
},
{
Name: "login_log_cleanup_limit",
Value: "10000",
Description: "登录日志保留条数0表示不按数量清理",
},
{
Name: "operation_log_cleanup_days",
Value: "30",
Description: "操作日志保留天数0表示不按天清理",
},
{
Name: "operation_log_cleanup_limit",
Value: "10000",
Description: "操作日志保留条数0表示不按数量清理",
},
// ===== Cookie相关默认项 =====
{
Name: "cookie_secure",
Value: "true",
Description: "Cookie Secure属性是否只在HTTPS下发送",
},
{
Name: "cookie_same_site",
Value: "Lax",
Description: "Cookie SameSite属性Strict/Lax/None",
},
{
Name: "cookie_domain",
Value: "",
Description: "Cookie域名",
},
{
Name: "cookie_max_age",
Value: "86400",
Description: "Cookie最大存活时间",
},
// ===== 站点基本信息默认项 =====
{
Name: "site_title",
Value: "NetworkAuth",
Description: "网站标题,显示在浏览器标题栏和页面顶部",
},
{
Name: "site_keywords",
Value: "NetworkAuth,鉴权,API管理,GoLang",
Description: "网站关键词用于SEO优化多个关键词用逗号分隔",
},
{
Name: "site_description",
Value: "NetworkAuth 网络授权服务,专注于应用鉴权与接口管理",
Description: "网站描述用于SEO优化和社交媒体分享",
},
{
Name: "site_logo",
Value: "/static/logo.png",
Description: "网站Logo图片路径",
},
{
Name: "contact_email",
Value: "admin@example.com",
Description: "联系邮箱,用于客服和业务咨询",
},
// ===== 页脚与备案相关默认项 =====
{
Name: "footer_text",
Value: "Copyright © 2026 NetworkAuth. All Rights Reserved.",
Description: "页脚展示的版权或说明信息",
},
{
Name: "icp_record",
Value: "",
Description: "ICP备案号留空则不显示",
},
{
Name: "icp_record_link",
Value: "https://beian.miit.gov.cn",
Description: "工信部ICP备案查询链接留空则不显示",
},
{
Name: "psb_record",
Value: "",
Description: "公安备案号,留空则不显示",
},
{
Name: "psb_record_link",
Value: "",
Description: "公安备案查询链接,留空则不显示",
},
}
// 逐个检查并创建不存在的设置项
for _, setting := range defaultSettings {
var count int64
if err := db.Model(&models.Settings{}).Where("name = ?", setting.Name).Count(&count).Error; err != nil {
return err
}
if count == 0 {
if err := db.Create(&setting).Error; err != nil {
logrus.WithError(err).WithField("name", setting.Name).Error("创建默认设置失败")
return err
}
logrus.WithField("name", setting.Name).WithField("value", setting.Value).Debug("创建默认设置项")
}
}
logrus.Info("默认系统设置初始化完成")
return nil
}

56
go.mod
View File

@@ -1,18 +1,19 @@
module networkDev
module NetworkAuth
go 1.24.1
go 1.25.0
require (
github.com/gin-gonic/gin v1.11.0
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
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.13.0
github.com/redis/go-redis/v9 v9.18.0
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.1
golang.org/x/crypto v0.41.0
github.com/xuri/excelize/v2 v2.10.1
golang.org/x/crypto v0.48.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.30.1
@@ -20,23 +21,24 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
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/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
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
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.27.0 // indirect
github.com/go-playground/validator/v10 v10.30.1 // 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.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
@@ -45,32 +47,34 @@ require (
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
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // 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
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/mock v0.5.0 // indirect
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
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
golang.org/x/arch v0.20.0 // indirect
golang.org/x/image v0.23.0 // indirect
golang.org/x/mod v0.26.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/tools v0.35.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/image v0.25.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect

118
go.sum
View File

@@ -4,10 +4,12 @@ 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=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
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/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
@@ -24,12 +26,12 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
@@ -40,16 +42,16 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
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-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=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
@@ -80,8 +82,9 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mojocn/base64Captcha v1.3.8 h1:rrN9BhCwXKS8ht1e21kvR3iTaMgf4qPC9sRoV52bqEg=
@@ -90,17 +93,21 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/redis/go-redis/v9 v9.13.0 h1:PpmlVykE0ODh8P43U0HqC+2NXHXwG+GUtQyz+MPKGRg=
github.com/redis/go-redis/v9 v9.13.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
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/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=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
@@ -121,44 +128,57 @@ github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqj
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -167,8 +187,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -176,8 +196,6 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -190,8 +208,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -209,22 +227,20 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
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.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
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=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

18
main.go
View File

@@ -1,9 +1,9 @@
package main
import "networkDev/cmd"
// main 是程序的入口点
// 调用Cobra命令执行器来处理命令行参数和子命令
func main() {
cmd.Execute()
}
package main
import "NetworkAuth/cmd"
// main 是程序的入口点
// 调用Cobra命令执行器来处理命令行参数和子命令
func main() {
cmd.Execute()
}

View File

@@ -1,7 +1,7 @@
package middleware
import (
"networkDev/web"
"NetworkAuth/web"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"

54
middleware/install.go Normal file
View File

@@ -0,0 +1,54 @@
package middleware
import (
"NetworkAuth/services"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// InstallCheckMiddleware 检查系统是否已安装
func InstallCheckMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Request.URL.Path
// 放行静态资源和favicon
if strings.HasPrefix(path, "/static/") || strings.HasPrefix(path, "/assets/") || path == "/favicon.ico" {
c.Next()
return
}
// 检查是否为安装相关的路由
isInstallRoute := path == "/install" || path == "/api/install"
// 获取系统的安装状态
// 在没有数据库的时候GetSettingsService().GetString 会返回默认值 "0"
isInstalled := services.GetSettingsService().GetString("is_installed", "0") == "1"
// 如果未安装且当前不是访问安装页面,则重定向到安装页面
if !isInstalled && !isInstallRoute {
// 对于 API 请求,返回 JSON 提示
if strings.HasPrefix(path, "/api/") || strings.Contains(path, "/api/") {
c.JSON(http.StatusForbidden, gin.H{
"code": 403,
"msg": "系统未初始化,请先完成安装",
})
c.Abort()
return
}
c.Redirect(http.StatusTemporaryRedirect, "/install")
c.Abort()
return
}
// 如果已安装但尝试访问安装页面,则重定向到首页或后台
if isInstalled && isInstallRoute {
c.Redirect(http.StatusTemporaryRedirect, "/admin")
c.Abort()
return
}
c.Next()
}
}

View File

@@ -1,11 +1,13 @@
package middleware
import (
"strings"
"time"
"NetworkAuth/utils/logger"
"github.com/gin-gonic/gin"
"networkDev/utils/logger"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
)
// ============================================================================
@@ -34,76 +36,49 @@ func NewLoggingMiddleware(logger *logger.Logger) *LoggingMiddleware {
// ============================================================================
// Handler 返回Gin中间件函数用于记录HTTP请求日志
// 记录格式遵循Apache Common Log Format
// 记录格式参考了更灵活的 NetworkAuth 实现,支持配置开关和日志级别检查
func (lm *LoggingMiddleware) Handler() gin.HandlerFunc {
return func(c *gin.Context) {
// 记录开始时间
// 检查是否启用了访问日志
if !viper.GetBool("server.access_log") {
c.Next()
return
}
// 如果日志级别不是Debug或更高Trace则不记录访问日志
// 避免在Info级别输出过多的访问日志干扰正常业务日志
if lm.logger.Level < logrus.DebugLevel {
c.Next()
return
}
start := time.Now()
path := c.Request.URL.Path
raw := c.Request.URL.RawQuery
// 处理请求
c.Next()
// 计算处理时间
// 计算响应时间
duration := time.Since(start)
// 获取客户端IP
clientIP := getClientIP(c)
if raw != "" {
path = path + "?" + raw
}
// 记录日志 - Apache Common Log Format
// 使用专门的HTTP日志方法避免User-Agent中的反斜杠被转义
// 记录请求日志
lm.logger.LogRequestWithHeaders(
c.Request.Method,
c.Request.RequestURI,
clientIP,
path,
c.ClientIP(), // 使用 Gin 内置的方法获取 IP
c.Writer.Status(),
duration,
"-", // referer (已废弃)
c.Errors.ByType(gin.ErrorTypePrivate).String(),
c.Request.UserAgent(),
)
}
}
// ============================================================================
// 私有函数
// ============================================================================
// getClientIP 获取客户端真实IP地址
// 优先从X-Forwarded-For、X-Real-IP等头部获取最后使用RemoteAddr
func getClientIP(c *gin.Context) string {
// 检查X-Forwarded-For头部
xForwardedFor := c.GetHeader("X-Forwarded-For")
if xForwardedFor != "" {
// X-Forwarded-For可能包含多个IP取第一个
ips := strings.Split(xForwardedFor, ",")
if len(ips) > 0 {
return strings.TrimSpace(ips[0])
}
}
// 检查X-Real-IP头部
xRealIP := c.GetHeader("X-Real-IP")
if xRealIP != "" {
return xRealIP
}
// 检查X-Forwarded头部
xForwarded := c.GetHeader("X-Forwarded")
if xForwarded != "" {
return xForwarded
}
// 使用RemoteAddr
remoteAddr := c.Request.RemoteAddr
if strings.Contains(remoteAddr, ":") {
// 移除端口号
if idx := strings.LastIndex(remoteAddr, ":"); idx != -1 {
return remoteAddr[:idx]
}
}
return remoteAddr
}
// ============================================================================
// 公共函数
// ============================================================================
@@ -111,7 +86,7 @@ func getClientIP(c *gin.Context) string {
// WrapHandler 创建Gin日志中间件
// 使用全局日志记录器创建日志中间件
func WrapHandler() gin.HandlerFunc {
logger := logger.GetLogger()
middleware := NewLoggingMiddleware(logger)
log := logger.GetLogger()
middleware := NewLoggingMiddleware(log)
return middleware.Handler()
}

99
middleware/maintenance.go Normal file
View File

@@ -0,0 +1,99 @@
package middleware
import (
"net/http"
"strings"
"NetworkAuth/services"
"github.com/gin-gonic/gin"
)
// MaintenanceMiddleware 维护模式中间件
// 当开启维护模式时,拦截非白名单请求
func MaintenanceMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 检查是否开启维护模式
if !services.GetSettingsService().IsMaintenanceMode() {
c.Next()
return
}
// 白名单检查(路径前缀匹配)
path := c.Request.URL.Path
// 1. 允许静态资源
if strings.HasPrefix(path, "/static/") || strings.HasPrefix(path, "/assets/") || path == "/favicon.ico" {
c.Next()
return
}
// 2. 允许管理员后台相关接口(以便管理员登录关闭维护模式)
// 包括登录页、登录接口、API接口、CSRF Token等
if strings.HasPrefix(path, "/admin") {
c.Next()
return
}
// 3. 检查请求类型
// AJAX/JSON 请求返回 503 JSON
accept := c.GetHeader("Accept")
xrw := strings.ToLower(strings.TrimSpace(c.GetHeader("X-Requested-With")))
if strings.Contains(accept, "application/json") || xrw == "xmlhttprequest" || strings.HasPrefix(path, "/api/") {
c.JSON(http.StatusServiceUnavailable, gin.H{
"code": 503,
"success": false,
"msg": "系统正在维护中,请稍后再试",
})
c.Abort()
return
}
// 4. 普通页面请求渲染维护页面
c.Header("Content-Type", "text/html; charset=utf-8")
c.Status(http.StatusServiceUnavailable)
c.Writer.WriteString(maintenanceHTML)
c.Abort()
}
}
// 简单的维护页面 HTML
const maintenanceHTML = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>系统维护中</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #f0f2f5;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
color: #333;
}
.container {
text-align: center;
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
max-width: 500px;
width: 90%;
}
h1 { font-size: 24px; margin-bottom: 16px; color: #1890ff; }
p { font-size: 16px; color: #666; line-height: 1.6; }
.icon { font-size: 64px; margin-bottom: 24px; color: #faad14; }
</style>
</head>
<body>
<div class="container">
<div class="icon">⚠️</div>
<h1>系统维护中</h1>
<p>为了提供更好的服务,系统正在进行升级维护。<br>请稍后访问,给您带来的不便敬请谅解。</p>
</div>
</body>
</html>`

17
models/login_log.go Normal file
View File

@@ -0,0 +1,17 @@
package models
import (
"time"
)
// LoginLog 登录日志模型
type LoginLog struct {
ID uint `gorm:"primarykey" json:"id"`
Type string `gorm:"type:varchar(20);index;comment:日志类型(admin/user)" json:"type"`
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"`
Message string `gorm:"type:varchar(255);comment:日志详情" json:"message"`
UserAgent string `gorm:"type:varchar(255);comment:用户代理" json:"user_agent"`
CreatedAt time.Time `gorm:"index;comment:创建时间" json:"created_at"`
}

24
models/operation_log.go Normal file
View File

@@ -0,0 +1,24 @@
package models
import (
"time"
)
// OperationLog 操作日志模型
type OperationLog struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `gorm:"index;comment:创建时间" json:"created_at"`
// 操作信息
OperationType string `gorm:"type:varchar(50);index;comment:操作方式" json:"operation_type"` // 如:入库成功、凭证分配等
// 操作人信息
Operator string `gorm:"type:varchar(100);index;comment:操作账号" json:"operator"`
OperatorUUID string `gorm:"type:varchar(36);index;comment:操作账号UUID" json:"operator_uuid"`
// 关联对象信息 (快照,防止关联对象被删除后无法查询)
TransactionID string `gorm:"type:varchar(100);index;comment:交易ID" json:"transaction_id"`
AppName string `gorm:"type:varchar(100);comment:应用名称" json:"app_name"`
ProductName string `gorm:"type:varchar(100);comment:商品名称" json:"product_name"`
Details string `gorm:"type:text;comment:操作详情" json:"details"`
}

View File

@@ -1,39 +1,36 @@
package server
import (
adminctl "networkDev/controllers/admin"
"networkDev/utils"
adminctl "NetworkAuth/controllers/admin"
"NetworkAuth/utils"
"github.com/gin-gonic/gin"
)
// ============================================================================
// 路由注册函数
// ============================================================================
// RegisterAdminRoutes 注册管理员后台相关路由
// - /admin/login: 支持GET渲染登录页、POST提交登录
// - /admin/logout: 管理员退出登录
// - /admin/dashboard: 管理员仪表盘(示例)
// - /admin/dashboard: 管理员仪表盘
// - /admin/fragment/*: 布局内动态片段加载
// - /admin/api/settings*: 设置接口(查询/更新)
func RegisterAdminRoutes(router *gin.Engine) {
// - /admin/api/*: 各种业务API
func RegisterAdminRoutes(r *gin.Engine) {
// /admin 根与前缀统一入口:根据是否登录跳转
router.GET("/admin", adminctl.AdminIndexHandler)
router.GET("/admin/", adminctl.AdminIndexHandler)
r.GET("/admin", adminctl.AdminIndexHandler)
r.GET("/admin/", adminctl.AdminIndexHandler)
// Admin 认证相关路由
router.GET("/admin/login", adminctl.LoginPageHandler)
router.POST("/admin/login", adminctl.LoginHandler) // CSRF验证在控制器内部处理
r.GET("/admin/login", adminctl.LoginPageHandler)
r.POST("/admin/login", adminctl.LoginHandler)
// 退出登录(无需拦截,幂等清理)
router.POST("/admin/logout", adminctl.LogoutHandler)
// 退出登录
r.GET("/admin/logout", adminctl.LogoutHandler)
r.POST("/admin/logout", adminctl.LogoutHandler)
// 验证码生成路由(无需认证)
router.GET("/admin/captcha", adminctl.CaptchaHandler)
r.GET("/admin/captcha", adminctl.CaptchaHandler)
// CSRF令牌获取API无需认证但需要在登录页面等地方获取
router.GET("/admin/api/csrf-token", func(c *gin.Context) {
r.GET("/admin/api/csrf-token", func(c *gin.Context) {
// 生成新的CSRF令牌
token, err := utils.GenerateCSRFToken()
if err != nil {
@@ -46,97 +43,125 @@ func RegisterAdminRoutes(router *gin.Engine) {
// 返回令牌给前端
c.JSON(200, gin.H{
"success": true,
"message": "CSRF令牌生成成功",
"csrf_token": token,
"code": 0, // 统一使用 code 0 表示成功
"success": true,
"message": "CSRF令牌生成成功",
"data": gin.H{
"csrf_token": token,
},
})
})
// 后台布局页(需要管理员认证)
router.GET("/admin/layout", adminctl.AdminAuthRequired(), adminctl.AdminLayoutHandler)
// 片段路由(需要管理员认证)
router.GET("/admin/dashboard", adminctl.AdminAuthRequired(), adminctl.DashboardFragmentHandler)
router.GET("/admin/user", adminctl.AdminAuthRequired(), adminctl.UserFragmentHandler)
router.GET("/admin/settings", adminctl.AdminAuthRequired(), adminctl.SettingsFragmentHandler)
router.GET("/admin/apps", adminctl.AdminAuthRequired(), adminctl.AppsFragmentHandler)
router.GET("/admin/apis", adminctl.AdminAuthRequired(), adminctl.APIFragmentHandler)
router.GET("/admin/variables", adminctl.AdminAuthRequired(), adminctl.VariableFragmentHandler)
router.GET("/admin/functions", adminctl.AdminAuthRequired(), adminctl.FunctionFragmentHandler)
// 系统信息API用于仪表盘定时刷新
router.GET("/admin/api/system/info", adminctl.AdminAuthRequired(), adminctl.SystemInfoHandler)
// 仪表盘统计数据API
router.GET("/admin/api/dashboard/stats", adminctl.AdminAuthRequired(), adminctl.DashboardStatsHandler)
// 个人资料API
userGroup := router.Group("/admin/api/user", adminctl.AdminAuthRequired())
// 需要认证的路由组
authorized := r.Group("/admin")
authorized.Use(adminctl.AdminAuthRequired())
{
userGroup.GET("/profile", adminctl.UserProfileQueryHandler)
userGroup.POST("/profile/update", adminctl.UserProfileUpdateHandler)
userGroup.POST("/password", adminctl.UserPasswordUpdateHandler)
}
// 后台布局页
authorized.GET("/layout", adminctl.AdminLayoutHandler)
// 系统设置API
settingsGroup := router.Group("/admin/api/settings", adminctl.AdminAuthRequired())
{
settingsGroup.GET("", adminctl.SettingsQueryHandler)
settingsGroup.POST("/update", adminctl.SettingsUpdateHandler)
}
// 片段路由
authorized.GET("/dashboard", adminctl.DashboardFragmentHandler)
authorized.GET("/profile", adminctl.ProfileFragmentHandler)
authorized.GET("/settings", adminctl.SettingsFragmentHandler)
authorized.GET("/operation_logs", adminctl.LogsFragmentHandler)
authorized.GET("/login_logs", adminctl.LoginLogsFragmentHandler)
authorized.GET("/apps", adminctl.AppsFragmentHandler)
authorized.GET("/apis", adminctl.APIFragmentHandler)
authorized.GET("/variables", adminctl.VariableFragmentHandler)
authorized.GET("/functions", adminctl.FunctionFragmentHandler)
// 应用管理API
appsGroup := router.Group("/admin/api/apps", adminctl.AdminAuthRequired())
{
appsGroup.GET("/list", adminctl.AppsListHandler)
appsGroup.GET("/simple", adminctl.AppsSimpleListHandler)
appsGroup.POST("/create", adminctl.AppCreateHandler)
appsGroup.POST("/update", adminctl.AppUpdateHandler)
appsGroup.POST("/delete", adminctl.AppDeleteHandler)
appsGroup.POST("/batch_delete", adminctl.AppsBatchDeleteHandler)
appsGroup.POST("/batch_update_status", adminctl.AppsBatchUpdateStatusHandler)
appsGroup.POST("/update_status", adminctl.AppUpdateStatusHandler)
appsGroup.POST("/reset_secret", adminctl.AppResetSecretHandler)
appsGroup.GET("/get_app_data", adminctl.AppGetAppDataHandler)
appsGroup.POST("/update_app_data", adminctl.AppUpdateAppDataHandler)
appsGroup.GET("/get_announcement", adminctl.AppGetAnnouncementHandler)
appsGroup.POST("/update_announcement", adminctl.AppUpdateAnnouncementHandler)
appsGroup.GET("/get_multi_config", adminctl.AppGetMultiConfigHandler)
appsGroup.POST("/update_multi_config", adminctl.AppUpdateMultiConfigHandler)
appsGroup.GET("/get_bind_config", adminctl.AppGetBindConfigHandler)
appsGroup.POST("/update_bind_config", adminctl.AppUpdateBindConfigHandler)
appsGroup.GET("/get_register_config", adminctl.AppGetRegisterConfigHandler)
appsGroup.POST("/update_register_config", adminctl.AppUpdateRegisterConfigHandler)
}
// 系统信息API
authorized.GET("/api/system/info", adminctl.SystemInfoHandler)
// 仪表盘数据
authorized.GET("/api/dashboard/stats", adminctl.DashboardStatsHandler)
authorized.GET("/api/dashboard/login-logs", adminctl.DashboardLoginLogsHandler)
// API接口管理API
apisGroup := router.Group("/admin/api/apis", adminctl.AdminAuthRequired())
{
apisGroup.GET("/list", adminctl.APIListHandler)
apisGroup.POST("/update", adminctl.APIUpdateHandler)
apisGroup.POST("/update_status", adminctl.APIUpdateStatusHandler)
apisGroup.GET("/types", adminctl.APIGetTypesHandler)
apisGroup.POST("/generate_keys", adminctl.APIGenerateKeysHandler)
}
// API 路由组
api := authorized.Group("/api")
{
// 个人资料API
profileGroup := api.Group("/profile")
{
profileGroup.GET("/info", adminctl.ProfileInfoHandler)
profileGroup.POST("/update", adminctl.ProfileUpdateHandler)
profileGroup.POST("/password", adminctl.ProfilePasswordUpdateHandler)
}
// 变量管理API
variableGroup := router.Group("/admin/variable", adminctl.AdminAuthRequired())
{
variableGroup.GET("/list", adminctl.VariableListHandler)
variableGroup.POST("/create", adminctl.VariableCreateHandler)
variableGroup.POST("/update", adminctl.VariableUpdateHandler)
variableGroup.POST("/delete", adminctl.VariableDeleteHandler)
variableGroup.POST("/batch_delete", adminctl.VariablesBatchDeleteHandler)
}
// 系统设置API
settingsGroup := api.Group("/settings")
{
settingsGroup.GET("", adminctl.SettingsQueryHandler)
settingsGroup.POST("/update", adminctl.SettingsUpdateHandler)
settingsGroup.POST("/generate-key", adminctl.SettingsGenerateKeyHandler)
}
// 函数管理API
functionGroup := router.Group("/admin/function", adminctl.AdminAuthRequired())
{
functionGroup.GET("/list", adminctl.FunctionListHandler)
functionGroup.POST("/create", adminctl.FunctionCreateHandler)
functionGroup.POST("/update", adminctl.FunctionUpdateHandler)
functionGroup.POST("/delete", adminctl.FunctionDeleteHandler)
functionGroup.POST("/batch_delete", adminctl.FunctionsBatchDeleteHandler)
}
// 操作日志API
logsGroup := api.Group("/logs")
{
logsGroup.GET("", adminctl.LogsListHandler)
logsGroup.POST("/clear", adminctl.LogsClearHandler)
}
// 登录日志API
loginLogsGroup := api.Group("/login_logs")
{
loginLogsGroup.GET("", adminctl.LoginLogsListHandler)
loginLogsGroup.POST("/clear", adminctl.LoginLogsClearHandler)
}
// 应用管理API
appsGroup := api.Group("/apps")
{
appsGroup.GET("/list", adminctl.AppsListHandler)
appsGroup.GET("/simple", adminctl.AppsSimpleListHandler)
appsGroup.POST("/create", adminctl.AppCreateHandler)
appsGroup.POST("/update", adminctl.AppUpdateHandler)
appsGroup.POST("/delete", adminctl.AppDeleteHandler)
appsGroup.POST("/batch_delete", adminctl.AppsBatchDeleteHandler)
appsGroup.POST("/batch_update_status", adminctl.AppsBatchUpdateStatusHandler)
appsGroup.POST("/update_status", adminctl.AppUpdateStatusHandler)
appsGroup.POST("/reset_secret", adminctl.AppResetSecretHandler)
appsGroup.GET("/get_app_data", adminctl.AppGetAppDataHandler)
appsGroup.POST("/update_app_data", adminctl.AppUpdateAppDataHandler)
appsGroup.GET("/get_announcement", adminctl.AppGetAnnouncementHandler)
appsGroup.POST("/update_announcement", adminctl.AppUpdateAnnouncementHandler)
appsGroup.GET("/get_multi_config", adminctl.AppGetMultiConfigHandler)
appsGroup.POST("/update_multi_config", adminctl.AppUpdateMultiConfigHandler)
appsGroup.GET("/get_bind_config", adminctl.AppGetBindConfigHandler)
appsGroup.POST("/update_bind_config", adminctl.AppUpdateBindConfigHandler)
appsGroup.GET("/get_register_config", adminctl.AppGetRegisterConfigHandler)
appsGroup.POST("/update_register_config", adminctl.AppUpdateRegisterConfigHandler)
}
// API接口管理API
apisGroup := api.Group("/apis")
{
apisGroup.GET("/list", adminctl.APIListHandler)
apisGroup.POST("/update", adminctl.APIUpdateHandler)
apisGroup.POST("/update_status", adminctl.APIUpdateStatusHandler)
apisGroup.GET("/types", adminctl.APIGetTypesHandler)
apisGroup.POST("/generate_keys", adminctl.APIGenerateKeysHandler)
}
}
// 变量管理API
variableGroup := authorized.Group("/variable")
{
variableGroup.GET("/list", adminctl.VariableListHandler)
variableGroup.POST("/create", adminctl.VariableCreateHandler)
variableGroup.POST("/update", adminctl.VariableUpdateHandler)
variableGroup.POST("/delete", adminctl.VariableDeleteHandler)
variableGroup.POST("/batch_delete", adminctl.VariablesBatchDeleteHandler)
}
// 函数管理API
functionGroup := authorized.Group("/function")
{
functionGroup.GET("/list", adminctl.FunctionListHandler)
functionGroup.POST("/create", adminctl.FunctionCreateHandler)
functionGroup.POST("/update", adminctl.FunctionUpdateHandler)
functionGroup.POST("/delete", adminctl.FunctionDeleteHandler)
functionGroup.POST("/batch_delete", adminctl.FunctionsBatchDeleteHandler)
}
}
}

View File

@@ -1,7 +1,7 @@
package server
import (
"networkDev/controllers/home"
default_ctrl "NetworkAuth/controllers/default"
"github.com/gin-gonic/gin"
)
@@ -10,9 +10,9 @@ import (
// 路由注册函数
// ============================================================================
// RegisterHomeRoutes 注册主页路由
// 只包含根路径,用于主页功能
func RegisterHomeRoutes(router *gin.Engine) {
// RegisterDefaultRoutes 注册默认路由
// 只包含根路径,用于默认主页功能
func RegisterDefaultRoutes(r *gin.Engine) {
// 根路径 - 主页
router.GET("/", home.RootHandler)
r.GET("/", default_ctrl.RootHandler)
}

16
server/install.go Normal file
View File

@@ -0,0 +1,16 @@
package server
import (
"NetworkAuth/controllers/install"
"github.com/gin-gonic/gin"
)
// RegisterInstallRoutes 注册安装相关的路由
func RegisterInstallRoutes(r *gin.Engine) {
// 安装向导页面
r.GET("/install", install.InstallPageHandler)
// 提交安装表单
r.POST("/api/install", install.InstallSubmitHandler)
}

View File

@@ -1,44 +1,36 @@
package server
import (
"NetworkAuth/web"
"io/fs"
"log"
"net/http"
"networkDev/web"
"github.com/gin-gonic/gin"
)
// ============================================================================
// 公共函数
// ============================================================================
// RegisterRoutes 聚合注册所有路由
func RegisterRoutes(router *gin.Engine) {
registerStaticRoutes(router)
registerFaviconRoute(router)
RegisterHomeRoutes(router)
RegisterAdminRoutes(router)
func RegisterRoutes(r *gin.Engine) {
registerStaticRoutes(r)
registerFaviconRoute(r)
RegisterInstallRoutes(r)
RegisterDefaultRoutes(r)
RegisterAdminRoutes(r)
}
// ============================================================================
// 私有函数
// ============================================================================
// registerStaticRoutes 注册静态资源路由
// 静态资源服务,将 /static/ 和 /assets/ 映射到嵌入的文件系统
func registerStaticRoutes(router *gin.Engine) {
func registerStaticRoutes(r *gin.Engine) {
if fsys, err := web.GetStaticFS(); err == nil {
// 为 /static/ 路径创建子文件系统
if staticSubFS, staticErr := fs.Sub(fsys, "static"); staticErr == nil {
router.StaticFS("/static", http.FS(staticSubFS))
r.StaticFS("/static", http.FS(staticSubFS))
} else {
log.Printf("创建静态资源子文件系统失败: %v", staticErr)
}
// 为 /assets/ 路径创建子文件系统
if assetsSubFS, assetsErr := fs.Sub(fsys, "assets"); assetsErr == nil {
router.StaticFS("/assets", http.FS(assetsSubFS))
r.StaticFS("/assets", http.FS(assetsSubFS))
} else {
log.Printf("创建资产资源子文件系统失败: %v", assetsErr)
}
@@ -48,9 +40,9 @@ func registerStaticRoutes(router *gin.Engine) {
}
// registerFaviconRoute 注册favicon路由
func registerFaviconRoute(router *gin.Engine) {
func registerFaviconRoute(r *gin.Engine) {
// 将 /favicon.ico 重定向到 /assets/favicon.svg
router.GET("/favicon.ico", func(c *gin.Context) {
r.GET("/favicon.ico", func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, "/assets/favicon.svg")
})
}

117
services/log_cleanup.go Normal file
View File

@@ -0,0 +1,117 @@
package services
import (
"NetworkAuth/database"
"NetworkAuth/models"
"fmt"
"strconv"
"time"
"github.com/sirupsen/logrus"
)
// StartLogCleanupTask 启动日志清理定时任务
// 每天执行一次,且服务启动后也会尝试执行一次
func StartLogCleanupTask() {
go func() {
// 启动后延迟1分钟执行首次清理避免影响启动速度
time.Sleep(1 * time.Minute)
cleanupLogs()
// 每天执行一次
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for range ticker.C {
cleanupLogs()
}
}()
}
func cleanupLogs() {
logrus.Debug("开始执行日志清理任务...")
// 获取清理配置 (使用实时查询)
loginLogDays := getSettingInt("login_log_cleanup_days", 30)
loginLogLimit := getSettingInt("login_log_cleanup_limit", 10000)
operationLogDays := getSettingInt("operation_log_cleanup_days", 30)
operationLogLimit := getSettingInt("operation_log_cleanup_limit", 10000)
// 清理登录日志
if err := cleanupTable(&models.LoginLog{}, loginLogDays, loginLogLimit); err != nil {
logrus.WithError(err).Error("清理登录日志失败")
}
// 清理操作日志
if err := cleanupTable(&models.OperationLog{}, operationLogDays, operationLogLimit); err != nil {
logrus.WithError(err).Error("清理操作日志失败")
}
logrus.Debug("日志清理任务执行完成")
}
// getSettingInt 获取配置整数值
func getSettingInt(key string, defaultValue int) int {
setting, err := GetSettingsService().GetSettingRealtime(key)
if err != nil {
return defaultValue
}
val, err := strconv.Atoi(setting.Value)
if err != nil {
return defaultValue
}
return val
}
// cleanupTable 通用清理函数
func cleanupTable(model interface{}, retentionDays int, maxCount int) error {
db, err := database.GetDB()
if err != nil {
return err
}
// 1. 按天清理
if retentionDays > 0 {
cutoffDate := time.Now().AddDate(0, 0, -retentionDays)
result := db.Unscoped().Where("created_at < ?", cutoffDate).Delete(model)
if result.Error != nil {
return fmt.Errorf("按天清理失败: %w", result.Error)
}
if result.RowsAffected > 0 {
logrus.Debugf("清理日志 (按天): 删除 %d 条记录", result.RowsAffected)
}
}
// 2. 按数量清理
if maxCount > 0 {
count, err := CountEntitiesByCondition(model, "", db)
if err != nil {
return fmt.Errorf("查询总数失败: %w", err)
}
if count > int64(maxCount) {
// 找出保留范围内的最后一条记录(第 maxCount 条,按时间倒序)
var keepRecord struct {
ID uint
}
// 假设 ID 是自增的主键,且 ID 越大代表记录越新
if err := db.Model(model).Select("id").Order("id DESC").Offset(maxCount - 1).Limit(1).Scan(&keepRecord).Error; err != nil {
return fmt.Errorf("查询分界记录失败: %w", err)
}
if keepRecord.ID > 0 {
result := db.Unscoped().Where("id < ?", keepRecord.ID).Delete(model)
if result.Error != nil {
return fmt.Errorf("按数量清理失败: %w", result.Error)
}
if result.RowsAffected > 0 {
logrus.Debugf("清理日志 (按数量): 删除 %d 条记录", result.RowsAffected)
}
}
}
}
return nil
}

33
services/operation_log.go Normal file
View File

@@ -0,0 +1,33 @@
package services
import (
"NetworkAuth/database"
"NetworkAuth/models"
"time"
"github.com/sirupsen/logrus"
)
// RecordOperationLog 记录操作日志
func RecordOperationLog(operationType, operator, operatorUUID, transactionID, appName, productName, details string) {
db, err := database.GetDB()
if err != nil {
logrus.WithError(err).Error("获取数据库连接失败,无法记录操作日志")
return
}
log := models.OperationLog{
OperationType: operationType,
Operator: operator,
OperatorUUID: operatorUUID,
TransactionID: transactionID,
AppName: appName,
ProductName: productName,
Details: details,
CreatedAt: time.Now(),
}
if err := db.Create(&log).Error; err != nil {
logrus.WithError(err).Error("创建操作日志失败")
}
}

View File

@@ -1,10 +1,10 @@
package services
import (
"NetworkAuth/models"
"NetworkAuth/utils"
"context"
"fmt"
"networkDev/models"
"networkDev/utils"
"time"
"gorm.io/gorm"
@@ -69,7 +69,11 @@ func BatchUpdateEntityStatus(model interface{}, ids []uint, status int, db *gorm
// 返回: 数量和错误
func CountEntitiesByCondition(model interface{}, condition string, db *gorm.DB, args ...interface{}) (int64, error) {
var count int64
err := db.Model(model).Where(condition, args...).Count(&count).Error
query := db.Model(model)
if condition != "" {
query = query.Where(condition, args...)
}
err := query.Count(&count).Error
return count, err
}
@@ -85,7 +89,11 @@ func CountEntitiesByCondition(model interface{}, condition string, db *gorm.DB,
// args: 查询参数
// 返回: 错误
func FindEntitiesByCondition(model interface{}, result interface{}, condition string, db *gorm.DB, args ...interface{}) error {
return db.Model(model).Where(condition, args...).Find(result).Error
query := db.Model(model)
if condition != "" {
query = query.Where(condition, args...)
}
return query.Find(result).Error
}
// CheckEntityExists 检查实体是否存在

View File

@@ -1,8 +1,8 @@
package services
import (
"networkDev/database"
"networkDev/models"
"NetworkAuth/database"
"NetworkAuth/models"
"strconv"
"sync"
@@ -53,6 +53,10 @@ func (s *SettingsService) loadAllSettings() {
logrus.WithError(err).Error("获取数据库连接失败")
return
}
// 如果数据库未初始化,直接返回,保持缓存为空
if db == nil {
return
}
var settings []models.Settings
if err := db.Find(&settings).Error; err != nil {
@@ -67,7 +71,24 @@ func (s *SettingsService) loadAllSettings() {
s.cache[setting.Name] = setting.Value
}
logrus.WithField("count", len(settings)).Info("设置缓存加载完成")
logrus.WithField("count", len(settings)).Debug("设置缓存加载完成")
}
// Set 设置值(用于测试或运行时更新)
func (s *SettingsService) Set(name, value string) {
s.mu.Lock()
defer s.mu.Unlock()
s.cache[name] = value
}
// GetSettingRealtime 实时获取设置值优先使用Redis缓存自动回源数据库
// 相比 GetString 的内存缓存此方法能感知其他实例或直接数据库的变更在Redis TTL过期后
func (s *SettingsService) GetSettingRealtime(name string) (*models.Settings, error) {
db, err := database.GetDB()
if err != nil {
return nil, err
}
return FindSettingByName(name, db)
}
// GetString 获取字符串类型的设置值
@@ -118,3 +139,32 @@ func (s *SettingsService) GetSessionTimeout() int {
func (s *SettingsService) IsMaintenanceMode() bool {
return s.GetBool("maintenance_mode", false)
}
// GetJWTSecret 获取JWT密钥
func (s *SettingsService) GetJWTSecret() string {
return s.GetString("jwt_secret", "")
}
// GetEncryptionKey 获取加密密钥
func (s *SettingsService) GetEncryptionKey() string {
return s.GetString("encryption_key", "")
}
// GetJWTRefresh 获取JWT刷新时间小时
func (s *SettingsService) GetJWTRefresh() int {
return s.GetInt("jwt_refresh", 6)
}
// GetJWTExpire 获取JWT有效期小时
func (s *SettingsService) GetJWTExpire() int {
return s.GetInt("jwt_expire", 24)
}
// GetCookieConfig 获取Cookie配置
func (s *SettingsService) GetCookieConfig() (secure bool, sameSite string, domain string, maxAge int) {
secure = s.GetBool("cookie_secure", true)
sameSite = s.GetString("cookie_same_site", "Lax")
domain = s.GetString("cookie_domain", "")
maxAge = s.GetInt("cookie_max_age", 86400)
return
}

View File

@@ -3,8 +3,6 @@ package utils
import (
"net/http"
"time"
"github.com/spf13/viper"
)
// ============================================================================
@@ -15,7 +13,10 @@ import (
// name: Cookie名称
// value: Cookie值
// maxAge: 过期时间0表示会话Cookie-1表示立即过期
func CreateSecureCookie(name, value string, maxAge int) *http.Cookie {
// domain: Cookie域名
// secure: 是否只在HTTPS下发送
// sameSiteStr: SameSite属性Strict/Lax/None
func CreateSecureCookie(name, value string, maxAge int, domain string, secure bool, sameSiteStr string) *http.Cookie {
cookie := &http.Cookie{
Name: name,
Value: value,
@@ -24,14 +25,13 @@ func CreateSecureCookie(name, value string, maxAge int) *http.Cookie {
MaxAge: maxAge,
}
// 从配置读取安全设置
if viper.GetBool("security.cookie.secure") {
// 设置安全属性
if secure {
cookie.Secure = true
}
// 设置SameSite属性
sameSite := viper.GetString("security.cookie.same_site")
switch sameSite {
switch sameSiteStr {
case "Strict":
cookie.SameSite = http.SameSiteStrictMode
case "Lax":
@@ -44,8 +44,7 @@ func CreateSecureCookie(name, value string, maxAge int) *http.Cookie {
cookie.SameSite = http.SameSiteStrictMode
}
// 设置Domain(如果配置了)
domain := viper.GetString("security.cookie.domain")
// 设置Domain
if domain != "" {
cookie.Domain = domain
}
@@ -62,24 +61,11 @@ func CreateSecureCookie(name, value string, maxAge int) *http.Cookie {
}
// CreateSessionCookie 创建会话Cookie浏览器关闭时过期
func CreateSessionCookie(name, value string) *http.Cookie {
return CreateSecureCookie(name, value, 0)
func CreateSessionCookie(name, value string, domain string, secure bool, sameSiteStr string) *http.Cookie {
return CreateSecureCookie(name, value, 0, domain, secure, sameSiteStr)
}
// CreateExpiredCookie 创建立即过期的Cookie用于清理
func CreateExpiredCookie(name string) *http.Cookie {
return CreateSecureCookie(name, "", -1)
}
// ============================================================================
// 配置函数
// ============================================================================
// GetDefaultCookieMaxAge 获取默认Cookie过期时间
func GetDefaultCookieMaxAge() int {
maxAge := viper.GetInt("security.cookie.max_age")
if maxAge <= 0 {
return 86400 // 默认24小时
}
return maxAge
func CreateExpiredCookie(name string, domain string) *http.Cookie {
return CreateSecureCookie(name, "", -1, domain, false, "Lax")
}

View File

@@ -11,7 +11,6 @@ import (
"io"
"sync"
"github.com/spf13/viper"
"golang.org/x/crypto/bcrypt"
)
@@ -38,28 +37,26 @@ var cryptoManager = &CryptoManager{}
// 私有函数
// ============================================================================
// initCrypto 初始化加密管理器
// 缓存密钥和GCM实例避免重复创建
func (cm *CryptoManager) initCrypto() error {
cm.mutex.Lock()
defer cm.mutex.Unlock()
// InitEncryption 初始化加密管理器
// 必须在应用启动时调用,传入加密密钥
func InitEncryption(secret string) error {
cryptoManager.mutex.Lock()
defer cryptoManager.mutex.Unlock()
if cm.inited {
if cryptoManager.inited {
return nil
}
// 从配置中获取密钥
secret := viper.GetString("encryption_key")
if secret == "" {
secret = "default-secret"
return errors.New("加密密钥不能为空")
}
// 生成AES密钥
sum := sha256.Sum256([]byte(secret))
cm.key = sum[:]
cryptoManager.key = sum[:]
// 创建AES cipher
block, err := aes.NewCipher(cm.key)
block, err := aes.NewCipher(cryptoManager.key)
if err != nil {
return err
}
@@ -70,8 +67,19 @@ func (cm *CryptoManager) initCrypto() error {
return err
}
cm.gcm = gcm
cm.inited = true
cryptoManager.gcm = gcm
cryptoManager.inited = true
return nil
}
// initCrypto 检查加密管理器是否已初始化
func (cm *CryptoManager) initCrypto() error {
cm.mutex.RLock()
defer cm.mutex.RUnlock()
if !cm.inited {
return errors.New("加密管理器未初始化请先调用InitEncryption")
}
return nil
}

View File

@@ -101,110 +101,6 @@ func ConfigureConnectionPool(db *gorm.DB, config *DatabaseConfig) error {
return nil
}
// PingDatabase 检查数据库连接健康状态
// 使用指定的超时时间ping数据库以验证连接是否正常
func PingDatabase(db *gorm.DB, timeout time.Duration) error {
sqlDB, err := db.DB()
if err != nil {
return fmt.Errorf("获取底层数据库连接失败: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return sqlDB.PingContext(ctx)
}
// GetConnectionStats 获取数据库连接池统计信息
// 返回当前数据库连接池的详细统计数据,包括连接数、等待时间等
func GetConnectionStats(db *gorm.DB) (*sql.DBStats, error) {
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("获取底层数据库连接失败: %w", err)
}
stats := sqlDB.Stats()
return &stats, nil
}
// LogConnectionStats 记录数据库连接池统计信息
// 获取并记录数据库连接池的统计信息到日志中,用于监控和调试
func LogConnectionStats(db *gorm.DB) {
stats, err := GetConnectionStats(db)
if err != nil {
LogError("获取数据库连接池统计信息失败", err, nil)
return
}
LogInfo("数据库连接池统计", map[string]interface{}{
"open_connections": stats.OpenConnections,
"in_use": stats.InUse,
"idle": stats.Idle,
"wait_count": stats.WaitCount,
"wait_duration": stats.WaitDuration,
"max_idle_closed": stats.MaxIdleClosed,
"max_idle_time_closed": stats.MaxIdleTimeClosed,
"max_lifetime_closed": stats.MaxLifetimeClosed,
})
}
// StartHealthCheck 启动数据库健康检查
// 启动一个后台goroutine定期检查数据库连接健康状态
// 只在健康检查失败时输出错误日志,正常情况下不输出日志
func StartHealthCheck(db *gorm.DB, config *DatabaseConfig) {
go func() {
ticker := time.NewTicker(config.HealthCheckInterval)
defer ticker.Stop()
for range ticker.C {
if err := PingDatabase(db, config.PingTimeout); err != nil {
// 只在健康检查失败时输出错误日志
LogError("数据库健康检查失败", err, map[string]interface{}{
"ping_timeout": config.PingTimeout,
})
}
// 记录连接池统计信息(仅在调试模式下)
if logrus.GetLevel() == logrus.DebugLevel {
LogConnectionStats(db)
}
}
}()
// LogInfo("数据库健康检查已启动", map[string]interface{}{
// "check_interval": config.HealthCheckInterval,
// "ping_timeout": config.PingTimeout,
// })
}
// ValidateDatabaseConfig 验证数据库配置参数
// 检查数据库配置参数的有效性,确保所有参数都在合理范围内
func ValidateDatabaseConfig(config *DatabaseConfig) error {
if config.MaxIdleConns < 0 {
return fmt.Errorf("最大空闲连接数不能为负数: %d", config.MaxIdleConns)
}
if config.MaxOpenConns < 0 {
return fmt.Errorf("最大打开连接数不能为负数: %d", config.MaxOpenConns)
}
if config.MaxIdleConns > config.MaxOpenConns && config.MaxOpenConns > 0 {
return fmt.Errorf("最大空闲连接数(%d)不能大于最大打开连接数(%d)", config.MaxIdleConns, config.MaxOpenConns)
}
if config.ConnMaxLifetime < 0 {
return fmt.Errorf("连接最大生存时间不能为负数: %v", config.ConnMaxLifetime)
}
if config.ConnMaxIdleTime < 0 {
return fmt.Errorf("连接最大空闲时间不能为负数: %v", config.ConnMaxIdleTime)
}
if config.PingTimeout <= 0 {
return fmt.Errorf("Ping超时时间必须大于0: %v", config.PingTimeout)
}
if config.HealthCheckInterval <= 0 {
return fmt.Errorf("健康检查间隔必须大于0: %v", config.HealthCheckInterval)
}
return nil
}
// ============================================================================
// 全局变量
// ============================================================================
@@ -222,11 +118,24 @@ var (
// Redis函数
// ============================================================================
// RedisLogger 自定义Redis日志记录器
// 仅在Debug级别输出Redis内部日志
type RedisLogger struct{}
func (l *RedisLogger) Printf(ctx context.Context, format string, v ...interface{}) {
if logrus.GetLevel() >= logrus.DebugLevel {
logrus.Debugf(format, v...)
}
}
// InitRedis 初始化Redis客户端仅在配置存在时尝试连接
// - 从 viper 读取 security.redis.* 配置
// - 从 viper 读取 redis.* 配置
// - 如果连接失败,则标记为不可用,不影响主流程
func InitRedis() {
redisOnce.Do(func() {
// 设置自定义日志记录器避免在Info级别输出大量连接错误日志
redis.SetLogger(&RedisLogger{})
host := viper.GetString("redis.host")
port := viper.GetInt("redis.port")
if host == "" || port == 0 {
@@ -341,3 +250,107 @@ func RedisDel(ctx context.Context, keys ...string) error {
}
return nil
}
// PingDatabase 检查数据库连接健康状态
// 使用指定的超时时间ping数据库以验证连接是否正常
func PingDatabase(db *gorm.DB, timeout time.Duration) error {
sqlDB, err := db.DB()
if err != nil {
return fmt.Errorf("获取底层数据库连接失败: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return sqlDB.PingContext(ctx)
}
// GetConnectionStats 获取数据库连接池统计信息
// 返回当前数据库连接池的详细统计数据,包括连接数、等待时间等
func GetConnectionStats(db *gorm.DB) (*sql.DBStats, error) {
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("获取底层数据库连接失败: %w", err)
}
stats := sqlDB.Stats()
return &stats, nil
}
// LogConnectionStats 记录数据库连接池统计信息
// 获取并记录数据库连接池的统计信息到日志中,用于监控和调试
func LogConnectionStats(db *gorm.DB) {
stats, err := GetConnectionStats(db)
if err != nil {
LogError("获取数据库连接池统计信息失败", err, nil)
return
}
LogInfo("数据库连接池统计", map[string]interface{}{
"open_connections": stats.OpenConnections,
"in_use": stats.InUse,
"idle": stats.Idle,
"wait_count": stats.WaitCount,
"wait_duration": stats.WaitDuration,
"max_idle_closed": stats.MaxIdleClosed,
"max_idle_time_closed": stats.MaxIdleTimeClosed,
"max_lifetime_closed": stats.MaxLifetimeClosed,
})
}
// StartHealthCheck 启动数据库健康检查
// 启动一个后台goroutine定期检查数据库连接健康状态
// 只在健康检查失败时输出错误日志,正常情况下不输出日志
func StartHealthCheck(db *gorm.DB, config *DatabaseConfig) {
go func() {
ticker := time.NewTicker(config.HealthCheckInterval)
defer ticker.Stop()
for range ticker.C {
if err := PingDatabase(db, config.PingTimeout); err != nil {
// 只在健康检查失败时输出错误日志
LogError("数据库健康检查失败", err, map[string]interface{}{
"ping_timeout": config.PingTimeout,
})
}
// 记录连接池统计信息(仅在调试模式下)
if logrus.GetLevel() == logrus.DebugLevel {
LogConnectionStats(db)
}
}
}()
// LogInfo("数据库健康检查已启动", map[string]interface{}{
// "check_interval": config.HealthCheckInterval,
// "ping_timeout": config.PingTimeout,
// })
}
// ValidateDatabaseConfig 验证数据库配置参数
// 检查数据库配置参数的有效性,确保所有参数都在合理范围内
func ValidateDatabaseConfig(config *DatabaseConfig) error {
if config.MaxIdleConns < 0 {
return fmt.Errorf("最大空闲连接数不能为负数: %d", config.MaxIdleConns)
}
if config.MaxOpenConns < 0 {
return fmt.Errorf("最大打开连接数不能为负数: %d", config.MaxOpenConns)
}
if config.MaxIdleConns > config.MaxOpenConns && config.MaxOpenConns > 0 {
return fmt.Errorf("最大空闲连接数(%d)不能大于最大打开连接数(%d)", config.MaxIdleConns, config.MaxOpenConns)
}
if config.ConnMaxLifetime < 0 {
return fmt.Errorf("连接最大生存时间不能为负数: %v", config.ConnMaxLifetime)
}
if config.ConnMaxIdleTime < 0 {
return fmt.Errorf("连接最大空闲时间不能为负数: %v", config.ConnMaxIdleTime)
}
if config.PingTimeout <= 0 {
return fmt.Errorf("Ping超时时间必须大于0: %v", config.PingTimeout)
}
if config.HealthCheckInterval <= 0 {
return fmt.Errorf("健康检查间隔必须大于0: %v", config.HealthCheckInterval)
}
return nil
}

121
utils/excel/excel.go Normal file
View File

@@ -0,0 +1,121 @@
package excel
import (
"fmt"
"io"
"reflect"
"time"
"github.com/xuri/excelize/v2"
)
// ExportData 导出数据配置
type ExportData struct {
Headers []string // 表头显示名称列表
Fields []string // 对应结构体字段名或Map键名
Data interface{} // 数据源(必须是切片类型)
Sheet string // 工作表名称,默认为 Sheet1
}
// Export 生成Excel文件
func Export(config ExportData) (*excelize.File, error) {
f := excelize.NewFile()
sheet := config.Sheet
if sheet == "" {
sheet = "Sheet1"
}
// 如果不是默认Sheet1则创建新Sheet
index, err := f.NewSheet(sheet)
if err != nil {
return nil, err
}
// 设置表头
for i, header := range config.Headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
f.SetCellValue(sheet, cell, header)
}
// 设置表头样式(加粗、背景色)
style, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true},
Fill: excelize.Fill{Type: "pattern", Color: []string{"#E0E0E0"}, Pattern: 1},
})
f.SetRowStyle(sheet, 1, 1, style)
// 处理数据
val := reflect.ValueOf(config.Data)
if val.Kind() != reflect.Slice {
return nil, fmt.Errorf("data must be a slice")
}
for i := 0; i < val.Len(); i++ {
item := val.Index(i)
// 如果是指针,获取其指向的值
if item.Kind() == reflect.Ptr {
item = item.Elem()
}
rowNum := i + 2 // 从第2行开始第1行是表头
for j, field := range config.Fields {
cellName, _ := excelize.CoordinatesToCellName(j+1, rowNum)
var cellValue interface{}
// 尝试从结构体或Map中获取值
if item.Kind() == reflect.Struct {
fieldVal := item.FieldByName(field)
if fieldVal.IsValid() {
cellValue = fieldVal.Interface()
}
} else if item.Kind() == reflect.Map {
key := reflect.ValueOf(field)
mapVal := item.MapIndex(key)
if mapVal.IsValid() {
cellValue = mapVal.Interface()
}
}
// 特殊类型处理
switch v := cellValue.(type) {
case time.Time:
if !v.IsZero() {
f.SetCellValue(sheet, cellName, v.Format("2006-01-02 15:04:05"))
}
case *time.Time:
if v != nil && !v.IsZero() {
f.SetCellValue(sheet, cellName, v.Format("2006-01-02 15:04:05"))
}
default:
f.SetCellValue(sheet, cellName, v)
}
}
}
f.SetActiveSheet(index)
// 如果创建了新Sheet且名字不叫Sheet1删除默认的Sheet1
if sheet != "Sheet1" {
f.DeleteSheet("Sheet1")
}
return f, nil
}
// Parse 读取Excel文件内容返回二维字符串数组
func Parse(r io.Reader) ([][]string, error) {
f, err := excelize.OpenReader(r)
if err != nil {
return nil, err
}
defer f.Close()
// 获取第一个工作表
sheet := f.GetSheetName(0)
rows, err := f.GetRows(sheet)
if err != nil {
return nil, err
}
return rows, nil
}

View File

@@ -5,51 +5,40 @@ import (
"time"
)
// ============================================================================
// 全局变量
// ============================================================================
// serverStartTime 记录进程启动时间(近似服务器启动时间)
var serverStartTime = time.Now()
// ============================================================================
// 公共函数
// ============================================================================
// GetServerStartTime 获取服务器启动时间
// 返回: 服务器启动的时间
// - 返回进程初始化时记录的时间
func GetServerStartTime() time.Time {
return serverStartTime
}
// GetServerUptime 获取服务器运行时长
// 返回: 从服务器启动到现在的时间间隔
// GetServerUptime 获取服务器运行时长
// - 通过当前时间与启动时间差计算
func GetServerUptime() time.Duration {
return time.Since(serverStartTime)
}
// GetServerUptimeString 获取服务器运行时长字符串表示
// 返回: 格式化的运行时长字符串(中文单位)
// GetServerUptimeString 获取格式化的服务器运行时长字符串
// - 返回不带小数点的时长格式,如 "1h23m45s"
func GetServerUptimeString() string {
duration := time.Since(serverStartTime)
// 获取总秒数并转换为整数
totalSeconds := int(duration.Seconds())
// 计算天、小时、分钟、秒
days := totalSeconds / 86400
hours := (totalSeconds % 86400) / 3600
// 计算小时、分钟、秒
hours := totalSeconds / 3600
minutes := (totalSeconds % 3600) / 60
seconds := totalSeconds % 60
// 根据时长长度选择合适的格式
if days > 0 {
return fmt.Sprintf("%d%d小时%d分钟", days, hours, minutes)
} else if hours > 0 {
return fmt.Sprintf("%d小时%d分钟%d秒", hours, minutes, seconds)
if hours > 0 {
return fmt.Sprintf("%dh%dm%ds", hours, minutes, seconds)
} else if minutes > 0 {
return fmt.Sprintf("%d分钟%d", minutes, seconds)
return fmt.Sprintf("%dm%ds", minutes, seconds)
} else {
return fmt.Sprintf("%d", seconds)
return fmt.Sprintf("%ds", seconds)
}
}

View File

@@ -1,11 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#2563eb"/>
<stop offset="100%" stop-color="#60a5fa"/>
</linearGradient>
</defs>
<rect x="2" y="2" width="28" height="28" rx="5" fill="url(#g)"/>
<path d="M16 7 L21.5 16 L16 25 L10.5 16 Z" fill="#fff" opacity="0.95"/>
<circle cx="16" cy="16" r="2.5" fill="#2563eb"/>
</svg>

Before

Width:  |  Height:  |  Size: 487 B

View File

@@ -1,73 +1,67 @@
package web
import (
"embed"
"html/template"
"io/fs"
"log"
"os"
"path/filepath"
"github.com/spf13/viper"
)
// TemplatesFS 嵌入模板的文件系统
//
//go:embed template/*.html template/admin/*.html
var templatesFS embed.FS
// StaticFS 嵌入静态资源的文件系统(包含 CSS/JS 的 static 与 图片/字体等资源的 assets
//
//go:embed static/* assets/*
var staticFS embed.FS
// getDistRootFS 获取基于 server.dist 的本地文件系统
// 当 server.dist 非空且路径存在时,返回对应的本地只读 FS否则返回 nil
func getDistRootFS() fs.FS {
// 从配置中读取 server.dist
distPath := viper.GetString("server.dist")
if distPath == "" {
return nil
}
// 归一化路径,兼容相对/绝对
absPath := distPath
if !filepath.IsAbs(distPath) {
if p, err := filepath.Abs(distPath); err == nil {
absPath = p
}
}
// 检查目录是否存在
if info, err := os.Stat(absPath); err == nil && info.IsDir() {
return os.DirFS(absPath)
}
log.Printf("server.dist 路径无效或不可访问:%s将回退使用嵌入资源", distPath)
return nil
}
// ParseTemplates 解析模板
// 优先从 server.dist 指定目录加载(当配置非空且有效),否则回退到嵌入模板
func ParseTemplates() (*template.Template, error) { // Go 顶级函数不支持箭头写法
if distFS := getDistRootFS(); distFS != nil {
// 期望 dist 目录下存在 template 与 template/admin 结构
// 如:{dist}/template/*.html 与 {dist}/template/admin/*.html
return template.ParseFS(distFS, "template/*.html", "template/admin/*.html")
}
// 默认:使用嵌入模板
return template.ParseFS(templatesFS, "template/*.html", "template/admin/*.html")
}
// GetStaticFS 返回静态资源文件系统(包含 static 与 assets 目录)
// 优先使用 server.dist 指定的本地目录;否则回退到嵌入静态资源
func GetStaticFS() (fs.FS, error) { // Go 顶级函数不支持箭头写法
if distFS := getDistRootFS(); distFS != nil {
// 直接返回以 dist 根为起点的 FSroutes 中会再基于此 FS Sub 出 static 与 assets
return distFS, nil
}
return staticFS, nil
}
// IsDevMode 检查是否为开发模式
// 注意:这个函数保留用于向后兼容,建议使用 middleware.IsDevMode()
func IsDevMode() bool {
return viper.GetBool("server.dev_mode")
}
package web
import (
"embed"
"html/template"
"io/fs"
"log"
"os"
"path/filepath"
"github.com/spf13/viper"
)
// TemplatesFS 嵌入模板的文件系统
//
//go:embed template/*/*.html
var templatesFS embed.FS
// StaticFS 嵌入静态资源的文件系统(包含 CSS/JS 的 static 与 图片/字体等资源的 assets
//
//go:embed static/* assets/*
var staticFS embed.FS
// getDistRootFS 获取基于 server.dist 的本地文件系统
// 当 server.dist 非空且路径存在时,返回对应的本地只读 FS否则返回 nil
func getDistRootFS() fs.FS {
// 从配置中读取 server.dist
distPath := viper.GetString("server.dist")
if distPath == "" {
return nil
}
// 归一化路径,兼容相对/绝对
absPath := distPath
if !filepath.IsAbs(distPath) {
if p, err := filepath.Abs(distPath); err == nil {
absPath = p
}
}
// 检查目录是否存在
if info, err := os.Stat(absPath); err == nil && info.IsDir() {
return os.DirFS(absPath)
}
log.Printf("server.dist 路径无效或不可访问:%s将回退使用嵌入资源", distPath)
return nil
}
// ParseTemplates 解析模板
// 优先从 server.dist 指定目录加载(当配置非空且有效),否则回退到嵌入模板
func ParseTemplates() (*template.Template, error) { // Go 顶级函数不支持箭头写法
if distFS := getDistRootFS(); distFS != nil {
// 期望 dist 目录下存在 template 与 template/admin 结构
// 如:{dist}/template/*.html 与 {dist}/template/admin/*.html
return template.ParseFS(distFS, "template/*/*.html")
}
// 默认:使用嵌入模板
return template.ParseFS(templatesFS, "template/*/*.html")
}
// GetStaticFS 返回静态资源文件系统(包含 static 与 assets 目录)
// 优先使用 server.dist 指定的本地目录;否则回退到嵌入静态资源
func GetStaticFS() (fs.FS, error) { // Go 顶级函数不支持箭头写法
if distFS := getDistRootFS(); distFS != nil {
// 直接返回以 dist 根为起点的 FSroutes 中会再基于此 FS Sub 出 static 与 assets
return distFS, nil
}
return staticFS, nil
}

View File

@@ -1,104 +1,93 @@
wc-include{padding: 15px;display: block;}
#app {display: none;}
.layui-layout-right .layui-nav-bar {background-color: unset !important;}
.layui-layout-admin .layui-side {top: 0 !important;z-index: 1001;}
.layui-layout-admin .layui-logo {position: relative !important;height: 60px !important;top: -2px !important;}
/* Logo文字美化样式 */
.logo-enhanced {
font-family: 'Microsoft YaHei', 'PingFang SC', 'Helvetica Neue', Arial, sans-serif !important;
font-size: 18px !important;
font-weight: 600 !important;
color: #ffffff !important;
text-shadow: 0 1px 2px rgba(0,0,0,0.3) !important;
letter-spacing: 1px !important;
}
.layui-side,
.layui-header,
.layui-body,
.layui-footer {transition: left 0.3s;}
.collapse .layui-layout-admin .layui-side,
.collapse .layui-layout-admin .layui-header {left: -200px;}
.collapse .layui-layout-admin .layui-footer,
.collapse .layui-layout-admin .layui-body {left: 0px;}
::view-transition-old(root),
::view-transition-new(root) {animation: none;mix-blend-mode: normal;}
::view-transition-old(root) {z-index: 9999;}
::view-transition-new(root) {z-index: 1;}
.dark::view-transition-old(root) {z-index: 1;}
.dark::view-transition-new(root) {z-index: 9999;}
/* 以下为自定义样式 */
.system-info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; margin-bottom: 20px; }
.system-info-item { padding: 16px; border-radius: 8px; background: var(--card); border: 1px solid var(--border); }
.system-info-label { font-size: 14px; color: var(--muted); margin-bottom: 8px; }
.system-info-value { font-size: 16px; font-weight: 600; color: var(--fg); }
/* ===================== 滚动条美化与布局约束(右侧滑块条) ===================== */
/*
作用:
1. 统一 Admin 布局下内容区(.layui-body为局部滚动容器只在头部与页脚之间滚动
2. 美化 .layui-body 的滚动条样式,增强可用性与观感
3. 不影响登录页等非 Admin 布局页面(仅在 .layui-layout-admin 作用域内生效)
*/
:root {
/* 头部与页脚的高度变量,便于后续维护/调整 */
--admin-header-h: 60px;
--admin-footer-h: 0px; /* 当前页脚未启用,如启用可改为 44px 等 */
}
/* Admin 主容器占满视口,高度锁定,避免出现浏览器右侧全局滚动条 */
.layui-layout-admin {
position: relative;
height: 100vh;
overflow: hidden;
}
/* 头部/页脚高度同步到变量,确保与内容区上下边界垂直齐平 */
.layui-layout-admin .layui-header {
height: var(--admin-header-h);
line-height: var(--admin-header-h);
}
.layui-layout-admin .layui-footer {
height: var(--admin-footer-h);
line-height: var(--admin-footer-h);
}
/* 内容区设为局部滚动容器,顶部/底部与头部/页脚精确对齐 */
.layui-layout-admin .layui-body {
/* 仅约束垂直方向,左右定位保持与 Layui 默认一致,兼容现有折叠动画 */
top: var(--admin-header-h) !important;
bottom: var(--admin-footer-h) !important;
overflow: auto;
/* Firefox 滚动条样式(细滚动条+自定义颜色) */
scrollbar-width: thin; /* 细滚动条 */
scrollbar-color: var(--lay-color-secondary) var(--lay-color-bg-3); /* thumb 与 track 颜色 */
}
/* WebKit 滚动条样式Chrome/Edge/Safari */
.layui-layout-admin .layui-body::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.layui-layout-admin .layui-body::-webkit-scrollbar-track {
background: var(--lay-color-bg-2);
border-left: 1px solid var(--lay-color-border-2);
}
.layui-layout-admin .layui-body::-webkit-scrollbar-thumb {
/* 渐变+内边透明边框,获得圆润质感 */
background: linear-gradient(180deg, var(--lay-color-gray-7), var(--lay-color-gray-9));
border-radius: 8px;
border: 2px solid transparent;
background-clip: padding-box;
}
.layui-layout-admin .layui-body::-webkit-scrollbar-thumb:hover {
background: var(--lay-color-secondary); /* 悬停高亮,强化可交互性 */
}
.layui-layout-admin .layui-body::-webkit-scrollbar-corner {
background: transparent;
}
/* ===================== END 滚动条美化与布局约束 ===================== */
wc-include{padding: 15px;display: block;}
#app {display: none;}
.layui-layout-right .layui-nav-bar {background-color: unset !important;}
.layui-layout-admin .layui-side {top: 0 !important;z-index: 1001;}
.layui-layout-admin .layui-logo {position: relative !important;height: 60px !important;top: -2px !important;}
.layui-side,
.layui-header,
.layui-body,
.layui-footer {transition: left 0.3s;}
.collapse .layui-layout-admin .layui-side,
.collapse .layui-layout-admin .layui-header {left: -200px;}
.collapse .layui-layout-admin .layui-footer,
.collapse .layui-layout-admin .layui-body {left: 0px;}
::view-transition-old(root),
::view-transition-new(root) {animation: none;mix-blend-mode: normal;}
::view-transition-old(root) {z-index: 9999;}
::view-transition-new(root) {z-index: 1;}
.dark::view-transition-old(root) {z-index: 1;}
.dark::view-transition-new(root) {z-index: 9999;}
/* 以下为自定义样式 */
.system-info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; margin-bottom: 20px; }
.system-info-item { padding: 16px; border-radius: 8px; background: var(--card); border: 1px solid var(--border); }
.system-info-label { font-size: 14px; color: var(--muted); margin-bottom: 8px; }
.system-info-value { font-size: 16px; font-weight: 600; color: var(--fg); }
/* ===================== 滚动条美化与布局约束(右侧滑块条) ===================== */
/*
作用:
1. 统一 Admin 布局下内容区(.layui-body为局部滚动容器只在头部与页脚之间滚动
2. 美化 .layui-body 的滚动条样式,增强可用性与观感
3. 不影响登录页等非 Admin 布局页面(仅在 .layui-layout-admin 作用域内生效)
*/
:root {
/* 头部与页脚的高度变量,便于后续维护/调整 */
--admin-header-h: 60px;
--admin-footer-h: 0px; /* 当前页脚未启用,如启用可改为 44px 等 */
}
/* Admin 主容器占满视口,高度锁定,避免出现浏览器右侧全局滚动条 */
.layui-layout-admin {
position: relative;
height: 100vh;
overflow: hidden;
}
/* 头部/页脚高度同步到变量,确保与内容区上下边界垂直齐平 */
.layui-layout-admin .layui-header {
height: var(--admin-header-h);
line-height: var(--admin-header-h);
}
.layui-layout-admin .layui-footer {
height: var(--admin-footer-h);
line-height: var(--admin-footer-h);
}
/* 内容区设为局部滚动容器,顶部/底部与头部/页脚精确对齐 */
.layui-layout-admin .layui-body {
/* 仅约束垂直方向,左右定位保持与 Layui 默认一致,兼容现有折叠动画 */
top: var(--admin-header-h) !important;
bottom: var(--admin-footer-h) !important;
overflow: auto;
/* Firefox 滚动条样式(细滚动条+自定义颜色) */
scrollbar-width: thin; /* 细滚动条 */
scrollbar-color: var(--lay-color-secondary) var(--lay-color-bg-3); /* thumb 与 track 颜色 */
}
/* WebKit 滚动条样式Chrome/Edge/Safari */
.layui-layout-admin .layui-body::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.layui-layout-admin .layui-body::-webkit-scrollbar-track {
background: var(--lay-color-bg-2);
border-left: 1px solid var(--lay-color-border-2);
}
.layui-layout-admin .layui-body::-webkit-scrollbar-thumb {
/* 渐变+内边透明边框,获得圆润质感 */
background: linear-gradient(180deg, var(--lay-color-gray-7), var(--lay-color-gray-9));
border-radius: 8px;
border: 2px solid transparent;
background-clip: padding-box;
}
.layui-layout-admin .layui-body::-webkit-scrollbar-thumb:hover {
background: var(--lay-color-secondary); /* 悬停高亮,强化可交互性 */
}
.layui-layout-admin .layui-body::-webkit-scrollbar-corner {
background: transparent;
}
/* ===================== END 滚动条美化与布局约束 ===================== */

View File

@@ -1,178 +0,0 @@
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Microsoft YaHei', sans-serif;
}
.card-container {
background: rgba(255, 255, 255, 0.95);
border-radius: 15px;
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.2);
padding: 40px;
width: 100%;
max-width: 500px;
backdrop-filter: blur(10px);
}
.header {
text-align: center;
margin-bottom: 30px;
}
.header h1 {
color: #333;
font-size: 28px;
margin-bottom: 10px;
font-weight: 300;
}
.header p {
color: #666;
font-size: 14px;
}
.progress-container {
margin-bottom: 30px;
}
.progress-steps {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
position: relative;
}
.progress-steps::before {
content: '';
position: absolute;
top: 20px;
left: 20px;
right: 20px;
height: 2px;
background: #e6e6e6;
z-index: 1;
}
.progress-line {
position: absolute;
top: 20px;
left: 20px;
height: 2px;
background: #5FB878;
transition: width 0.5s ease;
z-index: 2;
width: 0%;
}
.step {
position: relative;
z-index: 3;
text-align: center;
flex: 1;
}
.step-circle {
width: 40px;
height: 40px;
border-radius: 50%;
background: #e6e6e6;
color: #999;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 10px;
font-weight: bold;
transition: all 0.3s ease;
}
.step.active .step-circle {
background: #5FB878;
color: white;
}
.step.completed .step-circle {
background: #5FB878;
color: white;
}
.step-text {
font-size: 12px;
color: #666;
}
.step.active .step-text {
color: #5FB878;
font-weight: bold;
}
.form-container {
margin-top: 20px;
}
.form-item {
margin-bottom: 20px;
}
.form-item label {
display: block;
margin-bottom: 8px;
color: #333;
font-weight: 500;
}
.layui-input {
border-radius: 8px;
border: 1px solid #e6e6e6;
padding: 12px 15px;
font-size: 14px;
transition: border-color 0.3s ease;
}
.layui-input:focus {
border-color: #5FB878;
box-shadow: 0 0 0 2px rgba(95, 184, 120, 0.2);
}
.submit-btn {
width: 100%;
height: 45px;
background: linear-gradient(45deg, #5FB878, #42B983);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 20px;
}
.submit-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(95, 184, 120, 0.4);
}
.submit-btn:disabled {
background: #ccc;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.hidden {
display: none;
}
.loading {
text-align: center;
padding: 20px;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #5FB878;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 修复 select 下拉在某些浏览器中文字垂直被裁剪问题 */
select.layui-input,
select.layui-select {
/* 统一高度,避免被 padding 挤压导致文字显示不全 */
height: 40px;
line-height: 40px; /* 对多数浏览器有效,确保文字垂直居中 */
padding: 0 15px; /* 与 .layui-input 保持一致的水平内边距 */
box-sizing: border-box; /* 使高度计算更可控,不受 padding 影响 */
vertical-align: middle;
}
/* 兼容性优化:在部分内核下 select 需要明确字体大小与行高匹配 */
select.layui-input option,
select.layui-select option {
line-height: 40px;
}

View File

@@ -1,476 +1,463 @@
const VERSION = '2.10.1';
const layuicss = `https://unpkg.com/layui@${VERSION}/dist/css/layui.css`;
const layuijs = `https://unpkg.com/layui@${VERSION}/dist/layui.js`;
const rootPath = (function (src) {
src = (document.currentScript && document.currentScript.tagName.toUpperCase() === 'SCRIPT') ? document.currentScript.src : document.scripts[document.scripts.length - 1].src;
return src.substring(0, src.lastIndexOf('/') + 1);
})();
// CSRF令牌管理
const CSRFManager = {
// 缓存的CSRF令牌
token: null,
// 获取CSRF令牌
async getToken() {
if (this.token) {
return this.token;
}
try {
const response = await fetch('/admin/api/csrf-token', {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
if (response.ok) {
const data = await response.json();
if (data.code === 0 && data.data && data.data.csrf_token) {
this.token = data.data.csrf_token;
return this.token;
}
}
} catch (error) {
console.error('获取CSRF令牌失败:', error);
}
return null;
},
// 清除缓存的令牌
clearToken() {
this.token = null;
},
// 为fetch请求添加CSRF令牌
async addCSRFHeader(headers = {}) {
const token = await this.getToken();
if (token) {
headers['X-CSRF-Token'] = token;
}
return headers;
}
};
// 增强的fetch函数自动添加CSRF令牌
window.fetchWithCSRF = async function(url, options = {}) {
const headers = await CSRFManager.addCSRFHeader(options.headers || {});
return fetch(url, {
...options,
headers
});
};
const app = document.querySelector('#app')
addLink({ href: layuicss }).then(() => {
app.style.display = 'block';
});
addLink({ id: 'layui_theme_css', href: `./static/src/layui-theme-dark-selector.css` });
// TODO 弃用,下个版本只支持选择器模式
//addLink({ id: 'layui_theme_css', href: `${rootPath}dist/layui-theme-dark.css` });
loadScript(layuijs, function () {
layui
.config({
base: './static/lib/',
})
.extend({
drawer: 'drawer/drawer',
});
layui.use(['drawer', 'colorMode'], function () {
const { $, element, form, layer, util, dropdown, drawer, colorMode } = layui;
const APPERANCE_KEY = 'layui-theme-demo-prefer-dark';
const theme = colorMode.init({
selector: 'html',
attribute: 'class',
initialValue: 'dark',
modes: {
light: '',
dark: 'dark',
},
storageKey: APPERANCE_KEY,
onChanged(mode, defaultHandler) {
const isAppearanceTransition = document.startViewTransition && !window.matchMedia(`(prefers-reduced-motion: reduce)`).matches;
const isDark = mode === 'dark';
$('#change-theme').attr('class', `layui-icon layui-icon-${isDark ? 'moon' : 'light'}`);
if (!isAppearanceTransition) {
defaultHandler();
} else {
rippleViewTransition(isDark, function () {
defaultHandler();
});
}
},
});
routerTo({path: location.hash.slice(1) || 'dashboard'});
dropdown.render({
elem: '#change-theme',
align: 'center',
data: [
{
title: '深色模式',
id: 'dark',
icon: 'layui-icon-moon',
},
{
title: '浅色模式',
id: 'light',
icon: 'layui-icon-light',
},
{
title: '跟随系统',
id: 'auto',
icon: 'layui-icon-console',
},
],
templet(d) {
return `
<span style="display: flex;">
<i class="layui-icon ${d.icon}" style="margin-right: 8px"></i>
${d.title}
</span>`.trim();
},
click(obj) {
const { id: mode } = obj;
theme.setMode(mode);
},
});
util.event('lay-header-event', {
menuLeft() {
$('body').toggleClass('collapse');
},
menuRight() {
drawer.open({
area: '600px',
url: './static/tpl/theme.html',
hideOnClose: true,
id: 'drawer-theme-tpl',
shade: 0.01,
});
},
});
element.on('nav(nav-side)', function (elem) {
var path = elem.data('path');
if (path) {
routerTo({path});
if ($(window).width() <= 768) {
$('body').toggleClass('collapse', false);
}
}
});
$('#layuiv').text(layui.v);
/*
* 后台通用脚本
* 说明:统一处理全局的退出登录逻辑,遵循后端 jsonResponse 的返回格式:
* code: 0 表示成功非0表示失败
* msg: 提示信息
* data: 业务数据
*/
// 绑定退出登录按钮事件(箭头函数写法)
const bindLogout = () => {
const btn = document.getElementById('logout-btn');
if (!btn) return;
btn.addEventListener('click', (e) => {
e.preventDefault();
handleLogout();
});
};
// 执行退出登录(箭头函数写法)
// 功能:弹出确认框 -> 显示加载层 -> 调用 /admin/logout -> 依据 code===0 判断
const handleLogout = () => {
layer.confirm('确定要退出登录吗?', {
icon: 3,
title: '提示'
}, (index) => {
layer.close(index);
// 显示加载层
const loadIndex = layer.load(2, {
content: '正在退出登录...'
});
// 调用登出接口
fetchWithCSRF('/admin/logout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
layer.close(loadIndex);
const ok = data && data.code === 0;
const msg = (data && (data.msg || data.message)) || (ok ? '退出登录成功' : '退出登录失败');
if (ok) {
layer.msg(msg, {
icon: 1,
time: 1000
}, () => {
// 跳转到登录页或后端返回的地址
const redirect = (data && data.data && data.data.redirect) || '/admin/login';
window.location.href = redirect;
});
} else {
layer.msg(msg, { icon: 2 });
}
})
.catch(error => {
layer.close(loadIndex);
console.error('登出请求失败:', error);
layer.msg('网络错误,请重试', { icon: 2 });
});
});
};
// 页面就绪后绑定事件(箭头函数写法)
(() => {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bindLogout);
} else {
bindLogout();
}
})();
// 刷新页面功能处理
const handleRefresh = () => {
layer.confirm('确定要刷新内容吗?', {
icon: 3,
title: '提示'
}, (index) => {
layer.close(index);
// 获取当前hash值确定当前页面路径
let currentPath = window.location.hash.replace('#', '') || 'dashboard';
// 显示加载层
const loadIndex = layer.load(2, {
content: '正在刷新...'
});
// 延迟一下再刷新内容,让用户看到加载效果
setTimeout(() => {
// 重新加载当前内容页面
routerTo({ path: currentPath });
layer.close(loadIndex);
}, 500);
});
};
// 绑定刷新按钮点击事件
$('#refresh-btn').on('click', handleRefresh);
// 统一的Tips提示功能
// 使用事件委托避免重复绑定问题
$(document).off('click', '[data-tips]').on('click', '[data-tips]', function() {
var tipsType = $(this).data('tips');
var tipsContent = getTipsContent(tipsType);
layer.tips(tipsContent, this, {
tips: [2, '#16b777'], // 向右显示,绿色背景
time: 3000 // 3秒后自动关闭
});
});
// 获取Tips内容的统一函数
function getTipsContent(type) {
var tips = {
// 用户资料相关 (user.html)
'user-username': '用户名:用于登录的用户名,可以修改但需要保证唯一性',
'user-old-password': '密码:修改密码时需要输入当前密码进行验证,不修改密码时可留空',
'user-new-password': '新密码要设置的新密码长度至少6位不修改密码时可留空',
// 基本信息设置 (settings.html)
'site-title': '站点标题:网站的主标题,显示在浏览器标题栏和搜索引擎结果',
'site-keywords': '关键词网站的SEO关键词用于搜索引擎优化多个关键词用逗号分隔',
'site-description': '站点描述网站的简要描述用于SEO和搜索引擎结果展示',
'site-logo': '站点Logo网站的标志图片路径建议使用SVG格式',
// 系统配置 (settings.html)
'maintenance-mode': '维护模式:开启后网站将进入维护模式,普通用户无法访问',
'default-user-role': '默认角色新注册用户的默认权限级别0为管理员1为普通成员',
'session-timeout': '会话超时:用户登录会话的有效时间,单位为秒,超时后需要重新登录',
// 页脚与备案信息 (settings.html)
'footer-text': '页脚文本:显示在网站底部的版权信息或其他文本',
'icp-record': 'ICP备案网站的ICP备案号中国大陆网站必须显示',
'icp-record-link': 'ICP备案链接ICP备案号对应的查询链接通常指向工信部备案网站',
'psb-record': '公安备案:网站的公安备案号,部分地区要求显示',
'psb-record-link': '公安备案链接:公安备案号对应的查询链接,通常指向公安部备案网站',
// 应用管理相关 (apps.html)
'app-name': '应用名称:设置应用的显示名称,用户在客户端看到的应用标识',
'app-version': '应用版本:当前应用的版本号,用于版本控制和更新检测',
'app-status': '应用状态:控制应用是否可用,禁用后用户无法使用该应用',
'force-update': '强制更新:开启后用户必须更新到最新版本才能使用',
'download-type': '更新方式:设置应用的更新下载方式,支持不同的分发渠道',
'download-url': '下载地址:应用安装包的下载链接地址',
// 多开配置相关 (apps.html)
'login-type': '登录方式:设置用户登录验证的方式,如账号密码、卡密等',
'multi-open-scope': '多开范围:设置多开功能的作用范围,如全局或特定应用',
'clean-interval': '清理间隔:系统自动清理无效会话的时间间隔(分钟)',
'check-interval': '校验间隔:系统检查用户状态的时间间隔(分钟)',
'multi-open-count': '多开数量:允许用户同时运行的应用实例数量',
// 机器验证相关 (apps.html)
'machine-verify': '机器码验证:控制是否启用机器码验证功能,用于限制软件在特定设备上运行',
'machine-rebind': '机器码重绑:允许用户重新绑定机器码,当设备更换或重装系统时使用',
'machine-rebind-limit': '重绑限制:设置重绑的时间限制,每天表示每天可重绑,永久表示不限制重绑时间',
'machine-free-count': '免费次数:用户可以免费重绑机器码的次数',
'machine-rebind-count': '重绑次数:用户总共可以重绑机器码的次数限制',
'machine-rebind-deduct': '重绑扣除:每次重绑机器码时扣除的时间(分钟)',
// IP验证相关 (apps.html)
'ip-verify': 'IP地址验证控制是否启用IP地址验证关闭/开启/开启(市)/开启(省)分别对应不同的验证级别',
'ip-rebind': 'IP地址重绑允许用户重新绑定IP地址当网络环境变化时使用',
'ip-rebind-limit': '重绑限制设置IP重绑的时间限制每天表示每天可重绑永久表示不限制重绑时间',
'ip-free-count': '免费次数用户可以免费重绑IP地址的次数',
'ip-rebind-count': '重绑次数用户总共可以重绑IP地址的次数限制',
'ip-rebind-deduct': '重绑扣除每次重绑IP地址时扣除的时间分钟',
// 注册设置相关 (apps.html)
'register-enabled': '账号注册:控制是否允许新用户注册账号',
'register-limit': '注册限制:设置注册的限制规则,如时间限制等',
'register-limit-time': '限制时间:注册限制的时间周期,每天或永久',
'register-count': '注册次数:在限制时间内允许注册的账号数量',
// 试用设置相关 (apps.html)
'trial-enabled': '领取试用:控制是否允许用户领取试用时间',
'trial-limit-time': '限制时间:试用领取的时间限制周期',
'trial-time': '试用时间:用户可以领取的试用时长(分钟)',
// API接口管理相关 (apis.html)
'submit-algorithm': '提交算法:客户端向服务器提交数据时使用的加密算法<br/>• 不加密:数据明文传输,适用于内网环境<br/>• RC4对称加密速度快适用于一般场景<br/>• RSA非对称加密安全性高适用于敏感数据<br/>• RSA动态动态生成密钥的RSA加密安全性最高<br/>• 易加密自定义对称加密算法使用15-30位整数密钥数组',
'submit-keys': '提交密钥:用于加密客户端提交数据的密钥<br/>• RC416位十六进制密钥用于对称加密<br/>• RSA公钥用于客户端加密私钥用于服务器解密<br/>• 易加密15-30位整数数组逗号分隔<br/>• 密钥由系统自动生成,确保安全性',
'return-algorithm': '返回算法:服务器向客户端返回数据时使用的加密算法<br/>• 不加密:数据明文传输,适用于内网环境<br/>• RC4对称加密速度快适用于一般场景<br/>• RSA非对称加密安全性高适用于敏感数据<br/>• RSA动态动态生成密钥的RSA加密安全性最高<br/>• 易加密自定义对称加密算法使用15-30位整数密钥数组',
'return-keys': '返回密钥:用于加密服务器返回数据的密钥<br/>• RC416位十六进制密钥用于对称加密<br/>• RSA公钥用于服务器加密私钥用于客户端解密<br/>• 易加密15-30位整数数组逗号分隔<br/>• 密钥由系统自动生成,确保安全性',
'api-status': '接口状态控制当前API接口是否可用<br/>• 启用:接口正常工作,客户端可以调用<br/>• 禁用:接口暂停服务,客户端调用将返回错误',
// 变量管理相关 (variables.html)
'variable-alias': '变量别名:变量的唯一标识符,必须以英文字母开头,只能包含数字和英文字母,用于在代码中引用该变量',
'variable-app': '关联应用:选择变量所属的应用,选择"全局变量"表示该变量可在所有应用中使用',
'variable-data': '变量数据存储的具体数据内容可以是文本、数字、JSON等格式根据实际需要填写',
'variable-remark': '备注:对该变量的说明和描述,帮助理解变量的用途和使用场景,可选填写',
// 函数管理相关 (functions.html)
'function-alias': '函数别名:函数的唯一标识符,必须以英文字母开头,只能包含数字和英文字母,用于在代码中调用该函数',
'function-app': '关联应用:选择函数所属的应用,选择"全局函数"表示该函数可在所有应用中使用',
'function-code': '函数代码存储的JavaScript代码内容使用Goja引擎执行支持ES5语法和部分ES6特性',
'function-remark': '备注:对该函数的说明和描述,帮助理解函数的功能和使用场景,可选填写'
};
return tips[type] || '暂无说明';
}
function routerTo({
elem = '#router-view',
path = 'dashboard',
prefix = 'admin/', //路由前缀
suffix = '', //路由后缀
} = {}) {
var routerView = $(elem);
var url = prefix + path + suffix;
var loadTimer = setTimeout(() => {
layer.load(2);
}, 100);
history.replaceState({}, '', `#${path}`); // 因为并没有处理路由
routerView.attr('src', url)
routerView.off('load').on('load',function(){
element.render();
form.render();
clearTimeout(loadTimer);
layer.closeLast('loading');
})
// 选中, 展开菜单
$('#ws-nav-side')
.find("[data-path='" + path + "']")
.parent('dd')
.addClass('layui-this')
.closest('.layui-nav-item')
.addClass('layui-nav-itemed');
}
});
});
function rippleViewTransition(isDark, callback) {
// 移植自 https://github.com/vuejs/vitepress/pull/2347
// 支持 Chrome 111+
// 兼容 jQuery 3 下隐式 event 全局对象不可用的问题
if (!window.event) {
window.event = new MouseEvent('click', {
clientX: document.documentElement.clientWidth,
clientY: 60,
});
}
const x = event.clientX;
const y = event.clientY;
const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
const transition = document.startViewTransition(function () {
callback && callback();
});
transition.ready.then(function () {
var clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`];
document.documentElement.animate(
{
clipPath: isDark ? clipPath : [...clipPath].reverse(),
},
{
duration: 300,
easing: 'ease-in',
pseudoElement: isDark ? '::view-transition-new(root)' : '::view-transition-old(root)',
}
);
});
}
function addStyle(id, cssStr) {
const el = document.getElementById(id) || document.createElement('style');
if (!el.isConnected) {
el.type = 'text/css';
el.id = id;
document.head.appendChild(el);
}
el.textContent = cssStr;
}
function addLink(opt) {
return new Promise((resolve) => {
const link = Object.assign(document.createElement('link'), {
rel: 'stylesheet',
onload: () => resolve({ ...opt, status: 'success' }),
onerror: () => resolve({ ...opt, status: 'error' }), // 为了在 Promise.all 的使用场景
...opt,
});
document.head.appendChild(link);
});
}
function loadScript(url, callback) {
const script = document.createElement('script');
script.type = 'text/javascript';
script.async = 'async';
script.src = url;
document.body.appendChild(script);
if (script.readyState) {
script.onreadystatechange = function () {
if (script.readyState == 'complete' || script.readyState == 'loaded') {
script.onreadystatechange = null;
callback && callback();
}
};
} else {
script.onload = function () {
callback && callback();
};
}
}
const VERSION = '2.9.20'; // Using local version
const layuicss = '/static/lib/layui/css/layui.css';
const layuijs = '/static/lib/layui/layui.js';
const rootPath = (function (src) {
src = (document.currentScript && document.currentScript.tagName.toUpperCase() === 'SCRIPT') ? document.currentScript.src : document.scripts[document.scripts.length - 1].src;
return src.substring(0, src.lastIndexOf('/') + 1);
})();
// CSRF令牌管理
const CSRFManager = {
// 缓存的CSRF令牌
token: null,
// 获取CSRF令牌
async getToken() {
if (this.token) {
return this.token;
}
try {
const response = await fetch('/admin/api/csrf-token', {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
if (response.ok) {
const data = await response.json();
if (data.code === 0 && data.data && data.data.csrf_token) {
this.token = data.data.csrf_token;
return this.token;
}
}
} catch (error) {
console.error('获取CSRF令牌失败:', error);
}
return null;
},
// 清除缓存的令牌
clearToken() {
this.token = null;
},
// 为fetch请求添加CSRF令牌
async addCSRFHeader(headers = {}) {
const token = await this.getToken();
if (token) {
headers['X-CSRF-Token'] = token;
}
return headers;
}
};
// 增强的fetch函数自动添加CSRF令牌
window.fetchWithCSRF = async function(url, options = {}) {
const headers = await CSRFManager.addCSRFHeader(options.headers || {});
return fetch(url, {
...options,
headers
});
};
const app = document.querySelector('#app')
addLink({ href: layuicss }).then(() => {
app.style.display = 'block';
});
addLink({ id: 'layui_theme_css', href: `/static/src/layui-theme-dark-selector.css` });
loadScript(layuijs, function () {
layui
.config({
base: '/static/lib/',
})
.extend({
drawer: 'drawer/drawer',
});
layui.use(['drawer', 'colorMode', 'jquery', 'layer'], async function () {
const { $, element, form, layer, util, dropdown, drawer, colorMode } = layui;
// --- CSRF Setup for jQuery ---
// Ensure token is loaded
await CSRFManager.getToken();
$.ajaxSetup({
beforeSend: function(xhr) {
if (CSRFManager.token) {
xhr.setRequestHeader('X-CSRF-Token', CSRFManager.token);
}
},
complete: function(xhr) {
if (xhr.status === 401) {
window.location.href = '/admin/login';
}
}
});
// -----------------------------
const APPERANCE_KEY = 'layui-theme-demo-prefer-dark';
const theme = colorMode.init({
selector: 'html',
attribute: 'class',
initialValue: 'dark',
modes: {
light: '',
dark: 'dark',
},
storageKey: APPERANCE_KEY,
onChanged(mode, defaultHandler) {
const isAppearanceTransition = document.startViewTransition && !window.matchMedia(`(prefers-reduced-motion: reduce)`).matches;
const isDark = mode === 'dark';
$('#change-theme').attr('class', `layui-icon layui-icon-${isDark ? 'moon' : 'light'}`);
if (!isAppearanceTransition) {
defaultHandler();
} else {
rippleViewTransition(isDark, function () {
defaultHandler();
});
}
},
});
routerTo({path: location.hash.slice(1) || 'dashboard'});
dropdown.render({
elem: '#change-theme',
align: 'center',
data: [
{
title: '深色模式',
id: 'dark',
icon: 'layui-icon-moon',
},
{
title: '浅色模式',
id: 'light',
icon: 'layui-icon-light',
},
{
title: '跟随系统',
id: 'auto',
icon: 'layui-icon-console',
},
],
templet(d) {
return `
<span style="display: flex;">
<i class="layui-icon ${d.icon}" style="margin-right: 8px"></i>
${d.title}
</span>`.trim();
},
click(obj) {
const { id: mode } = obj;
theme.setMode(mode);
},
});
util.event('lay-header-event', {
menuLeft() {
$('body').toggleClass('collapse');
},
menuRight() {
drawer.open({
area: '600px',
url: './static/tpl/theme.html',
hideOnClose: true,
id: 'drawer-theme-tpl',
shade: 0.01,
});
},
});
element.on('nav(nav-side)', function (elem) {
var path = elem.data('path');
if (path) {
routerTo({path});
if ($(window).width() <= 768) {
$('body').toggleClass('collapse', false);
}
}
});
$('#layuiv').text(layui.v);
/*
* 后台通用脚本
* 说明:统一处理全局的退出登录逻辑
*/
// 绑定退出登录按钮事件
const bindLogout = () => {
const btn = document.getElementById('logout-btn');
if (!btn) return;
btn.addEventListener('click', (e) => {
e.preventDefault();
handleLogout();
});
};
// 执行退出登录
const handleLogout = () => {
layer.confirm('确定要退出登录吗?', {
icon: 3,
title: '提示'
}, (index) => {
layer.close(index);
const loadIndex = layer.load(2, {
content: '正在退出登录...'
});
fetchWithCSRF('/admin/logout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
layer.close(loadIndex);
const ok = data && (data.code === 0 || data.success);
const msg = (data && (data.msg || data.message)) || (ok ? '退出登录成功' : '退出登录失败');
if (ok) {
layer.msg(msg, {
icon: 1,
time: 1000
}, () => {
const redirect = (data && data.data && data.data.redirect) || '/admin/login';
window.location.href = redirect;
});
} else {
layer.msg(msg, { icon: 2 });
}
})
.catch(error => {
layer.close(loadIndex);
console.error('登出请求失败:', error);
layer.msg('网络错误,请重试', { icon: 2 });
});
});
};
(() => {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bindLogout);
} else {
bindLogout();
}
})();
// 刷新页面功能处理
const handleRefresh = () => {
layer.confirm('确定要刷新内容吗?', {
icon: 3,
title: '提示'
}, (index) => {
layer.close(index);
let currentPath = window.location.hash.replace('#', '') || 'dashboard';
const loadIndex = layer.load(2, {
content: '正在刷新...'
});
setTimeout(() => {
routerTo({ path: currentPath });
layer.close(loadIndex);
}, 500);
});
};
$('#refresh-btn').on('click', handleRefresh);
// 统一的Tips提示功能
$(document).off('click', '[data-tips]').on('click', '[data-tips]', function() {
var tipsType = $(this).data('tips');
var tipsContent = getTipsContent(tipsType);
layer.tips(tipsContent, this, {
tips: [2, '#16b777'],
time: 3000
});
});
function getTipsContent(type) {
var tips = {
'user-username': '用户名:用于登录的用户名,可以修改但需要保证唯一性',
'user-old-password': '旧密码:修改密码时需要输入当前密码进行验证,不修改密码时可留空',
'user-new-password': '密码:要设置的新密码长度至少6位,不修改密码时可留空',
'site-title': '站点标题:网站的主标题,显示在浏览器标题栏和搜索引擎结果中',
'site-keywords': '关键词网站的SEO关键词用于搜索引擎优化多个关键词用逗号分隔',
'site-description': '站点描述:网站的简要描述用于SEO和搜索引擎结果展示',
'site-logo': '站点Logo网站的标志图片路径建议使用SVG格式',
'maintenance-mode': '维护模式:开启后网站将进入维护模式,普通用户无法访问',
'default-user-role': '默认角色新注册用户的默认权限级别0为管理员1为普通成员',
'session-timeout': '会话超时:用户登录会话的有效时间,单位为秒,超时后需要重新登录',
'footer-text': '页脚文本:显示在网站底部的版权信息或其他文本',
'icp-record': 'ICP备案网站的ICP备案号中国大陆网站必须显示',
'icp-record-link': 'ICP备案链接ICP备案号对应的查询链接通常指向工信部备案网站',
'psb-record': '公安备案:网站的公安备案号,部分地区要求显示',
'psb-record-link': '公安备案链接:公安备案号对应的查询链接,通常指向公安部备案网站',
'app-name': '应用名称:设置应用的显示名称,用户在客户端看到的应用标识',
'app-version': '应用版本:当前应用的版本号,用于版本控制和更新检测',
'app-status': '应用状态:控制应用是否可用,禁用后用户无法使用该应用',
'force-update': '强制更新:开启后用户必须更新到最新版本才能使用',
'download-type': '更新方式:设置应用的更新下载方式,支持不同的分发渠道',
'download-url': '下载地址:应用安装包的下载链接地址',
'login-type': '登录方式:设置用户登录验证的方式,如账号密码、卡密等',
'multi-open-scope': '多开范围:设置多开功能的作用范围,如全局或特定应用',
'clean-interval': '清理间隔:系统自动清理无效会话的时间间隔(分钟)',
'check-interval': '校验间隔:系统检查用户状态的时间间隔(分钟)',
'multi-open-count': '多开数量:允许用户同时运行的应用实例数量',
'machine-verify': '机器码验证:控制是否启用机器码验证功能,用于限制软件在特定设备上运行',
'machine-rebind': '机器码重绑:允许用户重新绑定机器码,当设备更换或重装系统时使用',
'machine-rebind-limit': '重绑限制:设置重绑的时间限制,每天表示每天可重绑,永久表示不限制重绑时间',
'machine-free-count': '免费次数:用户可以免费重绑机器码的次数',
'machine-rebind-count': '重绑次数:用户总共可以重绑机器码的次数限制',
'machine-rebind-deduct': '重绑扣除:每次重绑机器码时扣除的时间(分钟)',
'ip-verify': 'IP地址验证控制是否启用IP地址验证关闭/开启/开启(市)/开启(省)分别对应不同的验证级别',
'ip-rebind': 'IP地址重绑允许用户重新绑定IP地址当网络环境变化时使用',
'ip-rebind-limit': '重绑限制设置IP重绑的时间限制每天表示每天可重绑永久表示不限制重绑时间',
'ip-free-count': '免费次数用户可以免费重绑IP地址的次数',
'ip-rebind-count': '重绑次数:用户总共可以重绑IP地址的次数限制',
'ip-rebind-deduct': '重绑扣除每次重绑IP地址时扣除的时间分钟',
'register-enabled': '账号注册:控制是否允许新用户注册账号',
'register-limit': '注册限制:设置注册的限制规则,如时间限制等',
'register-limit-time': '限制时间:注册限制的时间周期,每天或永久',
'register-count': '注册次数:在限制时间内允许注册的账号数量',
'trial-enabled': '领取试用:控制是否允许用户领取试用时间',
'trial-limit-time': '限制时间:试用领取的时间限制周期',
'trial-time': '试用时间:用户可以领取的试用时长(分钟)',
'submit-algorithm': '提交算法:客户端向服务器提交数据时使用的加密算法<br/>• 不加密:数据明文传输,适用于内网环境<br/>• RC4对称加密速度快适用于一般场景<br/>• RSA非对称加密安全性高适用于敏感数据<br/>• RSA动态动态生成密钥的RSA加密安全性最高<br/>• 易加密自定义对称加密算法使用15-30位整数密钥数组',
'submit-keys': '提交密钥:用于加密客户端提交数据的密钥<br/>• RC416位十六进制密钥用于对称加密<br/>• RSA公钥用于客户端加密私钥用于服务器解密<br/>• 易加密15-30位整数数组逗号分隔<br/>• 密钥由系统自动生成,确保安全性',
'return-algorithm': '返回算法:服务器向客户端返回数据时使用的加密算法<br/>• 不加密:数据明文传输,适用于内网环境<br/>• RC4对称加密速度快适用于一般场景<br/>• RSA非对称加密安全性高适用于敏感数据<br/>• RSA动态动态生成密钥的RSA加密安全性最高<br/>• 易加密自定义对称加密算法使用15-30位整数密钥数组',
'return-keys': '返回密钥:用于加密服务器返回数据的密钥<br/>• RC416位十六进制密钥用于对称加密<br/>• RSA公钥用于服务器加密私钥用于客户端解密<br/>• 易加密15-30位整数数组逗号分隔<br/>• 密钥由系统自动生成,确保安全性',
'api-status': '接口状态控制当前API接口是否可用<br/>• 启用:接口正常工作,客户端可以调用<br/>• 禁用:接口暂停服务,客户端调用将返回错误',
'variable-alias': '变量别名:变量的唯一标识符,必须以英文字母开头,只能包含数字和英文字母,用于在代码中引用该变量',
'variable-app': '关联应用:选择变量所属的应用,选择"全局变量"表示该变量可在所有应用中使用',
'variable-data': '变量数据存储的具体数据内容可以是文本、数字、JSON等格式根据实际需要填写',
'variable-remark': '备注:对该变量的说明和描述,帮助理解变量的用途和使用场景,可选填写',
'function-alias': '函数别名:函数的唯一标识符,必须以英文字母开头,只能包含数字和英文字母,用于在代码中调用该函数',
'function-app': '关联应用:选择函数所属的应用,选择"全局函数"表示该函数可在所有应用中使用',
'function-code': '函数代码存储的JavaScript代码内容使用Goja引擎执行支持ES5语法和部分ES6特性',
'function-remark': '备注:对该函数的说明和描述,帮助理解函数的功能和使用场景,可选填写'
};
return tips[type] || '暂无说明';
}
function routerTo({
elem = '#router-view',
path = 'dashboard',
prefix = '/admin/', //路由前缀
suffix = '', //路由后缀
} = {}) {
var routerView = $(elem);
var url = prefix + path + suffix;
var loadTimer = setTimeout(() => {
layer.load(2);
}, 100);
history.replaceState({}, '', `#${path}`);
routerView.attr('src', url)
routerView.off('load').on('load',function(){
element.render();
form.render();
clearTimeout(loadTimer);
layer.closeLast('loading');
})
// 选中, 展开菜单
$('#ws-nav-side')
.find("[data-path='" + path + "']")
.parent('dd')
.addClass('layui-this')
.closest('.layui-nav-item')
.addClass('layui-nav-itemed');
}
});
});
function rippleViewTransition(isDark, callback) {
// 移植自 https://github.com/vuejs/vitepress/pull/2347
// 支持 Chrome 111+
// 兼容 jQuery 3 下隐式 event 全局对象不可用的问题
if (!window.event) {
window.event = new MouseEvent('click', {
clientX: document.documentElement.clientWidth,
clientY: 60,
});
}
const x = event.clientX;
const y = event.clientY;
const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
const transition = document.startViewTransition(function () {
callback && callback();
});
transition.ready.then(function () {
var clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`];
document.documentElement.animate(
{
clipPath: isDark ? clipPath : [...clipPath].reverse(),
},
{
duration: 300,
easing: 'ease-in',
pseudoElement: isDark ? '::view-transition-new(root)' : '::view-transition-old(root)',
}
);
});
}
function addStyle(id, cssStr) {
const el = document.getElementById(id) || document.createElement('style');
if (!el.isConnected) {
el.type = 'text/css';
el.id = id;
document.head.appendChild(el);
}
el.textContent = cssStr;
}
function addLink(opt) {
return new Promise((resolve) => {
const link = Object.assign(document.createElement('link'), {
rel: 'stylesheet',
onload: () => resolve({ ...opt, status: 'success' }),
onerror: () => resolve({ ...opt, status: 'error' }), // 为了在 Promise.all 的使用场景
...opt,
});
document.head.appendChild(link);
});
}
function loadScript(url, callback) {
const script = document.createElement('script');
script.type = 'text/javascript';
script.async = 'async';
script.src = url;
document.body.appendChild(script);
if (script.readyState) {
script.onreadystatechange = function () {
if (script.readyState == 'complete' || script.readyState == 'loaded') {
script.onreadystatechange = null;
callback && callback();
}
};
} else {
script.onload = function () {
callback && callback();
};
}
}

45
web/static/lib/echarts/echarts.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2
web/static/lib/jquery/jquery.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -1,160 +1,181 @@
{{ define "dashboard.html" }}
<section>
<h2>系统信息</h2>
<div class="layui-row layui-col-space15" style="margin-top:12px">
<!-- 系统信息面板 -->
<div class="layui-col-md8">
<div class="layui-panel">
<div style="padding: 20px;">
<h3 style="margin-top: 0; margin-bottom: 15px; font-weight: bold; border-bottom: 1px solid var(--lay-color-border-2); padding-bottom: 10px;">系统信息</h3>
<table class="layui-table" lay-skin="nob">
<tbody>
<tr>
<td style="width: 120px; font-weight: bold;">程序版本</td>
<td style="height: 20px; vertical-align: middle;">
<span class="layui-badge layui-bg-blue" style="font-size: 14px; padding: 2px 8px; line-height: 1.2;">v{{ .Version }}</span>
</td>
</tr>
<tr>
<td style="font-weight: bold;">存储方案</td>
<td style="height: 20px; vertical-align: middle;">
<span class="layui-badge layui-bg-cyan" style="font-size: 14px; padding: 2px 8px; line-height: 1.2;">{{ .DBType }}</span>
</td>
</tr>
<tr>
<td style="font-weight: bold;">开发模式</td>
<td style="height: 20px; vertical-align: middle;">
{{ if .Mode }}
<span class="layui-badge layui-bg-orange" style="font-size: 14px; padding: 2px 8px; line-height: 1.2;">开启</span>
{{ else }}
<span class="layui-badge layui-bg-green" style="font-size: 14px; padding: 2px 8px; line-height: 1.2;">关闭</span>
{{ end }}
</td>
</tr>
<tr>
<td style="font-weight: bold;">运行时长</td>
<td style="height: 20px; vertical-align: middle;">
<span id="uptime-display" class="layui-badge layui-bg-gray" style="font-size: 14px; padding: 2px 8px; line-height: 1.2;">{{ .Uptime }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 应用统计面板 -->
<div class="layui-col-md4">
<div class="layui-panel">
<div style="padding: 20px;">
<h3 style="margin-top: 0; margin-bottom: 15px; font-weight: bold; border-bottom: 1px solid var(--lay-color-border-2); padding-bottom: 10px;">应用统计</h3>
<table class="layui-table" lay-skin="nob">
<tbody>
<tr>
<td style="width: 120px; font-weight: bold;">全部应用</td>
<td style="height: 20px; vertical-align: middle;">
<span id="total-apps" class="layui-badge" style="font-size: 14px; padding: 2px 8px; min-width: 30px; text-align: center; line-height: 1.2;">0</span>
</td>
</tr>
<tr>
<td style="font-weight: bold;">启用应用</td>
<td style="height: 20px; vertical-align: middle;">
<span id="enabled-apps" class="layui-badge layui-bg-green" style="font-size: 14px; padding: 2px 8px; min-width: 30px; text-align: center; line-height: 1.2;">0</span>
</td>
</tr>
<tr>
<td style="font-weight: bold;">禁用应用</td>
<td style="height: 20px; vertical-align: middle;">
<span id="disabled-apps" class="layui-badge layui-bg-orange" style="font-size: 14px; padding: 2px 8px; min-width: 30px; text-align: center; line-height: 1.2;">0</span>
</td>
</tr>
<tr>
<td style="font-weight: bold;">变量数</td>
<td style="height: 20px; vertical-align: middle;">
<span id="total-variables" class="layui-badge layui-bg-blue" style="font-size: 14px; padding: 2px 8px; min-width: 30px; text-align: center; line-height: 1.2;">0</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</section>
<script>
// 仪表盘统计脚本(采用箭头函数与中文注释)
layui.use(['layer', 'util'], function () {
const layer = layui.layer;
const util = layui.util;
const $ = layui.$;
// 全局引用ECharts CDN 地址
const echartsCdn = 'https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js';
// 工具函数:加载 ECharts 库(若已加载则直接回调)
// 功能:通过全局的 loadScript 方法按需加载图表库,避免重复加载
const ensureECharts = (cb) => {
if (window.echarts) { cb && cb(); return; }
if (typeof loadScript === 'function') {
loadScript(echartsCdn, () => cb && cb());
} else {
// 兜底:直接插入 <script>
const s = document.createElement('script');
s.src = echartsCdn;
s.onload = () => cb && cb();
document.head.appendChild(s);
}
};
// 函数:刷新基本信息和运行状态
// 说明:请求后台获取最新的系统信息并更新页面显示
const refreshSystemInfo = () => {
$.get('/admin/api/system/info', (res) => {
if (res && res.code === 0 && res.data) {
const data = res.data;
// 更新运行时长,保持徽章样式
if (data.uptime) {
const uptimeElement = $('#uptime-display');
uptimeElement.text(data.uptime);
// 确保徽章样式保持一致
if (!uptimeElement.hasClass('layui-badge')) {
uptimeElement.addClass('layui-badge layui-bg-gray');
uptimeElement.css({
'font-size': '14px',
'padding': '2px 8px',
'line-height': '1.2'
});
}
}
}
}).fail(() => {
console.log('获取系统信息失败');
});
};
// 函数:刷新应用统计数据
// 说明:请求后台获取应用统计信息并更新页面显示
const refreshAppStats = () => {
$.get('/admin/api/dashboard/stats', (res) => {
if (res && res.code === 0 && res.data) {
const data = res.data;
$('#total-apps').text(data.total_apps || 0);
$('#enabled-apps').text(data.enabled_apps || 0);
$('#disabled-apps').text(data.disabled_apps || 0);
$('#total-variables').text(data.total_variables || 0);
}
}).fail(() => {
// 显示默认值
$('#total-apps').text('0');
$('#enabled-apps').text('0');
$('#disabled-apps').text('0');
$('#total-variables').text('0');
});
};
// 立即刷新一次系统信息和应用统计
refreshSystemInfo();
refreshAppStats();
});
</script>
{{ define "dashboard.html" }}
<section>
<h2>系统信息</h2>
<div class="layui-row layui-col-space15" style="margin-top:12px">
<!-- 系统信息面板 -->
<div class="layui-col-md8">
<div class="layui-panel">
<div style="padding: 20px;">
<h3 style="margin-top: 0; margin-bottom: 15px; font-weight: bold; border-bottom: 1px solid var(--lay-color-border-2); padding-bottom: 10px;">系统信息</h3>
<table class="layui-table" lay-skin="nob">
<tbody>
<tr>
<td style="width: 120px; font-weight: bold;">程序版本</td>
<td style="height: 20px; vertical-align: middle;">
<span class="layui-badge layui-bg-blue" style="font-size: 14px; padding: 2px 8px; line-height: 1.2;">v{{ .Version }}</span>
</td>
</tr>
<tr>
<td style="font-weight: bold;">存储方案</td>
<td style="height: 20px; vertical-align: middle;">
<span class="layui-badge layui-bg-cyan" style="font-size: 14px; padding: 2px 8px; line-height: 1.2;">{{ .DBType }}</span>
</td>
</tr>
<tr>
<td style="font-weight: bold;">开发模式</td>
<td style="height: 20px; vertical-align: middle;">
{{ if .Mode }}
<span class="layui-badge layui-bg-orange" style="font-size: 14px; padding: 2px 8px; line-height: 1.2;">开启</span>
{{ else }}
<span class="layui-badge layui-bg-green" style="font-size: 14px; padding: 2px 8px; line-height: 1.2;">关闭</span>
{{ end }}
</td>
</tr>
<tr>
<td style="font-weight: bold;">运行时长</td>
<td style="height: 20px; vertical-align: middle;">
<span id="uptime-display" class="layui-badge layui-bg-gray" style="font-size: 14px; padding: 2px 8px; line-height: 1.2;">{{ .Uptime }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 系统统计面板 (预留) -->
<div class="layui-col-md4">
<div class="layui-panel">
<div style="padding: 20px;">
<h3 style="margin-top: 0; margin-bottom: 15px; font-weight: bold; border-bottom: 1px solid var(--lay-color-border-2); padding-bottom: 10px;">系统统计</h3>
<table class="layui-table" lay-skin="nob">
<tbody>
<tr>
<td style="width: 120px; font-weight: bold;">应用总数</td>
<td style="height: 20px; vertical-align: middle;">
<span id="total-apps" class="layui-badge" style="font-size: 14px; padding: 2px 8px; min-width: 30px; text-align: center; line-height: 1.2;">0</span>
</td>
</tr>
<tr>
<td style="font-weight: bold;">启用应用</td>
<td style="height: 20px; vertical-align: middle;">
<span id="enabled-apps" class="layui-badge layui-bg-green" style="font-size: 14px; padding: 2px 8px; min-width: 30px; text-align: center; line-height: 1.2;">0</span>
</td>
</tr>
<tr>
<td style="font-weight: bold;">禁用应用</td>
<td style="height: 20px; vertical-align: middle;">
<span id="disabled-apps" class="layui-badge layui-bg-gray" style="font-size: 14px; padding: 2px 8px; min-width: 30px; text-align: center; line-height: 1.2;">0</span>
</td>
</tr>
<tr>
<td style="font-weight: bold;">变量</td>
<td style="height: 20px; vertical-align: middle;">
<span id="total-variables" class="layui-badge layui-bg-orange" style="font-size: 14px; padding: 2px 8px; min-width: 30px; text-align: center; line-height: 1.2;">0</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<h2 style="margin-top: 20px;">最近登录日志</h2>
<div class="layui-panel" style="margin-top:12px">
<div style="padding: 20px;">
<table id="loginLogsTable" lay-filter="loginLogsTable"></table>
</div>
</div>
</section>
<script>
// 仪表盘脚本
layui.use(['jquery', 'table', 'util'], () => {
const $ = layui.$;
const table = layui.table;
const util = layui.util;
// 刷新基本信息和运行状态
const refreshSystemInfo = () => {
$.get('/admin/api/system/info', (res) => {
if (res && res.code === 0 && res.data) {
const data = res.data;
if (data.uptime) {
const uptimeElement = $('#uptime-display');
uptimeElement.text(data.uptime);
if (!uptimeElement.hasClass('layui-badge')) {
uptimeElement.addClass('layui-badge layui-bg-gray');
uptimeElement.css({
'font-size': '14px',
'padding': '2px 8px',
'line-height': '1.2'
});
}
}
}
}).fail(() => {
console.log('获取系统信息失败');
});
};
// 刷新系统统计数据
const refreshAppStats = () => {
$.get('/admin/api/dashboard/stats', (res) => {
if (res && res.code === 0 && res.data) {
const data = res.data;
$('#total-apps').text(data.total_apps || 0);
$('#enabled-apps').text(data.enabled_apps || 0);
$('#disabled-apps').text(data.disabled_apps || 0);
$('#total-variables').text(data.total_variables || 0);
}
}).fail(() => {
$('#total-apps').text('0');
$('#enabled-apps').text('0');
$('#disabled-apps').text('0');
$('#total-variables').text('0');
});
};
// 立即刷新一次
refreshSystemInfo();
refreshAppStats();
// 渲染登录日志表格
table.render({
elem: '#loginLogsTable',
url: '/admin/api/dashboard/login-logs',
page: true,
limit: 10,
limits: [10, 20, 30, 50],
cols: [[
{field: 'created_at', title: '登录时间', width: 180, templet: (d) => {
return util.toDateString(d.created_at);
}},
{field: 'username', title: '用户名', width: 150},
{field: 'ip', title: '登录IP', width: 150},
{field: 'status', title: '状态', width: 100, align: 'center', templet: (d) => {
return d.status === 1 ?
'<span class="layui-badge layui-bg-green">成功</span>' :
'<span class="layui-badge layui-bg-red">失败</span>';
}},
{field: 'message', title: '详情', minWidth: 150},
{field: 'user_agent', title: 'User Agent', minWidth: 200, templet: (d) => {
return '<div title="'+d.user_agent+'" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">'+d.user_agent+'</div>';
}}
]],
response: {
statusCode: 0
},
parseData: (res) => {
return {
"code": res.code,
"msg": res.msg,
"count": res.data ? res.data.total : 0,
"data": res.data ? res.data.list : []
};
}
});
});
</script>
{{ end }}

View File

@@ -9,7 +9,7 @@
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="stylesheet" href="/static/css/admin.css" />
<script type="module" src="./static/lib/include.js"></script>
<script type="module" src="/static/lib/include.js"></script>
</head>
<body>
@@ -48,7 +48,7 @@
<a class="" href="javascript:;">系统管理</a>
<dl class="layui-nav-child">
<dd><a data-path="dashboard" href="javascript:;">仪表盘</a></dd>
<dd><a data-path="user" href="javascript:;">个人资料</a></dd>
<dd><a data-path="profile" href="javascript:;">个人资料</a></dd>
<dd><a data-path="settings" href="javascript:;">系统设置</a></dd>
</dl>
</li>
@@ -61,6 +61,13 @@
<dd><a data-path="functions" href="javascript:;">公共函数</a></dd>
</dl>
</li>
<li class="layui-nav-item">
<a href="javascript:;">日志审计</a>
<dl class="layui-nav-child">
<dd><a data-path="login_logs" href="javascript:;">登录日志</a></dd>
<dd><a data-path="operation_logs" href="javascript:;">操作日志</a></dd>
</dl>
</li>
</ul>
</div>
</div>
@@ -70,7 +77,7 @@
</div>
<div class="layui-footer">{{ .FooterText }}</div>
</div>
<script type="module" src="./static/js/admin.js"></script>
<script type="module" src="/static/js/admin.js"></script>
</body>
</html>

View File

@@ -9,8 +9,7 @@
<!-- 站点图标 -->
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<!-- 请勿在项目正式环境中引用该 layui.css 地址 -->
<link href="//unpkg.com/layui@2.12.1/dist/css/layui.css" rel="stylesheet">
<link href="/static/lib/layui/css/layui.css" rel="stylesheet">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
@@ -206,8 +205,7 @@
</div>
</form>
<!-- 请勿在项目正式环境中引用该 layui.js 地址 -->
<script src="//unpkg.com/layui@2.12.1/dist/layui.js"></script>
<script src="/static/lib/layui/layui.js"></script>
<script>
layui.use(function () {
var form = layui.form;
@@ -215,8 +213,10 @@
// 登录提交回调:向 /admin/login 发送请求,并依据 code===0 判断成功与否
form.on('submit(demo-login)', function (data) {
var loadIndex = layer.load(1, {
shade: [0.1, '#fff']
var loadIndex = layer.msg('登录中...', {
icon: 16,
shade: 0.01,
time: 0
});
// 获取CSRF令牌
@@ -231,7 +231,15 @@
},
body: JSON.stringify(data.field)
})
.then(response => response.json())
.then(response => response.text())
.then(text => {
try {
return JSON.parse(text);
} catch (e) {
console.error('Non-JSON response:', text);
throw new Error('服务器响应格式错误');
}
})
.then(result => {
layer.close(loadIndex);
@@ -256,7 +264,8 @@
.catch(error => {
layer.close(loadIndex);
console.error('登录错误:', error);
layer.msg('网络错误,请稍后重试', { icon: 2 });
var msg = error.message || '网络错误,请稍后重试';
layer.msg(msg, { icon: 2 });
// 网络错误时也刷新验证码
document.getElementById('captcha-img').src = '/admin/captcha?t=' + new Date().getTime();

View File

@@ -0,0 +1,171 @@
{{ define "login_logs.html" }}
<section>
<h2>登录日志</h2>
<div class="layui-panel" style="margin-top:12px">
<h3 style="margin: 0; padding: 15px 20px; border-bottom: 1px solid var(--lay-color-border-2); padding-bottom: 10px; margin-bottom: 15px;">筛选</h3>
<div style="padding: 20px;">
<form class="layui-form layui-form-pane" id="loginLogFilterForm" lay-filter="loginLogFilterForm">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">日期范围</label>
<div class="layui-input-inline" style="width: 200px;">
<input type="text" class="layui-input" id="loginTimeRange" placeholder=" - " autocomplete="off">
<input type="hidden" name="login_start_time" id="login_start_time">
<input type="hidden" name="login_end_time" id="login_end_time">
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">状态</label>
<div class="layui-input-inline">
<select name="status">
<option value="">全部</option>
<option value="1">成功</option>
<option value="0">失败</option>
</select>
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">用户名</label>
<div class="layui-input-inline">
<input type="text" name="username" placeholder="请输入用户名" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">登录IP</label>
<div class="layui-input-inline">
<input type="text" name="ip" placeholder="请输入IP地址" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-inline">
<button type="button" class="layui-btn" id="btnSearchLoginLogs">搜索</button>
<button type="button" class="layui-btn layui-btn-primary" id="btnResetLoginLogs">重置</button>
</div>
</div>
</form>
</div>
</div>
<div class="layui-panel" style="margin-top:12px">
<h3 style="margin: 0; padding: 15px 20px; border-bottom: 1px solid var(--lay-color-border-2); padding-bottom: 10px; margin-bottom: 15px;">日志列表</h3>
<div style="padding: 20px;">
<script type="text/html" id="loginLogsToolbar">
<div class="layui-btn-container">
<button class="layui-btn layui-btn-sm layui-btn-danger" lay-event="clearLogs">
<i class="layui-icon layui-icon-delete"></i>
</button>
</div>
</script>
<table id="loginLogsTable" lay-filter="loginLogsTableFilter"></table>
</div>
</div>
</section>
<script>
layui.use(['table', 'form', 'laydate', 'util', 'jquery'], function(){
var table = layui.table;
var form = layui.form;
var laydate = layui.laydate;
var util = layui.util;
var $ = layui.jquery;
// 日期范围选择器
laydate.render({
elem: '#loginTimeRange',
range: true,
type: 'datetime',
format: 'yyyy-MM-dd HH:mm:ss',
done: function(value, date, endDate){
if(value) {
const dates = value.split(' - ');
$('#login_start_time').val(dates[0]);
$('#login_end_time').val(dates[1]);
} else {
$('#login_start_time').val('');
$('#login_end_time').val('');
}
}
});
// 渲染表格
var loginLogsTable = table.render({
elem: '#loginLogsTable',
url: '/admin/api/login_logs',
toolbar: '#loginLogsToolbar',
page: true,
limit: 20,
limits: [10, 20, 50, 100],
cols: [[
{field: 'username', title: '用户名', width: 150},
{field: 'ip', title: '登录IP', width: 150},
{field: 'status', title: '状态', width: 100, align: 'center', templet: function(d){
return d.status === 1 ?
'<span class="layui-badge layui-bg-green">成功</span>' :
'<span class="layui-badge layui-bg-red">失败</span>';
}},
{field: 'message', title: '详情', minWidth: 150},
{field: 'user_agent', title: 'User Agent', minWidth: 200, templet: function(d){
return '<div title="'+d.user_agent+'" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">'+d.user_agent+'</div>';
}},
{field: 'created_at', title: '登录时间', width: 180, templet: function(d){
return util.toDateString(d.created_at);
}}
]],
response: {
statusCode: 0
},
parseData: function(res){
return {
"code": res.code,
"msg": res.msg,
"count": res.data ? res.data.total : 0,
"data": res.data ? res.data.list : []
};
}
});
// 搜索按钮
$('#btnSearchLoginLogs').on('click', function(){
const formData = form.val('loginLogFilterForm');
loginLogsTable.reload({
where: {
status: formData.status,
username: formData.username,
ip: formData.ip,
start_time: $('#login_start_time').val(),
end_time: $('#login_end_time').val()
},
page: {curr: 1}
});
});
// 头工具栏事件
table.on('toolbar(loginLogsTableFilter)', function(obj){
switch(obj.event){
case 'clearLogs':
layer.confirm('确定要清空所有登录日志吗?此操作不可恢复!', {icon: 3, title:'警告'}, function(index){
$.post('/admin/api/login_logs/clear', function(res){
if(res.code === 0){
layer.msg('登录日志已清空', {icon: 1});
loginLogsTable.reload({page: {curr: 1}});
} else {
layer.msg(res.msg || '清空失败', {icon: 2});
}
});
layer.close(index);
});
break;
};
});
// 重置按钮
$('#btnResetLoginLogs').on('click', function(){
$('#loginLogFilterForm')[0].reset();
$('#login_start_time').val('');
$('#login_end_time').val('');
$('#loginTimeRange').val('');
form.render('select');
$('#btnSearchLoginLogs').click();
});
});
</script>
{{ end }}

View File

@@ -0,0 +1,165 @@
{{ define "operation_logs.html" }}
<section>
<h2>日志操作</h2>
<div class="layui-panel" style="margin-top:12px">
<h3 style="margin: 0; padding: 15px 20px; border-bottom: 1px solid var(--lay-color-border-2); padding-bottom: 10px; margin-bottom: 15px;">筛选</h3>
<div style="padding: 20px;">
<form class="layui-form layui-form-pane" id="operationLogFilterForm" lay-filter="operationLogFilterForm">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">日期范围</label>
<div class="layui-input-inline" style="width: 200px;">
<input type="text" class="layui-input" id="operationTimeRange" placeholder=" - " autocomplete="off">
<input type="hidden" name="operation_start_time" id="operation_start_time">
<input type="hidden" name="operation_end_time" id="operation_end_time">
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">操作方式</label>
<div class="layui-input-inline">
<input type="text" name="operation_type" placeholder="请输入操作方式" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">操作账号</label>
<div class="layui-input-inline">
<input type="text" name="operator" placeholder="请输入操作账号" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">交易ID</label>
<div class="layui-input-inline">
<input type="text" name="transaction_id" placeholder="请输入交易ID" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-inline">
<button type="button" class="layui-btn" id="btnSearchOperationLogs">搜索</button>
<button type="button" class="layui-btn layui-btn-primary" id="btnResetOperationLogs">重置</button>
</div>
</div>
</form>
</div>
</div>
<div class="layui-panel" style="margin-top:12px">
<h3 style="margin: 0; padding: 15px 20px; border-bottom: 1px solid var(--lay-color-border-2); padding-bottom: 10px; margin-bottom: 15px;">日志列表</h3>
<div style="padding: 20px;">
<script type="text/html" id="operationLogsToolbar">
<div class="layui-btn-container">
<button class="layui-btn layui-btn-sm layui-btn-danger" lay-event="clearLogs">
<i class="layui-icon layui-icon-delete"></i>
</button>
</div>
</script>
<table id="operationLogsTable" lay-filter="operationLogsTableFilter"></table>
</div>
</div>
</section>
<script>
layui.use(['table', 'form', 'laydate', 'util', 'jquery'], function(){
var table = layui.table;
var form = layui.form;
var laydate = layui.laydate;
var util = layui.util;
var $ = layui.jquery;
// 日期范围选择器
laydate.render({
elem: '#operationTimeRange',
range: true,
type: 'datetime',
format: 'yyyy-MM-dd HH:mm:ss',
done: function(value, date, endDate){
if(value) {
const dates = value.split(' - ');
$('#operation_start_time').val(dates[0]);
$('#operation_end_time').val(dates[1]);
} else {
$('#operation_start_time').val('');
$('#operation_end_time').val('');
}
}
});
// 渲染表格
var operationLogsTable = table.render({
elem: '#operationLogsTable',
url: '/admin/api/logs',
toolbar: '#operationLogsToolbar',
page: true,
limit: 20,
limits: [10, 20, 50, 100, 200, 500, 1000],
cols: [[
{field: 'app_name', title: '应用名称', minWidth: 150},
{field: 'product_name', title: '商品名称', minWidth: 150},
{field: 'transaction_id', title: '交易ID', width: 280},
{field: 'operator', title: '操作账号', minWidth: 150},
{field: 'operation_type', title: '操作方式', minWidth: 150},
{field: 'details', title: '日志内容', minWidth: 200},
{field: 'created_at', title: '创建时间', width: 180, templet: function(d){
return util.toDateString(d.created_at);
}}
]],
response: {
statusName: 'code',
statusCode: 0,
msgName: 'msg',
countName: 'count', // 解析数据长度的字段名称
dataName: 'data' // 解析数据列表的字段名称
},
parseData: function(res) { // 将原始数据格式解析成 table 组件所规定的数据格式
return {
"code": res.code, // 解析接口状态
"msg": res.msg, // 解析提示文本
"count": res.data ? res.data.total : 0, // 解析数据长度
"data": res.data ? res.data.list : [] // 解析数据列表
};
}
});
// 搜索按钮
$('#btnSearchOperationLogs').on('click', function(){
const formData = form.val('operationLogFilterForm');
operationLogsTable.reload({
where: {
operation_type: formData.operation_type,
operator: formData.operator,
transaction_id: formData.transaction_id,
start_time: $('#operation_start_time').val(),
end_time: $('#operation_end_time').val()
},
page: {curr: 1}
});
});
// 头工具栏事件
table.on('toolbar(operationLogsTableFilter)', function(obj){
switch(obj.event){
case 'clearLogs':
layer.confirm('确定要清空所有日志吗?此操作不可恢复!', {icon: 3, title:'警告'}, function(index){
$.post('/admin/api/logs/clear', function(res){
if(res.code === 0){
layer.msg('日志已清空', {icon: 1});
operationLogsTable.reload({page: {curr: 1}});
} else {
layer.msg(res.msg || '清空失败', {icon: 2});
}
});
layer.close(index);
});
break;
};
});
// 重置按钮
$('#btnResetOperationLogs').on('click', function(){
$('#operationLogFilterForm')[0].reset();
$('#operation_start_time').val('');
$('#operation_end_time').val('');
$('#operationTimeRange').val('');
$('#btnSearchOperationLogs').click();
});
});
</script>
{{ end }}

View File

@@ -0,0 +1,237 @@
{{ define "profile.html" }}
<div class="layui-card">
<div class="layui-card-header">个人资料</div>
<div class="layui-card-body">
<form class="layui-form" id="accountForm" lay-filter="accountForm" onsubmit="return false">
<!-- 按照要求纵向排序:用户名、旧密码、新密码、确认新密码 -->
<div class="layui-form-item">
<label class="layui-form-label">用户名</label>
<div class="layui-input-block">
<input type="text" name="username" placeholder="请输入用户名(不修改可留空或保持不变)" autocomplete="off" class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">旧密码</label>
<div class="layui-input-block">
<!-- 不修改密码时可留空 -->
<input type="password" name="old_password" placeholder="不修改可留空" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">新密码</label>
<div class="layui-input-block">
<!-- 不修改密码时可留空 -->
<input type="password" name="new_password" placeholder="不修改可留空至少6位" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">确认密码</label>
<div class="layui-input-block">
<!-- 不修改密码时可留空 -->
<input type="password" name="confirm_password" placeholder="不修改可留空" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="submitAccount">保存更改</button>
<!-- 将原先 type="reset" 改为自定义按钮,避免浏览器重置成初始空值 -->
<button type="button" id="btnReset" class="layui-btn layui-btn-primary">重置</button>
</div>
</div>
</form>
</div>
</div>
<script>
// 使用自执行函数创建局部作用域,避免与其他页面脚本发生全局命名冲突
(() => {
// 等待layui加载完成
function waitForLayui(callback) {
if (typeof layui !== 'undefined') {
callback();
} else {
setTimeout(() => waitForLayui(callback), 100);
}
}
waitForLayui(() => {
layui.use(['form', 'layer'], () => {
const form = layui.form
const layer = layui.layer
// 记录初始用户名,用于判断是否需要更新
let initialUsername = ''
// 缓存最近一次加载到表单中的资料,用于“重置”恢复
let lastProfile = null
// 加载个人资料:填充用户名
// 返回:无;副作用:设置 initialUsername、lastProfile 与表单值
const loadProfile = async () => {
try {
const res = await fetch('/admin/api/profile/info', {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
const data = await res.json()
const ok = (data.success === true) || (data.code === 0)
if (!ok) throw new Error(data.message || data.msg || '加载失败')
const payload = data.data || {}
initialUsername = payload.username || ''
const display = { ...payload }
lastProfile = display
form.val('accountForm', display)
} catch (e) {
layer.msg(e.message || '加载个人资料失败', { icon: 2 })
}
}
// 校验密码表单:当任一密码字段填写时,要求三个字段均填写且有效
// 返回:{ ok: boolean, msg?: string }
const validatePassword = (fields) => {
const oldPwd = (fields.old_password || '').trim()
const newPwd = (fields.new_password || '').trim()
const confirmPwd = (fields.confirm_password || '').trim()
const anyFilled = !!(oldPwd || newPwd || confirmPwd)
if (!anyFilled) return { ok: true }
if (!oldPwd || !newPwd || !confirmPwd) return { ok: false, msg: '请完整填写旧密码/新密码/确认新密码' }
if (newPwd.length < 6) return { ok: false, msg: '新密码长度不能少于6位' }
if (newPwd !== confirmPwd) return { ok: false, msg: '两次输入的新密码不一致' }
if (oldPwd === newPwd) return { ok: false, msg: '新密码不能与旧密码相同' }
return { ok: true }
}
// 更新用户名:传输 username 与 old_password当仅修改用户名时必须提供当前密码同时修改密码时沿用同一 old_password
// 返回Promise<void>
const updateUsername = async (username, oldPassword) => {
const payload = { username }
if (oldPassword) payload.old_password = oldPassword
const res = await fetch('/admin/api/profile/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(payload)
})
const data = await res.json()
const ok = (data.success === true) || (data.code === 0)
if (!ok) throw new Error(data.message || data.msg || '保存资料失败')
}
// 更新密码:仅传输旧/新/确认三个字段
// 返回Promise<any> 后端响应数据,用于可能的重定向处理
const updatePassword = async (fields) => {
const payload = {
old_password: fields.old_password,
new_password: fields.new_password,
confirm_password: fields.confirm_password
}
const res = await fetch('/admin/api/profile/password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(payload)
})
const data = await res.json()
const ok = (data.success === true) || (data.code === 0)
if (!ok) throw new Error(data.message || data.msg || '修改密码失败')
return data
}
// 提交综合更新:
// 规则:
// - 用户名:仅当与 initialUsername 不同且非空时更新
// - 密码:当任一密码字段填写时,要求完整校验并更新;若均未填则不更新
// - 若两者均无改动,则提示“未修改任何内容”
form.on('submit(submitAccount)', async (obj) => {
const fields = obj.field
const desiredUsername = (fields.username || '').trim()
const needUpdateUsername = desiredUsername && desiredUsername !== initialUsername
// 判定密码相关输入:
// - wantChangePassword输入了新密码或确认密码视为尝试修改密码将要求三个字段都填写
// - onlyOldProvided仅输入了旧密码用于支持“仅修改用户名需要当前密码”的场景
const hasOld = !!(fields.old_password && fields.old_password.trim())
const hasNewOrConfirm = !!((fields.new_password && fields.new_password.trim()) || (fields.confirm_password && fields.confirm_password.trim()))
const wantChangePassword = hasNewOrConfirm
const onlyOldProvided = hasOld && !hasNewOrConfirm
if (!needUpdateUsername && !wantChangePassword) {
layer.msg('未修改任何内容', { icon: 0 })
return false
}
// 修改密码场景:需进行严格校验(旧/新/确认均必填)
if (wantChangePassword) {
const pwdCheck = validatePassword(fields)
if (!pwdCheck.ok) {
layer.msg(pwdCheck.msg, { icon: 2 })
return false
}
}
// 仅修改用户名:要求输入当前密码
if (needUpdateUsername && !wantChangePassword && !hasOld) {
layer.msg('修改用户名需要输入当前密码', { icon: 2 })
return false
}
try {
// 始终先更新用户名,再更新密码(避免改密后跳转导致无法继续)
if (needUpdateUsername) {
await updateUsername(desiredUsername, hasOld ? fields.old_password : '')
initialUsername = desiredUsername
}
if (wantChangePassword) {
await updatePassword(fields)
layer.msg('密码修改成功', { icon: 1 })
// 清空密码框
form.val('accountForm', {
old_password: '',
new_password: '',
confirm_password: ''
})
} else {
// 未修改密码,仅修改资料
await loadProfile()
layer.msg('保存成功', { icon: 1 })
}
} catch (e) {
layer.msg(e.message || '保存失败', { icon: 2 })
}
return false
})
// 绑定“重置”按钮:将表单恢复为最近一次加载到表单中的资料
// 逻辑:
// - 如有 lastProfile直接回填
// - 回填时同时清空三个密码字段;
// - 如暂无缓存(极小概率),则重新请求资料
const bindReset = () => {
const btn = document.getElementById('btnReset')
if (!btn) return
btn.addEventListener('click', () => {
if (lastProfile) {
form.val('accountForm', { ...lastProfile, old_password: '', new_password: '', confirm_password: '' })
layer.msg('已恢复为当前资料', { icon: 1 })
} else {
loadProfile()
}
})
}
// 初始化加载
bindReset()
loadProfile()
})
})
})()
</script>
{{ end }}

View File

@@ -1,6 +1,156 @@
{{ define "settings.html" }}
<section>
<h2>系统设置</h2>
<!-- 系统配置设置 -->
<div class="layui-panel" style="margin-top: 16px;">
<h3 style="margin: 0; padding: 15px 20px; border-bottom: 1px solid var(--lay-color-border-2); padding-bottom: 10px; margin-bottom: 15px;">安全配置</h3>
<div style="padding: 20px;">
<form class="layui-form" id="systemForm">
<div class="layui-form-item">
<label class="layui-form-label" style="cursor: pointer;" data-tips="maintenance-mode">维护模式</label>
<div class="layui-input-block">
<div style="display: flex; align-items: center; justify-content: flex-start; gap: 10px;">
<input type="checkbox" name="maintenance_mode" lay-skin="switch" lay-text="开启|关闭" title="开启|关闭">
</div>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label" style="cursor: pointer;" data-tips="encryption-key">加密密钥</label>
<div class="layui-input-block">
<div style="display: flex; gap: 10px;">
<input type="text" name="encryption_key" placeholder="请输入数据加密密钥" class="layui-input" readonly>
<button type="button" class="layui-btn layui-btn-primary" id="generateEncBtn">生成</button>
</div>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label" style="cursor: pointer;" data-tips="jwt-secret">JWT密钥</label>
<div class="layui-input-block">
<div style="display: flex; gap: 10px;">
<input type="text" name="jwt_secret" placeholder="请输入JWT签名密钥" class="layui-input" readonly>
<button type="button" class="layui-btn layui-btn-primary" id="generateJwtBtn">生成</button>
</div>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label" style="cursor: pointer;" data-tips="jwt-refresh">JWT刷新</label>
<div class="layui-input-block">
<div style="display: flex; align-items: center; gap: 10px;">
<input type="number" name="jwt_refresh" placeholder="6" min="1" lay-affix="number" class="layui-input"
style="width: 120px;" />
<span class="layui-form-mid">小时至少1小时</span>
</div>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label" style="cursor: pointer;" data-tips="jwt-expire">JWT有效期</label>
<div class="layui-input-block">
<div style="display: flex; align-items: center; gap: 10px;">
<input type="number" name="jwt_expire" placeholder="24" min="1" lay-affix="number" class="layui-input"
style="width: 120px;" />
<span class="layui-form-mid">小时至少1小时</span>
</div>
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button type="button" class="layui-btn layui-btn-sm" lay-submit lay-filter="save_system">保存安全配置</button>
<button type="button" class="layui-btn layui-btn-primary layui-btn-sm reset-btn" data-type="system">重置</button>
</div>
</div>
</form>
</div>
</div>
<!-- Cookie 设置 -->
<div class="layui-panel" style="margin-top: 16px;">
<h3 style="margin: 0; padding: 15px 20px; border-bottom: 1px solid var(--lay-color-border-2); padding-bottom: 10px; margin-bottom: 15px;">Cookie 设置</h3>
<div style="padding: 20px;">
<form class="layui-form" id="cookieForm">
<div class="layui-form-item">
<label class="layui-form-label" style="cursor: pointer;" data-tips="cookie-secure">Secure</label>
<div class="layui-input-block">
<div style="display: flex; align-items: center; justify-content: flex-start; gap: 10px;">
<input type="checkbox" name="cookie_secure" lay-skin="switch" lay-text="开启|关闭" title="开启|关闭">
</div>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label" style="cursor: pointer;" data-tips="cookie-samesite">Same</label>
<div class="layui-input-block">
<select name="cookie_same_site">
<option value="Strict">Strict</option>
<option value="Lax">Lax</option>
<option value="None">None</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label" style="cursor: pointer;" data-tips="cookie-domain">Domain</label>
<div class="layui-input-block">
<input type="text" name="cookie_domain" placeholder="留空则默认为当前域名" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label" style="cursor: pointer;" data-tips="cookie-max-age">MaxAge</label>
<div class="layui-input-block">
<div style="display: flex; align-items: center; gap: 10px;">
<input type="number" name="cookie_max_age" placeholder="86400" min="0" lay-affix="number" class="layui-input"
style="width: 120px;" />
<span class="layui-form-mid"></span>
</div>
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button type="button" class="layui-btn layui-btn-sm" lay-submit lay-filter="save_cookie">保存Cookie设置</button>
<button type="button" class="layui-btn layui-btn-primary layui-btn-sm reset-btn" data-type="cookie">重置</button>
</div>
</div>
</form>
</div>
</div>
<!-- 日志清理设置 -->
<div class="layui-panel" style="margin-top: 16px;">
<h3 style="margin: 0; padding: 15px 20px; border-bottom: 1px solid var(--lay-color-border-2); padding-bottom: 10px; margin-bottom: 15px;">日志清理设置</h3>
<div style="padding: 20px;">
<form class="layui-form" id="logCleanupForm">
<div class="layui-form-item">
<label class="layui-form-label" style="cursor: pointer;" data-tips="login-log-cleanup">登录日志</label>
<div class="layui-input-block">
<div style="display: flex; align-items: center; gap: 10px;">
<span class="layui-form-mid">保留</span>
<input type="number" name="login_log_cleanup_days" placeholder="30" min="0" lay-affix="number" class="layui-input" style="width: 80px;" />
<span class="layui-form-mid">天,且保留最近</span>
<input type="number" name="login_log_cleanup_limit" placeholder="10000" min="0" lay-affix="number" class="layui-input" style="width: 100px;" />
<span class="layui-form-mid">0为不限制</span>
</div>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label" style="cursor: pointer;" data-tips="operation-log-cleanup">操作日志</label>
<div class="layui-input-block">
<div style="display: flex; align-items: center; gap: 10px;">
<span class="layui-form-mid">保留</span>
<input type="number" name="operation_log_cleanup_days" placeholder="30" min="0" lay-affix="number" class="layui-input" style="width: 80px;" />
<span class="layui-form-mid">天,且保留最近</span>
<input type="number" name="operation_log_cleanup_limit" placeholder="10000" min="0" lay-affix="number" class="layui-input" style="width: 100px;" />
<span class="layui-form-mid">0为不限制</span>
</div>
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button type="button" class="layui-btn layui-btn-sm" lay-submit lay-filter="save_cleanup">保存清理策略</button>
<button type="button" class="layui-btn layui-btn-primary layui-btn-sm reset-btn" data-type="cleanup">重置</button>
</div>
</div>
</form>
</div>
</div>
<!-- 基本信息设置 -->
<div class="layui-panel" style="margin-top: 16px;">
<h3 style="margin: 0; padding: 15px 20px; border-bottom: 1px solid var(--lay-color-border-2); padding-bottom: 10px; margin-bottom: 15px;">基本信息设置</h3>
@@ -30,43 +180,12 @@
<input type="text" name="site_logo" placeholder="/assets/logo.svg" class="layui-input" />
</div>
</div>
</form>
</div>
</div>
<!-- 系统配置设置 -->
<div class="layui-panel" style="margin-top: 16px;">
<h3 style="margin: 0; padding: 15px 20px; border-bottom: 1px solid var(--lay-color-border-2); padding-bottom: 10px; margin-bottom: 15px;">系统配置</h3>
<div style="padding: 20px;">
<form class="layui-form" id="systemForm">
<div class="layui-form-item">
<label class="layui-form-label" style="cursor: pointer;" data-tips="maintenance-mode">维护模式</label>
<div class="layui-input-block">
<div style="display: flex; align-items: center; justify-content: flex-start; gap: 10px;">
<input type="checkbox" name="maintenance_mode" lay-skin="switch" lay-text="开启|关闭" title="开启|关闭">
</div>
<button type="button" class="layui-btn layui-btn-sm" lay-submit lay-filter="save_basic">保存基本信息</button>
<button type="button" class="layui-btn layui-btn-primary layui-btn-sm reset-btn" data-type="basic">重置</button>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label" style="cursor: pointer;" data-tips="default-user-role">默认角色</label>
<div class="layui-input-block">
<select name="default_user_role">
<option value="0">管理员</option>
<option value="1">普通用户</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label" style="cursor: pointer;" data-tips="session-timeout">会话超时</label>
<div class="layui-input-block">
<div style="display: flex; align-items: center; gap: 10px;">
<input type="number" name="session_timeout" placeholder="3600" min="300" max="86400" lay-affix="number" class="layui-input"
style="width: 120px;" />
<span class="layui-form-mid">300-86400秒</span>
</div>
</div>
</div>
</form>
</div>
</div>
@@ -107,17 +226,15 @@
class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button type="button" class="layui-btn layui-btn-sm" lay-submit lay-filter="save_footer">保存页脚备案</button>
<button type="button" class="layui-btn layui-btn-primary layui-btn-sm reset-btn" data-type="footer">重置</button>
</div>
</div>
</form>
</div>
</div>
<!-- 操作按钮 -->
<div class="layui-form-item" style="margin-top: 24px;">
<div class="layui-input-block">
<button type="button" class="layui-btn" id="saveAllBtn" lay-submit lay-filter="saveAll">保存所有设置</button>
<button type="button" class="layui-btn layui-btn-primary" id="resetBtn">重置</button>
</div>
</div>
</section>
<script>
@@ -164,29 +281,53 @@
/**
* 将 settings 数据回填到各表单控件
* - 文本/文本域/下拉:直接赋值
* - 开关:根据 "1"/"0" 置为选中/未选中
* - 拆分为独立的填充函数,便于局部重置
*/
const fillForms = (settings = {}) => {
// 基本信息
const fillSystem = (settings) => {
const maintenanceChecked = (settings.maintenance_mode || '0') === '1';
$('[name="maintenance_mode"]').prop('checked', maintenanceChecked);
$('[name="jwt_secret"]').val(settings.jwt_secret || '');
$('[name="encryption_key"]').val(settings.encryption_key || '');
$('[name="jwt_refresh"]').val(settings.jwt_refresh || '6');
$('[name="jwt_expire"]').val(settings.jwt_expire || '24');
};
const fillCookie = (settings) => {
const cookieSecureChecked = (settings.cookie_secure || 'true') === 'true' || settings.cookie_secure === '1';
$('[name="cookie_secure"]').prop('checked', cookieSecureChecked);
$('[name="cookie_same_site"]').val(settings.cookie_same_site || 'Lax');
$('[name="cookie_domain"]').val(settings.cookie_domain || '');
$('[name="cookie_max_age"]').val(settings.cookie_max_age || '86400');
};
const fillCleanup = (settings) => {
$('[name="login_log_cleanup_days"]').val(settings.login_log_cleanup_days || '30');
$('[name="login_log_cleanup_limit"]').val(settings.login_log_cleanup_limit || '10000');
$('[name="operation_log_cleanup_days"]').val(settings.operation_log_cleanup_days || '30');
$('[name="operation_log_cleanup_limit"]').val(settings.operation_log_cleanup_limit || '10000');
};
const fillBasic = (settings) => {
$('[name="site_title"]').val(settings.site_title || '');
$('[name="site_keywords"]').val(settings.site_keywords || '');
$('[name="site_description"]').val(settings.site_description || '');
$('[name="site_logo"]').val(settings.site_logo || '');
};
// 系统配置
const maintenanceChecked = (settings.maintenance_mode || '0') === '1';
$('[name="maintenance_mode"]').prop('checked', maintenanceChecked);
$('[name="default_user_role"]').val(settings.default_user_role || '1');
$('[name="session_timeout"]').val(settings.session_timeout || '3600');
// 页脚与备案
const fillFooter = (settings) => {
$('[name="footer_text"]').val(settings.footer_text || '');
$('[name="icp_record"]').val(settings.icp_record || '');
$('[name="icp_record_link"]').val(settings.icp_record_link || '');
$('[name="psb_record"]').val(settings.psb_record || '');
$('[name="psb_record_link"]').val(settings.psb_record_link || '');
};
const fillForms = (settings = {}) => {
fillBasic(settings);
fillSystem(settings);
fillCookie(settings);
fillCleanup(settings);
fillFooter(settings);
// 渲染 layui 组件
form.render();
};
@@ -222,68 +363,146 @@
return {
...collectForm('#basicForm'),
...collectForm('#systemForm'),
...collectForm('#cookieForm'),
...collectForm('#footerForm'),
...collectForm('#logCleanupForm'),
};
};
/**
* 处理“保存所有设置”点击
* - 二次确认后提交
* - 显示加载中,防重复提交
* - 成功后提示并刷新缓存的 originalSettings
* 提交设置到后端
* @param {Object} payload - 要保存的设置对象
* @param {HTMLElement} btnElem - 触发保存的按钮元素(用于禁用/恢复)
* @param {String} successMsg - 成功提示信息
*/
const handleSaveAll = () => {
const payload = collectAllSettings();
layer.confirm('确认保存所有设置?', { icon: 3, title: '提示' }, (idx) => {
layer.close(idx);
const btn = $('#saveAllBtn');
btn.prop('disabled', true).addClass('layui-btn-disabled');
const loadIdx = layer.load(2, { content: '正在保存...' });
fetch('/admin/api/settings/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(payload)
})
.then(resp => resp.json())
.then(res => {
if (res.code === 0) {
layer.msg(res.msg || '保存成功', { icon: 1, time: 1000 });
originalSettings = { ...payload };
} else {
layer.msg(res.msg || '保存失败', { icon: 2 });
}
})
.catch(err => {
console.error('保存设置失败:', err);
layer.msg('网络错误,保存失败', { icon: 2 });
})
.finally(() => {
layer.close(loadIdx);
btn.prop('disabled', false).removeClass('layui-btn-disabled');
});
const submitSettings = (payload, btnElem, successMsg = '保存成功') => {
const $btn = $(btnElem);
$btn.prop('disabled', true).addClass('layui-btn-disabled');
const loadIdx = layer.msg('正在保存...', {
icon: 16,
time: 0,
shade: 0.1
});
fetch('/admin/api/settings/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(payload)
})
.then(resp => resp.json())
.then(res => {
if (res.code === 0) {
layer.msg(res.msg || successMsg, { icon: 1, time: 1000 });
// 更新本地缓存,合并新保存的设置
originalSettings = { ...originalSettings, ...payload };
} else {
layer.msg(res.msg || '保存失败', { icon: 2 });
}
})
.catch(err => {
console.error('保存设置失败:', err);
var msg = '网络错误,保存失败';
if (err.response && err.response.data && err.response.data.msg) {
msg = err.response.data.msg;
} else if (err.message) {
msg = err.message;
}
layer.msg(msg, { icon: 2 });
})
.finally(() => {
layer.close(loadIdx);
$btn.prop('disabled', false).removeClass('layui-btn-disabled');
});
};
/**
* 处理“重置”点击
* - 恢复为上次加载的 originalSettings
* 绑定各个分块的保存按钮
*/
const handleReset = () => {
fillForms(originalSettings);
layer.msg('已恢复到上次加载的值', { icon: 1, time: 800 });
form.on('submit(save_system)', function(data){
submitSettings(collectForm('#systemForm'), data.elem, '安全配置已保存');
return false;
});
form.on('submit(save_cookie)', function(data){
submitSettings(collectForm('#cookieForm'), data.elem, 'Cookie设置已保存');
return false;
});
form.on('submit(save_cleanup)', function(data){
submitSettings(collectForm('#logCleanupForm'), data.elem, '清理策略已保存');
return false;
});
form.on('submit(save_basic)', function(data){
submitSettings(collectForm('#basicForm'), data.elem, '基本信息已保存');
return false;
});
form.on('submit(save_footer)', function(data){
submitSettings(collectForm('#footerForm'), data.elem, '页脚备案已保存');
return false;
});
/**
* 处理各个分块的重置按钮
*/
$(document).on('click', '.reset-btn', function() {
const type = $(this).data('type');
switch (type) {
case 'system':
fillSystem(originalSettings);
break;
case 'cookie':
fillCookie(originalSettings);
break;
case 'cleanup':
fillCleanup(originalSettings);
break;
case 'basic':
fillBasic(originalSettings);
break;
case 'footer':
fillFooter(originalSettings);
break;
}
form.render();
layer.msg('已恢复该部分默认值', { icon: 1, time: 800 });
});
/**
* 生成安全密钥
*/
const generateKey = async (type) => {
try {
const loadIdx = layer.load(2);
const res = await fetch(`/admin/api/settings/generate_key?type=${type}`, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await res.json();
layer.close(loadIdx);
if (data.code === 0) {
if (type === 'jwt') {
$('[name="jwt_secret"]').val(data.data.key);
} else if (type === 'encryption') {
$('[name="encryption_key"]').val(data.data.key);
}
layer.msg('生成成功', { icon: 1 });
} else {
layer.msg(data.msg || '生成失败', { icon: 2 });
}
} catch (err) {
console.error('生成密钥失败:', err);
layer.closeAll('loading');
layer.msg('网络错误', { icon: 2 });
}
};
// 事件绑定
$('#saveAllBtn').off('click').on('click', handleSaveAll);
$('#resetBtn').off('click').on('click', handleReset);
$('#generateJwtBtn').on('click', () => generateKey('jwt'));
$('#generateEncBtn').on('click', () => generateKey('encryption'));
// 初始化:加载设置
loadSettings();
});
});
</script>
{{ end }}
{{ end }}

View File

@@ -1,319 +0,0 @@
{{ define "user.html" }}
<section>
<h2>账户管理</h2>
<div class="layui-tab layui-tab-brief" lay-filter="userTabs" style="margin-top: 16px;">
<ul class="layui-tab-title">
<li class="layui-this">修改密码</li>
<li>修改用户名</li>
</ul>
<div class="layui-tab-content">
<!-- 修改密码模块 -->
<div class="layui-tab-item layui-show">
<div class="layui-panel" style="margin-top: 16px;">
<h3 style="margin: 0; padding: 15px 20px; border-bottom: 1px solid var(--lay-color-border-2); padding-bottom: 10px; margin-bottom: 15px;">修改密码</h3>
<div style="padding: 20px;">
<form class="layui-form" id="passwordForm" lay-filter="passwordForm" onsubmit="return false">
<div class="layui-form-item">
<label class="layui-form-label" style="cursor: pointer;" data-tips="user-old-password">当前密码</label>
<div class="layui-input-block">
<div class="layui-input-wrap">
<input type="password" name="old_password" placeholder="请输入当前密码" autocomplete="off"
class="layui-input" lay-verify="required" lay-affix="eye" />
</div>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label" style="cursor: pointer;" data-tips="user-new-password">新的密码</label>
<div class="layui-input-block">
<div class="layui-input-wrap">
<input type="password" name="new_password" placeholder="请输入新密码至少6位" autocomplete="off"
class="layui-input" lay-verify="required" lay-affix="eye" />
</div>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">确认密码</label>
<div class="layui-input-block">
<div class="layui-input-wrap">
<input type="password" name="confirm_password" placeholder="请再次输入新密码" autocomplete="off"
class="layui-input" lay-verify="required" lay-affix="eye" />
</div>
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="submitPassword">
<i class="layui-icon layui-icon-ok"></i> 修改密码
</button>
<button type="button" id="resetPasswordBtn" class="layui-btn layui-btn-primary">
<i class="layui-icon layui-icon-refresh"></i> 重置
</button>
</div>
</div>
</form>
</div>
</div>
</div>
<!-- 修改用户名模块 -->
<div class="layui-tab-item">
<div class="layui-panel" style="margin-top: 16px;">
<h3 style="margin: 0; padding: 15px 20px; border-bottom: 1px solid var(--lay-color-border-2); padding-bottom: 10px; margin-bottom: 15px;">修改用户名</h3>
<div style="padding: 20px;">
<form class="layui-form" id="usernameForm" lay-filter="usernameForm" onsubmit="return false">
<div class="layui-form-item">
<label class="layui-form-label">当前用户名</label>
<div class="layui-input-block">
<input type="text" name="current_username" disabled readonly class="layui-input readonly-field" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label" style="cursor: pointer;" data-tips="user-username">新用户名</label>
<div class="layui-input-block">
<input type="text" name="new_username" placeholder="请输入新用户名" autocomplete="off" class="layui-input"
lay-verify="required" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label" style="cursor: pointer;" data-tips="user-old-password">当前密码</label>
<div class="layui-input-block">
<div class="layui-input-wrap">
<input type="password" name="password" placeholder="请输入当前密码以确认身份" autocomplete="off"
class="layui-input" lay-verify="required" lay-affix="eye" />
</div>
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="submitUsername">
<i class="layui-icon layui-icon-ok"></i> 修改用户名
</button>
<button type="button" id="resetUsernameBtn" class="layui-btn layui-btn-primary">
<i class="layui-icon layui-icon-refresh"></i> 重置
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
// 使用自执行函数创建局部作用域,避免与其他页面脚本发生全局命名冲突
(() => {
// 工具方法:将数值角色转为中文标签
const roleToText = (role) => {
const r = typeof role === 'string' ? parseInt(role, 10) : role
return r === 0 ? '管理员' : '普通成员'
}
// 格式化时间
const formatTime = (timeStr) => {
if (!timeStr) return ''
const date = new Date(timeStr)
return date.toLocaleString('zh-CN')
}
// 如果未加载 layui则按需加载
const ensureLayui = () => new Promise((resolve) => {
if (window.layui) return resolve(window.layui)
const css = document.createElement('link')
css.rel = 'stylesheet'
css.href = 'https://unpkg.com/layui@2.10.1/dist/css/layui.css'
document.head.appendChild(css)
const script = document.createElement('script')
script.src = 'https://unpkg.com/layui@2.10.1/dist/layui.js'
script.onload = () => resolve(window.layui)
document.head.appendChild(script)
})
// 在确保 Layui 可用后再执行页面逻辑
ensureLayui().then(() => {
layui.use(['form', 'layer', 'element'], () => {
const form = layui.form
const layer = layui.layer
const element = layui.element
// 全局变量
let currentUsername = null
// 获取当前用户名
const getCurrentUsername = async () => {
try {
const res = await fetch('/admin/api/user/profile')
const data = await res.json()
const ok = (data.success === true) || (data.code === 0)
if (!ok) throw new Error(data.message || data.msg || '获取用户信息失败')
currentUsername = data.data.username
// 填充用户名修改表单的当前用户名
form.val('usernameForm', { current_username: currentUsername })
} catch (e) {
layer.msg(e.message || '获取用户信息失败', { icon: 2 })
}
}
// 修改密码模块
const PasswordModule = {
validate: (fields) => {
const { old_password, new_password, confirm_password } = fields
if (!old_password || !new_password || !confirm_password) {
return { ok: false, msg: '请填写完整的密码信息' }
}
if (new_password.length < 6) {
return { ok: false, msg: '新密码长度不能少于6位' }
}
if (new_password !== confirm_password) {
return { ok: false, msg: '两次输入的新密码不一致' }
}
if (old_password === new_password) {
return { ok: false, msg: '新密码不能与当前密码相同' }
}
return { ok: true }
},
submit: async (fields) => {
const validation = PasswordModule.validate(fields)
if (!validation.ok) {
layer.msg(validation.msg, { icon: 2 })
return false
}
try {
const res = await fetch('/admin/api/user/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
old_password: fields.old_password,
new_password: fields.new_password,
confirm_password: fields.confirm_password
})
})
const data = await res.json()
const ok = (data.success === true) || (data.code === 0)
if (!ok) throw new Error(data.message || data.msg || '修改密码失败')
// 检查是否需要跳转
if (data.data?.redirect) {
layer.msg('密码修改成功,即将跳转到登录页', { icon: 1, time: 1500 }, () => {
window.location.href = data.data.redirect
})
} else {
// 密码修改成功,不跳转,重置表单
layer.msg('密码修改成功', { icon: 1 })
document.getElementById('passwordForm').reset()
}
} catch (e) {
layer.msg(e.message || '修改密码失败', { icon: 2 })
}
return false
},
reset: () => {
document.getElementById('passwordForm').reset()
layer.msg('表单已重置', { icon: 1 })
}
}
// 修改用户名模块
const UsernameModule = {
validate: (fields) => {
const { new_username, password } = fields
if (!new_username || !password) {
return { ok: false, msg: '请填写新用户名和当前密码' }
}
if (new_username === currentUsername) {
return { ok: false, msg: '新用户名不能与当前用户名相同' }
}
if (new_username.length < 3) {
return { ok: false, msg: '用户名长度不能少于3位' }
}
return { ok: true }
},
submit: async (fields) => {
const validation = UsernameModule.validate(fields)
if (!validation.ok) {
layer.msg(validation.msg, { icon: 2 })
return false
}
try {
const res = await fetch('/admin/api/user/profile/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: fields.new_username,
old_password: fields.password
})
})
const data = await res.json()
const ok = (data.success === true) || (data.code === 0)
if (!ok) throw new Error(data.message || data.msg || '修改用户名失败')
layer.msg('用户名修改成功', { icon: 1 })
// 重新获取当前用户名
await getCurrentUsername()
// 清空表单(不显示重置提示)
form.val('usernameForm', {
new_username: '',
password: '',
current_username: currentUsername || ''
})
} catch (e) {
layer.msg(e.message || '修改用户名失败', { icon: 2 })
}
return false
},
reset: () => {
form.val('usernameForm', {
new_username: '',
password: '',
current_username: currentUsername || ''
})
layer.msg('表单已重置', { icon: 1 })
}
}
// 绑定表单提交事件
form.on('submit(submitPassword)', (obj) => {
return PasswordModule.submit(obj.field)
})
form.on('submit(submitUsername)', (obj) => {
return UsernameModule.submit(obj.field)
})
// 绑定重置按钮
document.getElementById('resetPasswordBtn')?.addEventListener('click', PasswordModule.reset)
document.getElementById('resetUsernameBtn')?.addEventListener('click', UsernameModule.reset)
// 初始化加载
getCurrentUsername()
})
})
})()
</script>
</section>
{{ end }}

View File

@@ -0,0 +1,374 @@
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<title>{{ .Title }}</title>
<!-- 站 点 协 议 -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<meta http-equiv="content-language" content="zh-cn">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="mobile-web-app-capable" content="yes">
<meta name="format-detection" content="telephone=no">
{{ if .Description }}<meta name="description" content="{{ .Description }}">{{ end }}
{{ if .Keywords }}<meta name="keywords" content="{{ .Keywords }}">{{ end }}
<!-- 站 点 图 标 -->
<link href='/favicon.ico' rel='icon' type='image/x-icon'>
<link href="/favicon.ico" rel="shortcut icon">
<link href="/favicon.ico" rel="bookmark">
<!-- 样 式 文 件 -->
<link rel="stylesheet" href="/static/lib/layui/css/layui.css"/>
<style>
html, body {
width: 100%;
height: 100%;
overflow: hidden;
margin: 0;
padding: 0;
font-family: 'Microsoft YaHei', Arial, sans-serif;
}
body {
background-color: #000000 !important;
}
.layui-container {
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
}
.body-background {
width: 420px;
min-height: 350px;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 10;
}
.logo-title {
text-align: center;
letter-spacing: 3px;
padding: 0 0 0 0;
margin-bottom: 5px;
}
.logo-title h1 {
color: #2550dd;
font-size: 28px;
font-weight: 600;
margin: 0;
text-shadow: 0 0 20px rgba(0, 212, 255, 0.5);
animation: glow 2s ease-in-out infinite alternate;
}
@keyframes glow {
from {
text-shadow: 0 0 20px rgba(0, 212, 255, 0.5);
}
to {
text-shadow: 0 0 30px rgba(0, 212, 255, 0.8), 0 0 40px rgba(0, 212, 255, 0.6);
}
}
.box-form {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(240, 248, 255, 0.9));
border: 2px solid rgba(0, 212, 255, 0.3);
border-radius: 15px;
padding: 30px 25px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(255, 255, 255, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
position: relative;
overflow: hidden;
}
.box-form::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.1), transparent);
animation: shimmer 3s infinite;
}
@keyframes shimmer {
0% { left: -100%; }
100% { left: 100%; }
}
.box-form .layui-form-item {
margin-bottom: 20px;
position: relative;
}
.warning-text {
font-size: 24px;
color: #ff4757;
font-weight: 600;
text-shadow: 0 2px 4px rgba(255, 71, 87, 0.3);
margin: 15px 0;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.info-text {
color: #3742fa;
font-size: 16px;
font-weight: 500;
margin: 15px 0;
text-shadow: 0 1px 2px rgba(55, 66, 250, 0.2);
}
.body_box {
text-align: center;
}
.body_footer {
padding-top: 15px;
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
.body_beian {
padding-top: 8px;
}
.body_beian a {
color: rgba(0, 212, 255, 0.8);
text-decoration: none;
font-size: 13px;
transition: all 0.3s ease;
}
.body_beian a:hover {
color: #00d4ff;
text-shadow: 0 0 10px rgba(0, 212, 255, 0.5);
}
#canvas {
position: absolute;
top: 0;
left: 0;
z-index: 1;
}
hr {
border: none;
height: 2px;
background: linear-gradient(90deg, transparent, #00d4ff, transparent);
margin: 20px 0;
border-radius: 1px;
}
</style>
</head>
<body>
<!-- 代 码 结 构 -->
<div class="layui-container">
<canvas id="canvas"></canvas>
<div class="body-background body_box">
<div class="layui-form box-form body_box">
<div class="layui-form-item logo-title">
<h1><strong>{{ .SystemName }}</strong></h1>
</div>
<hr>
<div class="layui-form-item">
<div class="warning-text">{{ .WarningText }}</div>
</div>
<div class="layui-form-item">
<div class="info-text">{{ .InfoText }}</div>
</div>
</div>
<div class="body_footer">{{ .FooterText }}</div>
{{ if .ICPRecord }}
<div class="body_beian"><a href="{{ .ICPRecordLink }}" target="_blank">{{ .ICPRecord }}</a></div>
{{ end }}
</div>
</div>
<!-- 资 源 引 入 -->
<script src="/static/lib/jquery/jquery.min.js" type="text/javascript"></script>
<script>
// 设置版权年份 (保留此逻辑以防 FooterText 中使用了 id="currentYear")
if(document.getElementById('currentYear')) {
document.getElementById('currentYear').textContent = new Date().getFullYear();
}
// 获取canvas元素和绘图上下文
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// 设置canvas尺寸为全屏
const resizeCanvas = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// 粒子类
class Particle {
constructor() {
this.reset();
}
// 重置粒子位置和属性
reset() {
this.x = Math.random() * canvas.width;
this.y = Math.random() * canvas.height;
this.vx = (Math.random() - 0.5) * 2;
this.vy = (Math.random() - 0.5) * 2;
this.size = Math.random() * 3 + 1;
this.opacity = Math.random() * 0.8 + 0.2;
this.color = this.getRandomColor();
}
// 获取随机颜色
getRandomColor() {
const colors = [
'#00FF00', '#0080FF', '#FF0080', '#FFFF00',
'#FF8000', '#8000FF', '#00FFFF', '#FF4000'
];
return colors[Math.floor(Math.random() * colors.length)];
}
// 更新粒子位置
update() {
this.x += this.vx;
this.y += this.vy;
// 边界检测,粒子超出边界时重置
if (this.x < 0 || this.x > canvas.width ||
this.y < 0 || this.y > canvas.height) {
this.reset();
}
// 随机改变透明度
this.opacity += (Math.random() - 0.5) * 0.02;
this.opacity = Math.max(0.1, Math.min(1, this.opacity));
}
// 绘制粒子
draw() {
ctx.save();
ctx.globalAlpha = this.opacity;
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
// 创建粒子数组
const particles = [];
const particleCount = 150;
// 初始化粒子
const initParticles = () => {
for (let i = 0; i < particleCount; i++) {
particles.push(new Particle());
}
};
// 绘制连线
const drawConnections = () => {
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const dx = particles[i].x - particles[j].x;
const dy = particles[i].y - particles[j].y;
const distance = Math.sqrt(dx * dx + dy * dy);
// 如果距离小于100像素绘制连线
if (distance < 100) {
ctx.save();
ctx.globalAlpha = (100 - distance) / 100 * 0.3;
ctx.strokeStyle = '#00FF00';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(particles[i].x, particles[i].y);
ctx.lineTo(particles[j].x, particles[j].y);
ctx.stroke();
ctx.restore();
}
}
}
};
// 动画循环
const animate = () => {
// 清除画布,使用半透明黑色创建拖尾效果
ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 更新和绘制所有粒子
particles.forEach(particle => {
particle.update();
particle.draw();
});
// 绘制粒子间的连线
drawConnections();
requestAnimationFrame(animate);
};
// 鼠标交互效果
const addMouseInteraction = () => {
let mouseX = 0;
let mouseY = 0;
canvas.addEventListener('mousemove', (e) => {
mouseX = e.clientX;
mouseY = e.clientY;
// 鼠标附近的粒子会被吸引
particles.forEach(particle => {
const dx = mouseX - particle.x;
const dy = mouseY - particle.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 150) {
particle.vx += dx * 0.0001;
particle.vy += dy * 0.0001;
}
});
});
// 点击时添加新粒子
canvas.addEventListener('click', (e) => {
for (let i = 0; i < 5; i++) {
const newParticle = new Particle();
newParticle.x = e.clientX + (Math.random() - 0.5) * 20;
newParticle.y = e.clientY + (Math.random() - 0.5) * 20;
particles.push(newParticle);
}
// 限制粒子数量
if (particles.length > particleCount + 50) {
particles.splice(0, 5);
}
});
};
// 启动粒子系统
initParticles();
addMouseInteraction();
animate();
</script>
</body>
</html>

View File

@@ -1,385 +0,0 @@
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<title>{{.SystemName}} - 生活就像愤怒的小鸟,失败后总有几只猪在笑。</title>
<!-- 站 点 协 议 -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<meta http-equiv="content-language" content="zh-cn">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="mobile-web-app-capable" content="yes">
<meta name="format-detection" content="telephone=no">
<!-- 站 点 图 标 -->
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="bookmark" href="/favicon.ico" />
<!-- 样 式 文 件 -->
<link rel="stylesheet" href="//lib.baomitu.com/layui/2.8.17/css/layui.css" />
<style>
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
margin: 0;
padding: 0;
font-family: 'Microsoft YaHei', Arial, sans-serif;
}
body {
background-color: #000000 !important;
}
.layui-container {
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
}
.body-background {
width: 420px;
min-height: 350px;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 10;
}
.logo-title {
text-align: center;
letter-spacing: 3px;
padding: 0 0 0 0;
margin-bottom: 5px;
}
.logo-title h1 {
color: #2550dd;
font-size: 28px;
font-weight: 600;
margin: 0;
text-shadow: 0 0 20px rgba(0, 212, 255, 0.5);
animation: glow 2s ease-in-out infinite alternate;
}
@keyframes glow {
from {
text-shadow: 0 0 20px rgba(0, 212, 255, 0.5);
}
to {
text-shadow: 0 0 30px rgba(0, 212, 255, 0.8), 0 0 40px rgba(0, 212, 255, 0.6);
}
}
.box-form {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(240, 248, 255, 0.9));
border: 2px solid rgba(0, 212, 255, 0.3);
border-radius: 15px;
padding: 30px 25px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(255, 255, 255, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
position: relative;
overflow: hidden;
}
.box-form::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.1), transparent);
animation: shimmer 3s infinite;
}
@keyframes shimmer {
0% {
left: -100%;
}
100% {
left: 100%;
}
}
.box-form .layui-form-item {
margin-bottom: 20px;
position: relative;
}
.warning-text {
font-size: 24px;
color: #ff4757;
font-weight: 600;
text-shadow: 0 2px 4px rgba(255, 71, 87, 0.3);
margin: 15px 0;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.info-text {
color: #3742fa;
font-size: 16px;
font-weight: 500;
margin: 15px 0;
text-shadow: 0 1px 2px rgba(55, 66, 250, 0.2);
}
.body_box {
text-align: center;
}
.body_footer {
padding-top: 15px;
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
.body_beian {
padding-top: 8px;
}
.body_beian a {
color: rgba(0, 212, 255, 0.8);
text-decoration: none;
font-size: 13px;
transition: all 0.3s ease;
}
.body_beian a:hover {
color: #00d4ff;
text-shadow: 0 0 10px rgba(0, 212, 255, 0.5);
}
#canvas {
position: absolute;
top: 0;
left: 0;
z-index: 1;
}
hr {
border: none;
height: 2px;
background: linear-gradient(90deg, transparent, #00d4ff, transparent);
margin: 20px 0;
border-radius: 1px;
}
</style>
</head>
<body>
<!-- 代 码 结 构 -->
<div class="layui-container">
<canvas id="canvas"></canvas>
<div class="body-background body_box">
<div class="layui-form box-form body_box">
<div class="layui-form-item logo-title">
<h1><strong>系统提醒</strong></h1>
</div>
<hr>
<div class="layui-form-item">
<div class="warning-text">🚫 未授权,拒绝访问</div>
</div>
<div class="layui-form-item">
<div class="info-text">💬 如有问题,请联系网站管理员</div>
</div>
</div>
<div class="body_footer">{{.FooterText}}</div>
{{if or .ICPRecord .PSBRecord}}<div class="body_beian">{{if .ICPRecord}}<a href="{{.ICPRecordLink}}"
target="_blank">{{.ICPRecord}}</a>{{end}}{{if and .ICPRecord .PSBRecord}} {{end}}{{if .PSBRecord}}<a
href="{{.PSBRecordLink}}" target="_blank">{{.PSBRecord}}</a>{{end}}</div>{{end}}
</div>
</div>
<!-- 资 源 引 入 -->
<script src="//lib.baomitu.com/jquery/3.6.4/jquery.min.js" type="text/javascript"></script>
<script>
// 获取canvas元素和绘图上下文
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// 设置canvas尺寸为全屏
const resizeCanvas = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// 粒子类
class Particle {
constructor() {
this.reset();
}
// 重置粒子位置和属性
reset() {
this.x = Math.random() * canvas.width;
this.y = Math.random() * canvas.height;
this.vx = (Math.random() - 0.5) * 2;
this.vy = (Math.random() - 0.5) * 2;
this.size = Math.random() * 3 + 1;
this.opacity = Math.random() * 0.8 + 0.2;
this.color = this.getRandomColor();
}
// 获取随机颜色
getRandomColor() {
const colors = [
'#00FF00', '#0080FF', '#FF0080', '#FFFF00',
'#FF8000', '#8000FF', '#00FFFF', '#FF4000'
];
return colors[Math.floor(Math.random() * colors.length)];
}
// 更新粒子位置
update() {
this.x += this.vx;
this.y += this.vy;
// 边界检测,粒子超出边界时重置
if (this.x < 0 || this.x > canvas.width ||
this.y < 0 || this.y > canvas.height) {
this.reset();
}
// 随机改变透明度
this.opacity += (Math.random() - 0.5) * 0.02;
this.opacity = Math.max(0.1, Math.min(1, this.opacity));
}
// 绘制粒子
draw() {
ctx.save();
ctx.globalAlpha = this.opacity;
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
// 创建粒子数组
const particles = [];
const particleCount = 150;
// 初始化粒子
const initParticles = () => {
for (let i = 0; i < particleCount; i++) {
particles.push(new Particle());
}
};
// 绘制连线
const drawConnections = () => {
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const dx = particles[i].x - particles[j].x;
const dy = particles[i].y - particles[j].y;
const distance = Math.sqrt(dx * dx + dy * dy);
// 如果距离小于100像素绘制连线
if (distance < 100) {
ctx.save();
ctx.globalAlpha = (100 - distance) / 100 * 0.3;
ctx.strokeStyle = '#00FF00';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(particles[i].x, particles[i].y);
ctx.lineTo(particles[j].x, particles[j].y);
ctx.stroke();
ctx.restore();
}
}
}
};
// 动画循环
const animate = () => {
// 清除画布,使用半透明黑色创建拖尾效果
ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 更新和绘制所有粒子
particles.forEach(particle => {
particle.update();
particle.draw();
});
// 绘制粒子间的连线
drawConnections();
requestAnimationFrame(animate);
};
// 鼠标交互效果
const addMouseInteraction = () => {
let mouseX = 0;
let mouseY = 0;
canvas.addEventListener('mousemove', (e) => {
mouseX = e.clientX;
mouseY = e.clientY;
// 鼠标附近的粒子会被吸引
particles.forEach(particle => {
const dx = mouseX - particle.x;
const dy = mouseY - particle.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 150) {
particle.vx += dx * 0.0001;
particle.vy += dy * 0.0001;
}
});
});
// 点击时添加新粒子
canvas.addEventListener('click', (e) => {
for (let i = 0; i < 5; i++) {
const newParticle = new Particle();
newParticle.x = e.clientX + (Math.random() - 0.5) * 20;
newParticle.y = e.clientY + (Math.random() - 0.5) * 20;
particles.push(newParticle);
}
// 限制粒子数量
if (particles.length > particleCount + 50) {
particles.splice(0, 5);
}
});
};
// 启动粒子系统
initParticles();
addMouseInteraction();
animate();
</script>
</body>
</html>

View File

@@ -0,0 +1,209 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>{{ .title }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link rel="stylesheet" href="/static/lib/layui/css/layui.css">
<style>
body {
background-color: #f2f2f2;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
padding: 40px 0;
}
.install-box {
width: 600px;
background: #fff;
padding: 30px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.install-header {
text-align: center;
margin-bottom: 30px;
}
.install-header h2 {
color: #333;
font-weight: 500;
}
.install-header p {
color: #999;
margin-top: 10px;
}
</style>
</head>
<body>
<div class="install-box">
<div class="install-header">
<h2>系统初始化</h2>
<p>欢迎使用,请完成以下初始化设置</p>
</div>
<form class="layui-form" lay-filter="install-form">
<fieldset class="layui-elem-field layui-field-title" style="margin-top: 20px;">
<legend style="font-size: 14px;">1. 数据库配置</legend>
</fieldset>
<div class="layui-form-item">
<label class="layui-form-label">数据库类型</label>
<div class="layui-input-block">
<input type="radio" name="db_type" value="sqlite" title="SQLite (默认)" lay-filter="db_type" checked>
<input type="radio" name="db_type" value="mysql" title="MySQL" lay-filter="db_type">
</div>
</div>
<div id="mysql-config" style="display: none;">
<div class="layui-form-item">
<label class="layui-form-label">主机地址</label>
<div class="layui-input-block">
<input type="text" name="db_host" value="127.0.0.1" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">端口号</label>
<div class="layui-input-block">
<input type="number" name="db_port" value="3306" lay-affix="number" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">数据库名</label>
<div class="layui-input-block">
<input type="text" name="db_name" value="networkauth" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">用户名</label>
<div class="layui-input-block">
<input type="text" name="db_user" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">密码</label>
<div class="layui-input-block">
<input type="password" name="db_pass" autocomplete="off" class="layui-input">
</div>
</div>
</div>
<fieldset class="layui-elem-field layui-field-title" style="margin-top: 30px;">
<legend style="font-size: 14px;">2. 站点信息</legend>
</fieldset>
<div class="layui-form-item">
<label class="layui-form-label">站点标题</label>
<div class="layui-input-block">
<input type="text" name="site_title" lay-verify="required" value="NetworkAuth" autocomplete="off" class="layui-input">
</div>
</div>
<fieldset class="layui-elem-field layui-field-title" style="margin-top: 30px;">
<legend style="font-size: 14px;">3. 管理员设置</legend>
</fieldset>
<div class="layui-form-item">
<label class="layui-form-label">管理员账号</label>
<div class="layui-input-block">
<input type="text" name="admin_username" lay-verify="required" placeholder="设置管理员账号" value="admin" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">管理员密码</label>
<div class="layui-input-block">
<input type="password" name="admin_password" lay-verify="required|pass" placeholder="设置管理员密码至少6位" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">确认密码</label>
<div class="layui-input-block">
<input type="password" name="confirm_password" lay-verify="required|confirmPass" placeholder="请再次输入管理员密码" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item" style="margin-top: 40px; text-align: center;">
<button class="layui-btn layui-btn-normal layui-btn-lg" style="width: 200px;" lay-submit lay-filter="install-submit">立即初始化</button>
</div>
</form>
</div>
<script src="/static/lib/layui/layui.js"></script>
<script>
layui.use(['form', 'layer', 'jquery'], () => {
const form = layui.form;
const layer = layui.layer;
const $ = layui.jquery;
// 监听数据库类型切换
form.on('radio(db_type)', (data) => {
if (data.value === 'mysql') {
$('#mysql-config').slideDown();
$('#mysql-config input').attr('lay-verify', 'required');
} else {
$('#mysql-config').slideUp();
$('#mysql-config input').removeAttr('lay-verify');
}
});
// 自定义验证规则
form.verify({
pass: [
/^[\S]{6,20}$/,
'密码必须6到20位且不能出现空格'
],
confirmPass: (value) => {
const pass = $('input[name="admin_password"]').val();
if(value !== pass){
return '两次输入的密码不一致';
}
}
});
// 监听提交
form.on('submit(install-submit)', (data) => {
const loading = layer.load(2, {shade: [0.1, '#fff']});
// 处理 db_port 转换为整数
const payload = { ...data.field };
if (payload.db_port) {
payload.db_port = parseInt(payload.db_port, 10) || 3306;
}
$.ajax({
url: '/api/install',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(payload),
success: (res) => {
layer.close(loading);
if (res.code === 0) {
layer.msg('系统初始化成功!正在跳转登录...', {
icon: 1,
time: 2000
}, () => {
window.location.href = '/admin/login';
});
} else {
layer.msg(res.msg || '初始化失败', {icon: 2});
}
},
error: (xhr) => {
layer.close(loading);
const res = xhr.responseJSON;
layer.msg(res ? res.msg : '请求失败,请重试', {icon: 2});
}
});
return false; // 阻止表单跳转
});
});
</script>
</body>
</html>