mirror of
https://github.com/skyle1995/NetworkAuth.git
synced 2026-05-25 02:24:05 +08:00
更新底层架构
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,6 +1,6 @@
|
||||
# 编译后的二进制文件
|
||||
networkDev
|
||||
networkDev.exe
|
||||
NetworkAuth
|
||||
NetworkAuth.exe
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
|
||||
270
cmd/root.go
270
cmd/root.go
@@ -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("日志切割功能已启用")
|
||||
}
|
||||
// 当日志文件路径为空时,保持默认输出到控制台,不创建任何目录
|
||||
}
|
||||
|
||||
151
cmd/server.go
151
cmd/server.go
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
178
config/config.go
178
config/config.go
@@ -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(¤tConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(filePath, configBytes, 0o644)
|
||||
// 2. 执行更新回调
|
||||
updateFn(¤tConfig)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,5 +7,5 @@ package constants
|
||||
// 应用程序版本信息
|
||||
const (
|
||||
// AppVersion 应用程序版本号
|
||||
AppVersion = "0.3.0"
|
||||
AppVersion = "1.0.3"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 Cookie(HttpOnly,安全)
|
||||
// 使用系统配置的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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "验证码错误")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
174
controllers/admin/login_log.go
Normal file
174
controllers/admin/login_log.go
Normal 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)
|
||||
}
|
||||
152
controllers/admin/operation_log.go
Normal file
152
controllers/admin/operation_log.go
Normal 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)
|
||||
}
|
||||
262
controllers/admin/profile.go
Normal file
262
controllers/admin/profile.go
Normal 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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
7
controllers/admin/utils.go
Normal file
7
controllers/admin/utils.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"NetworkAuth/controllers"
|
||||
)
|
||||
|
||||
var base = controllers.NewBaseController()
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
32
controllers/default/handlers.go
Normal file
32
controllers/default/handlers.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
122
controllers/install/install.go
Normal file
122
controllers/install/install.go
Normal 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": "安装成功"})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
56
go.mod
@@ -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
118
go.sum
@@ -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
18
main.go
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
54
middleware/install.go
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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
99
middleware/maintenance.go
Normal 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
17
models/login_log.go
Normal 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
24
models/operation_log.go
Normal 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"`
|
||||
}
|
||||
225
server/admin.go
225
server/admin.go
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
16
server/install.go
Normal 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)
|
||||
}
|
||||
@@ -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
117
services/log_cleanup.go
Normal 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
33
services/operation_log.go
Normal 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("创建操作日志失败")
|
||||
}
|
||||
}
|
||||
@@ -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 检查实体是否存在
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
121
utils/excel/excel.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
140
web/public.go
140
web/public.go
@@ -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 根为起点的 FS,routes 中会再基于此 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 根为起点的 FS,routes 中会再基于此 FS Sub 出 static 与 assets
|
||||
return distFS, nil
|
||||
}
|
||||
return staticFS, nil
|
||||
}
|
||||
|
||||
@@ -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 滚动条美化与布局约束 ===================== */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/>• RC4:16位十六进制密钥,用于对称加密<br/>• RSA:公钥用于客户端加密,私钥用于服务器解密<br/>• 易加密:15-30位整数数组,逗号分隔<br/>• 密钥由系统自动生成,确保安全性',
|
||||
'return-algorithm': '返回算法:服务器向客户端返回数据时使用的加密算法<br/>• 不加密:数据明文传输,适用于内网环境<br/>• RC4:对称加密,速度快,适用于一般场景<br/>• RSA:非对称加密,安全性高,适用于敏感数据<br/>• RSA(动态):动态生成密钥的RSA加密,安全性最高<br/>• 易加密:自定义对称加密算法,使用15-30位整数密钥数组',
|
||||
'return-keys': '返回密钥:用于加密服务器返回数据的密钥<br/>• RC4:16位十六进制密钥,用于对称加密<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/>• RC4:16位十六进制密钥,用于对称加密<br/>• RSA:公钥用于客户端加密,私钥用于服务器解密<br/>• 易加密:15-30位整数数组,逗号分隔<br/>• 密钥由系统自动生成,确保安全性',
|
||||
'return-algorithm': '返回算法:服务器向客户端返回数据时使用的加密算法<br/>• 不加密:数据明文传输,适用于内网环境<br/>• RC4:对称加密,速度快,适用于一般场景<br/>• RSA:非对称加密,安全性高,适用于敏感数据<br/>• RSA(动态):动态生成密钥的RSA加密,安全性最高<br/>• 易加密:自定义对称加密算法,使用15-30位整数密钥数组',
|
||||
'return-keys': '返回密钥:用于加密服务器返回数据的密钥<br/>• RC4:16位十六进制密钥,用于对称加密<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
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
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
2
web/static/lib/layui/css/layui.css
Normal file
2
web/static/lib/layui/css/layui.css
Normal file
File diff suppressed because one or more lines are too long
BIN
web/static/lib/layui/font/iconfont.ttf
Normal file
BIN
web/static/lib/layui/font/iconfont.ttf
Normal file
Binary file not shown.
BIN
web/static/lib/layui/font/iconfont.woff2
Normal file
BIN
web/static/lib/layui/font/iconfont.woff2
Normal file
Binary file not shown.
2
web/static/lib/layui/layui.js
Normal file
2
web/static/lib/layui/layui.js
Normal file
File diff suppressed because one or more lines are too long
@@ -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 }}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
|
||||
171
web/template/admin/login_logs.html
Normal file
171
web/template/admin/login_logs.html
Normal 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 }}
|
||||
165
web/template/admin/operation_logs.html
Normal file
165
web/template/admin/operation_logs.html
Normal 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 }}
|
||||
237
web/template/admin/profile.html
Normal file
237
web/template/admin/profile.html
Normal 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 }}
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
374
web/template/default/index.html
Normal file
374
web/template/default/index.html
Normal 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>
|
||||
@@ -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>
|
||||
209
web/template/install/install.html
Normal file
209
web/template/install/install.html
Normal 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>
|
||||
Reference in New Issue
Block a user