diff --git a/build.sh b/build.sh index 73962c8..4601a9b 100755 --- a/build.sh +++ b/build.sh @@ -54,9 +54,9 @@ build_backend() { local desc=$4 # 确定可执行文件名称 - local exe_name="ApiServe" + local exe_name="NetworkAuth" if [ "$os" = "windows" ]; then - exe_name="ApiServe.exe" + exe_name="NetworkAuth.exe" fi # 创建对应架构的输出目录 @@ -98,9 +98,9 @@ show_menu() { echo -e "${BLUE}=====================================${NC}" echo -e "${GREEN} ApiServe 项目构建脚本菜单 ${NC}" echo -e "${BLUE}=====================================${NC}" - echo -e "1. 🌐 仅编译前端并拷贝" + echo -e "1. 🚀 一键全部构建 (前端 + 所有架构后端)" echo -e "2. 📦 仅编译所有后端架构" - echo -e "3. 🚀 一键全部构建 (前端 + 所有架构后端)" + echo -e "3. 🌐 仅编译前端并拷贝" echo -e "-------------------------------------" echo -e "4. 🪟 编译后端: Windows 64位" echo -e "5. 🐧 编译后端: Linux ARM64" @@ -123,6 +123,7 @@ while true; do case $choice in 1) build_frontend + build_all_backend pause_and_return ;; 2) @@ -131,7 +132,6 @@ while true; do ;; 3) build_frontend - build_all_backend pause_and_return ;; 4) diff --git a/cmd/root.go b/cmd/root.go index 6ed647c..8e1d4ab 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -80,7 +80,7 @@ func setupLogrusForNonHTTP() { // 记录配置加载完成,使用相对路径或文件名保持一致性 configFile := viper.ConfigFileUsed() if configFile != "" { - fileName := filepath.Base(configFile) + fileName := utils.DisplayPath(configFile) logrus.WithField("file", fileName).Info("配置文件加载完成") } else { logrus.Info("配置加载完成(内存默认配置)") @@ -105,12 +105,17 @@ func setupLogrusFromConfig() { // 设置日志输出目标 logFile := viper.GetString("log.file") if logFile != "" { - // 确保日志目录存在 - path := logFile - if !filepath.IsAbs(path) { - path = filepath.Join(utils.GetRootDir(), path) + // 统一转换为绝对路径,避免不同系统或启动目录下出现日志落点不一致。 + absPath := filepath.Clean(logFile) + if !filepath.IsAbs(absPath) { + absPath = filepath.Join(utils.GetRootDir(), absPath) + } + if normalizedPath, err := filepath.Abs(absPath); err == nil { + absPath = normalizedPath } - logDir := filepath.Dir(path) + + // 确保日志目录存在 + logDir := filepath.Dir(absPath) if err := os.MkdirAll(logDir, 0755); err != nil { logrus.WithError(err).Error("创建日志目录失败") return @@ -118,7 +123,7 @@ func setupLogrusFromConfig() { // 配置lumberjack日志轮转 lumberjackLogger := &lumberjack.Logger{ - Filename: path, + Filename: absPath, MaxSize: viper.GetInt("log.max_size"), // MB MaxBackups: viper.GetInt("log.max_backups"), // 保留的旧日志文件数量 MaxAge: viper.GetInt("log.max_age"), // 天数 @@ -130,7 +135,7 @@ func setupLogrusFromConfig() { logrus.SetOutput(multiWriter) logrus.WithFields(logrus.Fields{ - "file": logFile, + "file": utils.DisplayPath(absPath), "max_size": viper.GetInt("log.max_size"), "max_backups": viper.GetInt("log.max_backups"), "max_age": viper.GetInt("log.max_age"), diff --git a/cmd/server.go b/cmd/server.go index 8b16c8d..cf38953 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -1,202 +1,205 @@ -package cmd - -import ( - "context" - "fmt" - "net/http" - "os" - "os/signal" - "syscall" - "time" - - "NetworkAuth/database" - "NetworkAuth/middleware" - "NetworkAuth/server" - "NetworkAuth/services" - "NetworkAuth/utils" - "NetworkAuth/utils/logger" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -// serverCmd 代表服务器命令 -var serverCmd = &cobra.Command{ - Use: "server", - Short: "启动 NetworkAuth 系统服务器", - Long: `启动 NetworkAuth 系统 HTTP 服务器,监听配置文件中指定的端口,提供 Web 管理界面和 API 服务。`, - Run: runServer, -} - -func init() { - // 将服务器命令添加到根命令 - rootCmd.AddCommand(serverCmd) - - // 添加服务器特定的标志 - serverCmd.Flags().StringP("host", "H", "", "服务器监听地址 (覆盖配置文件)") - serverCmd.Flags().IntP("port", "p", 0, "服务器监听端口 (覆盖配置文件)") -} - -// runServer 运行HTTP服务器 -func runServer(cmd *cobra.Command, args []string) { - // 获取配置 - host := getServerHost(cmd) - port := getServerPort(cmd) - addr := fmt.Sprintf("%s:%d", host, port) - - // 获取全局日志实例 - 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) - // 如果初始化失败(例如 MySQL 连不上),则打印错误并退出 - db, err := database.Init() - if err != nil { - logrus.WithError(err).Fatal("数据库初始化失败,请检查配置或确认是否已安装") - } - - if db != nil { - // 检查系统是否已安装 - isInstalled := services.GetSettingsService().GetString("is_installed", "0") - if isInstalled == "1" { - // 执行自动迁移(确保表结构存在) - 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("系统尚未安装 (is_installed=0),跳过核心组件初始化") - } - } else { - logrus.Info("系统处于未初始化状态,跳过数据库自动迁移和设置加载") - } - - // 创建HTTP服务器 - server := createHTTPServer(addr) - - // 启动服务器 - startServer(server) -} - -// getServerHost 获取服务器监听地址 -func getServerHost(cmd *cobra.Command) string { - if host, _ := cmd.Flags().GetString("host"); host != "" { - return host - } - return viper.GetString("server.host") -} - -// getServerPort 获取服务器监听端口 -func getServerPort(cmd *cobra.Command) int { - if port, _ := cmd.Flags().GetInt("port"); port != 0 { - return port - } - return viper.GetInt("server.port") -} - -// createHTTPServer 创建HTTP服务器 -func createHTTPServer(addr string) *http.Server { - // 创建 Gin 引擎 - r := gin.New() - - // 使用默认的 Recovery 中间件 - r.Use(gin.Recovery()) - - // 启用 CORS 中间件,支持前后端分离 - r.Use(middleware.CorsMiddleware()) - - // 添加日志中间件 - // 默认为 true,只有显式设置为 false 才关闭 - enableAccessLog := true - if viper.IsSet("server.access_log") { - enableAccessLog = viper.GetBool("server.access_log") - } - if enableAccessLog { - r.Use(middleware.WrapHandler()) - } - - // 添加开发模式中间件(统一管理开发模式功能) - r.Use(middleware.DevModeMiddleware()) - - // 添加安装检查中间件 - r.Use(middleware.InstallCheckMiddleware()) - - // 添加维护模式中间件 - r.Use(middleware.MaintenanceMiddleware()) - - // 注册路由 - registerRoutes(r) - - return &http.Server{ - Addr: addr, - Handler: r, - } -} - -// registerRoutes 注册HTTP路由 -func registerRoutes(r *gin.Engine) { - // 使用server包中的路由注册函数 - server.RegisterRoutes(r) -} - -// startServer 启动服务器并处理优雅关闭 -func startServer(server *http.Server) { - // 获取全局日志实例 - logger := logger.GetLogger() - - // 创建一个通道来接收操作系统信号 - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - - // 在goroutine中启动服务器 - go func() { - logger.WithField("addr", server.Addr).Info("HTTP服务器已启动") - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.LogError(err, "服务器启动失败") - os.Exit(1) - } - }() - - // 等待中断信号 - <-sigChan - logger.Info("收到关闭信号,正在优雅关闭服务器...") - - // 创建一个带超时的上下文 - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // 优雅关闭服务器 - if err := server.Shutdown(ctx); err != nil { - logger.LogError(err, "服务器关闭时出错") - } else { - logger.LogServerStop() - } -} +package cmd + +import ( + "context" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "NetworkAuth/database" + "NetworkAuth/middleware" + "NetworkAuth/server" + "NetworkAuth/services" + "NetworkAuth/utils" + "NetworkAuth/utils/logger" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// serverCmd 代表服务器命令 +var serverCmd = &cobra.Command{ + Use: "server", + Short: "启动 NetworkAuth 系统服务器", + Long: `启动 NetworkAuth 系统 HTTP 服务器,监听配置文件中指定的端口,提供 Web 管理界面和 API 服务。`, + Run: runServer, +} + +func init() { + // 将服务器命令添加到根命令 + rootCmd.AddCommand(serverCmd) + + // 添加服务器特定的标志 + serverCmd.Flags().StringP("host", "H", "", "服务器监听地址 (覆盖配置文件)") + serverCmd.Flags().IntP("port", "p", 0, "服务器监听端口 (覆盖配置文件)") +} + +// runServer 运行HTTP服务器 +func runServer(cmd *cobra.Command, args []string) { + // 获取配置 + host := getServerHost(cmd) + port := getServerPort(cmd) + addr := fmt.Sprintf("%s:%d", host, port) + + // 获取全局日志实例 + 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) + // 如果初始化失败(例如 MySQL 连不上),则打印错误并退出 + db, err := database.Init() + if err != nil { + logrus.WithError(err).Fatal("数据库初始化失败,请检查配置或确认是否已安装") + } + + if db != nil { + // 检查系统是否已安装 + isInstalled := services.GetSettingsService().GetString("is_installed", "0") + if isInstalled == "1" { + // 执行自动迁移(确保表结构存在) + if err := database.AutoMigrate(); err != nil { + logrus.WithError(err).Fatal("数据库自动迁移失败") + } + // 初始化默认系统设置 + if err := database.SeedDefaultSettings(); err != nil { + logrus.WithError(err).Fatal("默认系统设置初始化失败") + } + if err := database.SeedDefaultPortalNavigation(); 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("系统尚未安装 (is_installed=0),跳过核心组件初始化") + } + } else { + logrus.Info("系统处于未初始化状态,跳过数据库自动迁移和设置加载") + } + + // 创建HTTP服务器 + server := createHTTPServer(addr) + + // 启动服务器 + startServer(server) +} + +// getServerHost 获取服务器监听地址 +func getServerHost(cmd *cobra.Command) string { + if host, _ := cmd.Flags().GetString("host"); host != "" { + return host + } + return viper.GetString("server.host") +} + +// getServerPort 获取服务器监听端口 +func getServerPort(cmd *cobra.Command) int { + if port, _ := cmd.Flags().GetInt("port"); port != 0 { + return port + } + return viper.GetInt("server.port") +} + +// createHTTPServer 创建HTTP服务器 +func createHTTPServer(addr string) *http.Server { + // 创建 Gin 引擎 + r := gin.New() + + // 使用默认的 Recovery 中间件 + r.Use(gin.Recovery()) + + // 启用 CORS 中间件,支持前后端分离 + r.Use(middleware.CorsMiddleware()) + + // 添加日志中间件 + // 默认为 true,只有显式设置为 false 才关闭 + enableAccessLog := true + if viper.IsSet("server.access_log") { + enableAccessLog = viper.GetBool("server.access_log") + } + if enableAccessLog { + r.Use(middleware.WrapHandler()) + } + + // 添加开发模式中间件(统一管理开发模式功能) + r.Use(middleware.DevModeMiddleware()) + + // 添加安装检查中间件 + r.Use(middleware.InstallCheckMiddleware()) + + // 添加维护模式中间件 + r.Use(middleware.MaintenanceMiddleware()) + + // 注册路由 + registerRoutes(r) + + return &http.Server{ + Addr: addr, + Handler: r, + } +} + +// registerRoutes 注册HTTP路由 +func registerRoutes(r *gin.Engine) { + // 使用server包中的路由注册函数 + server.RegisterRoutes(r) +} + +// startServer 启动服务器并处理优雅关闭 +func startServer(server *http.Server) { + // 获取全局日志实例 + logger := logger.GetLogger() + + // 创建一个通道来接收操作系统信号 + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + // 在goroutine中启动服务器 + go func() { + logger.WithField("addr", server.Addr).Info("HTTP服务器已启动") + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.LogError(err, "服务器启动失败") + os.Exit(1) + } + }() + + // 等待中断信号 + <-sigChan + logger.Info("收到关闭信号,正在优雅关闭服务器...") + + // 创建一个带超时的上下文 + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // 优雅关闭服务器 + if err := server.Shutdown(ctx); err != nil { + logger.LogError(err, "服务器关闭时出错") + } else { + logger.LogServerStop() + } +} diff --git a/config/config.go b/config/config.go index 09b56db..60a1112 100644 --- a/config/config.go +++ b/config/config.go @@ -186,11 +186,11 @@ func Init(cfgFilePath string) { ).Fatal("配置文件解析错误") } - // 统一使用 filepath.Clean 和 filepath.Base 处理路径展示 + // 日志中优先显示相对运行根目录的路径,避免泄露安装目录。 cleanPath := filepath.Clean(cfgFilePath) log.WithFields( log.Fields{ - "file": cleanPath, + "file": utils.DisplayPath(cleanPath), }, ).Info("使用配置文件") diff --git a/controllers/admin/portal_navigation.go b/controllers/admin/portal_navigation.go new file mode 100644 index 0000000..cfcf596 --- /dev/null +++ b/controllers/admin/portal_navigation.go @@ -0,0 +1,219 @@ +package admin + +import ( + "NetworkAuth/models" + "NetworkAuth/services" + "fmt" + + "github.com/gin-gonic/gin" +) + +// PortalNavigationListHandler 查询门户导航列表 +// 返回后台管理使用的完整导航数据 +func PortalNavigationListHandler(c *gin.Context) { + db, ok := authBaseController.GetDB(c) + if !ok { + return + } + + var list []models.PortalNavigation + if err := db.Order("sort ASC, id ASC").Find(&list).Error; err != nil { + authBaseController.HandleInternalError(c, "查询门户导航失败", err) + return + } + + authBaseController.HandleSuccess(c, "ok", list) +} + +// PortalNavigationPublicListHandler 查询公开门户导航列表 +// 返回门户首页展示使用的可见导航数据 +func PortalNavigationPublicListHandler(c *gin.Context) { + db, ok := authBaseController.GetDB(c) + if !ok { + return + } + + var list []models.PortalNavigation + if err := db.Where("is_hidden = ?", false).Order("sort ASC, id ASC").Find(&list).Error; err != nil { + authBaseController.HandleInternalError(c, "查询门户导航失败", err) + return + } + + authBaseController.HandleSuccess(c, "ok", list) +} + +// PortalNavigationCreateHandler 创建门户导航 +// 保存新导航并在需要时自动切换唯一首页 +func PortalNavigationCreateHandler(c *gin.Context) { + var body portalNavigationPayload + if !authBaseController.BindJSON(c, &body) { + return + } + + item, valid := buildPortalNavigationFromPayload(c, body) + if !valid { + return + } + + db, ok := authBaseController.GetDB(c) + if !ok { + return + } + + if err := services.SavePortalNavigation(db, &item); err != nil { + authBaseController.HandleInternalError(c, "创建门户导航失败", err) + return + } + + recordPortalNavigationOperation(c, "新增门户导航", "新增了门户导航:"+item.Name) + authBaseController.HandleSuccess(c, "创建成功", item) +} + +// PortalNavigationUpdateHandler 更新门户导航 +// 按ID更新导航信息并维护唯一首页约束 +func PortalNavigationUpdateHandler(c *gin.Context) { + var body portalNavigationPayload + if !authBaseController.BindJSON(c, &body) { + return + } + + switch { + case body.ID == 0: + authBaseController.HandleValidationError(c, "导航ID不能为空") + return + } + + item, valid := buildPortalNavigationFromPayload(c, body) + if !valid { + return + } + + db, ok := authBaseController.GetDB(c) + if !ok { + return + } + + var exists models.PortalNavigation + if err := db.Where("id = ?", body.ID).First(&exists).Error; err != nil { + authBaseController.HandleNotFoundError(c, "门户导航") + return + } + + item.ID = body.ID + if err := services.SavePortalNavigation(db, &item, exists); err != nil { + authBaseController.HandleInternalError(c, "更新门户导航失败", err) + return + } + + recordPortalNavigationOperation(c, "修改门户导航", "修改了门户导航:"+item.Name) + authBaseController.HandleSuccess(c, "更新成功", item) +} + +// PortalNavigationDeleteHandler 删除门户导航 +// 按ID删除指定导航记录 +func PortalNavigationDeleteHandler(c *gin.Context) { + var body struct { + ID uint `json:"id"` + } + if !authBaseController.BindJSON(c, &body) { + return + } + + switch { + case body.ID == 0: + authBaseController.HandleValidationError(c, "导航ID不能为空") + return + } + + db, ok := authBaseController.GetDB(c) + if !ok { + return + } + + var item models.PortalNavigation + if err := db.Where("id = ?", body.ID).First(&item).Error; err != nil { + authBaseController.HandleNotFoundError(c, "门户导航") + return + } + + switch services.IsPortalNavigationAdminEntry(item) { + case true: + authBaseController.HandleValidationError(c, "管理员登录导航为系统保留项,不允许删除") + return + } + + if err := db.Delete(&item).Error; err != nil { + authBaseController.HandleInternalError(c, "删除门户导航失败", err) + return + } + + recordPortalNavigationOperation(c, "删除门户导航", "删除了门户导航:"+item.Name) + authBaseController.HandleSuccess(c, "删除成功", nil) +} + +// portalNavigationPayload 门户导航请求体 +type portalNavigationPayload struct { + ID uint `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Sort int `json:"sort"` + IsHome bool `json:"is_home"` + IsHidden bool `json:"is_hidden"` + IsExternal bool `json:"is_external"` +} + +// buildPortalNavigationFromPayload 构建门户导航实体 +// 负责统一做字段校验和数据转换 +func buildPortalNavigationFromPayload(c *gin.Context, body portalNavigationPayload) (models.PortalNavigation, bool) { + item := models.PortalNavigation{ + Name: body.Name, + Path: body.Path, + Sort: body.Sort, + IsHome: body.IsHome, + IsHidden: body.IsHidden, + IsExternal: body.IsExternal, + } + services.NormalizePortalNavigation(&item) + + if err := validatePortalNavigationInput(item); err != nil { + authBaseController.HandleValidationError(c, err.Error()) + return models.PortalNavigation{}, false + } + + return item, true +} + +// validatePortalNavigationInput 校验门户导航字段 +// 保证名称和地址满足基础格式要求 +func validatePortalNavigationInput(item models.PortalNavigation) error { + switch { + case item.Name == "": + return fmt.Errorf("名称不能为空") + case len(item.Name) > 64: + return fmt.Errorf("名称长度不能超过64个字符") + case item.Path == "": + return fmt.Errorf("地址不能为空") + case len(item.Path) > 255: + return fmt.Errorf("地址长度不能超过255个字符") + case item.Sort < 0: + return fmt.Errorf("排序不能小于0") + case item.IsHome && item.IsHidden: + return fmt.Errorf("设为首页后禁止隐藏") + default: + return nil + } +} + +// recordPortalNavigationOperation 记录门户导航操作日志 +// 统一写入管理员操作日志,便于后台审计 +func recordPortalNavigationOperation(c *gin.Context, logType, message string) { + operator := c.GetString("admin_username") + operatorUUID := c.GetString("admin_uuid") + + switch { + case operator == "": + operator = "system" + } + + services.RecordOperationLog(logType, operator, operatorUUID, message) +} diff --git a/controllers/admin/settings.go b/controllers/admin/settings.go index f4e36d5..9e4faf1 100644 --- a/controllers/admin/settings.go +++ b/controllers/admin/settings.go @@ -244,7 +244,7 @@ func SettingsPublicHandler(c *gin.Context) { var list []models.Settings // 查询公开的基本信息、维护模式和所有前端平台配置 - if err := db.Where("name IN ? OR name LIKE ?", []string{"site_title", "site_description", "site_keywords", "site_logo", "contact_email", "maintenance_mode", "hide_login_entrance"}, "platform_%").Find(&list).Error; err != nil { + if err := db.Where("name IN ? OR name LIKE ?", []string{"site_title", "site_description", "site_keywords", "site_logo", "contact_email", "maintenance_mode"}, "platform_%").Find(&list).Error; err != nil { authBaseController.HandleInternalError(c, "查询失败", err) return } diff --git a/controllers/install/install.go b/controllers/install/install.go index ffa4c8d..1cfae43 100644 --- a/controllers/install/install.go +++ b/controllers/install/install.go @@ -98,6 +98,7 @@ func InstallSubmitHandler(c *gin.Context) { // 初始化系统默认设置 database.SeedDefaultSettings() + database.SeedDefaultPortalNavigation() // 3. 生成新的管理员密码哈希和盐值 adminSalt, err := utils.GenerateRandomSalt() diff --git a/database/database.go b/database/database.go index 81f5083..35154ae 100644 --- a/database/database.go +++ b/database/database.go @@ -229,7 +229,7 @@ func initSQLite(sqliteConfig *appconfig.SQLiteConfig, logLevel string) error { sqlDB.SetMaxIdleConns(1) } dbInstance = db - logrus.WithField("path", path).Info("SQLite 连接已建立") + logrus.WithField("path", utils.DisplayPath(path)).Info("SQLite 连接已建立") return nil } diff --git a/database/migrate.go b/database/migrate.go index d1ef641..393a52e 100644 --- a/database/migrate.go +++ b/database/migrate.go @@ -1,32 +1,33 @@ -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.User{}, - &models.App{}, - &models.API{}, - &models.Variable{}, - &models.Function{}, - ); err != nil { - logrus.WithError(err).Error("AutoMigrate 执行失败") - return err - } - logrus.Info("AutoMigrate 执行完成") - 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.PortalNavigation{}, + &models.OperationLog{}, + &models.LoginLog{}, + &models.User{}, + &models.App{}, + &models.API{}, + &models.Variable{}, + &models.Function{}, + ); err != nil { + logrus.WithError(err).Error("AutoMigrate 执行失败") + return err + } + logrus.Info("AutoMigrate 执行完成") + return nil +} diff --git a/database/portal_navigation.go b/database/portal_navigation.go new file mode 100644 index 0000000..381c691 --- /dev/null +++ b/database/portal_navigation.go @@ -0,0 +1,70 @@ +package database + +import ( + "NetworkAuth/models" + "errors" + + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +// SeedDefaultPortalNavigation 初始化默认门户导航 +// 当系统首次安装或升级后缺少默认入口时,自动补充首页和管理员登录入口 +func SeedDefaultPortalNavigation() error { + db, err := GetDB() + if err != nil { + return err + } + + defaultItems := []models.PortalNavigation{ + { + Name: "首页", + Path: "/home/index", + Sort: 0, + IsHome: true, + IsHidden: false, + IsExternal: false, + }, + { + Name: "管理员登录", + Path: "admin", + Sort: 999, + IsHome: false, + IsHidden: false, + IsExternal: false, + }, + } + + for _, item := range defaultItems { + var exists models.PortalNavigation + if err := db.Where("path = ?", item.Path).First(&exists).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if err := db.Create(&item).Error; err != nil { + logrus.WithError(err).WithField("path", item.Path).Error("创建默认门户导航失败") + return err + } + continue + } + + switch exists.Path == "admin" { + case true: + if err := db.Model(&models.PortalNavigation{}).Where("id = ?", exists.ID).Updates(map[string]interface{}{ + "name": "管理员登录", + "path": "admin", + "sort": 999, + "is_home": false, + "is_external": false, + }).Error; err != nil { + logrus.WithError(err).WithField("path", item.Path).Error("更新默认门户导航失败") + return err + } + default: + continue + } + } + + logrus.Info("默认门户导航初始化完成") + return nil +} diff --git a/database/settings.go b/database/settings.go index 54d6e1c..39119eb 100644 --- a/database/settings.go +++ b/database/settings.go @@ -51,11 +51,6 @@ func SeedDefaultSettings() error { Value: "0", Description: "维护模式,0=关闭维护模式,1=开启维护模式", }, - { - Name: "hide_login_entrance", - Value: "0", - Description: "隐藏登录入口,0=显示,1=隐藏(门户中不显示管理员或子账号登录入口)", - }, { Name: "encryption_key", Value: encryptionKey, @@ -329,6 +324,11 @@ func SeedDefaultSettings() error { } } + // 移除已废弃的旧设置项,管理员登录入口改由门户导航控制 + if err := db.Where("name = ?", "hide_login_entrance").Delete(&models.Settings{}).Error; err != nil { + return err + } + logrus.Info("系统设置初始化完成") return nil } diff --git a/go.mod b/go.mod index e1acfb1..af41807 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,12 @@ go 1.25.0 require ( github.com/andybalholm/brotli v1.2.1 github.com/gin-contrib/cors v1.7.6 - github.com/gin-gonic/gin v1.12.0 + github.com/gin-gonic/gin v1.10.1 github.com/glebarez/sqlite v1.11.0 github.com/go-resty/resty/v2 v2.17.2 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 + github.com/imroc/req/v3 v3.50.0 github.com/mojocn/base64Captcha v1.3.8 github.com/redis/go-redis/v9 v9.18.0 github.com/sirupsen/logrus v1.9.3 @@ -29,7 +30,6 @@ require ( github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cloudflare/circl v1.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -43,27 +43,28 @@ require ( github.com/go-rod/rod v0.116.2 // indirect github.com/go-rod/stealth v0.4.9 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/goccy/go-json v0.10.6 // indirect - github.com/goccy/go-yaml v1.19.2 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/imroc/req/v3 v3.49.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.17.11 // indirect + github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/onsi/ginkgo/v2 v2.22.0 // indirect github.com/pelletier/go-toml/v2 v2.3.0 // indirect - github.com/quic-go/qpack v0.6.0 // indirect - github.com/quic-go/quic-go v0.59.0 // indirect - github.com/refraction-networking/utls v1.6.7 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.48.2 // indirect + github.com/refraction-networking/utls v1.8.1 // 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 @@ -83,14 +84,18 @@ require ( github.com/ysmood/got v0.40.0 // indirect github.com/ysmood/gson v0.7.3 // indirect github.com/ysmood/leakless v0.9.0 // indirect - go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.uber.org/atomic v1.11.0 // indirect + go.uber.org/mock v0.5.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/arch v0.25.0 // indirect + golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect golang.org/x/image v0.25.0 // indirect + golang.org/x/mod v0.33.0 // indirect golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect + golang.org/x/tools v0.42.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.22.5 // indirect diff --git a/go.sum b/go.sum index aab26ec..5fe35cc 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,6 @@ github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiD github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cloudflare/circl v1.5.0 h1:hxIWksrX6XN5a1L2TI/h53AGPhNHoUBo+TD1ms9+pys= -github.com/cloudflare/circl v1.5.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -36,12 +34,14 @@ github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQ github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= 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.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= -github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= +github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= 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= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -59,12 +59,12 @@ github.com/go-rod/stealth v0.4.9 h1:X2PmQk4DUF2wzw6GOsWjW/glb8K5ebnftbEvLh7MlZ4= github.com/go-rod/stealth v0.4.9/go.mod h1:eAzyvw8c0iAd5nJJsSWeh0fQ5z94vCIfdi1hUmYDimc= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 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.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/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= @@ -82,8 +82,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/imroc/req/v3 v3.49.0 h1:5Rac2qvz7Dq0E3PeBo/c2szV3hagPQIGLoHtfBmYhu4= -github.com/imroc/req/v3 v3.49.0/go.mod h1:XZf4t94DNJzcA0UOBlA68hmSrWsAyvN407ADdH4mzCA= +github.com/imroc/req/v3 v3.50.0 h1:n3BVnZiTRpvkN5T1IB79LC/THhFU9iXksNRMH4ZNVaY= +github.com/imroc/req/v3 v3.50.0/go.mod h1:tsOk8K7zI6cU4xu/VWCZVtq9Djw9IWm4MslKzme5woU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -92,8 +92,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= -github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -111,18 +111,22 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mojocn/base64Captcha v1.3.8 h1:rrN9BhCwXKS8ht1e21kvR3iTaMgf4qPC9sRoV52bqEg= github.com/mojocn/base64Captcha v1.3.8/go.mod h1:QFZy927L8HVP3+VV5z2b1EAEiv1KxVJKZbAucVgLUy4= +github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= +github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.34.2 h1:pNCwDkzrsv7MS9kpaQvVb1aVLahQXyJ/Tv5oAZMI3i8= +github.com/onsi/gomega v1.34.2/go.mod h1:v1xfxRgk0KIsG+QOdm7p8UosrOzPYRo60fd3B/1Dukc= github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= github.com/pelletier/go-toml/v2 v2.3.0/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.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/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.48.2 h1:wsKXZPeGWpMpCGSWqOcqpW2wZYic/8T3aqiOID0/KWE= +github.com/quic-go/quic-go v0.48.2/go.mod h1:yBgs3rWBOADpga7F+jJsb6Ybg1LSYiQvwWlLX+/6HMs= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= -github.com/refraction-networking/utls v1.6.7 h1:zVJ7sP1dJx/WtVuITug3qYUq034cDq9B2MR1K67ULZM= -github.com/refraction-networking/utls v1.6.7/go.mod h1:BC3O4vQzye5hqpmDTWUqi4P5DDhzJfkV1tdqtawQIH0= +github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo= +github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= 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= @@ -130,8 +134,8 @@ github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq 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/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/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= @@ -199,12 +203,10 @@ github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= -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/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= 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.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE= @@ -216,6 +218,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e h1:4qufH0hlUYs6AO6XmZC3GqfDPGSXHVXUFR6OND+iJX4= +golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= 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= @@ -224,6 +228,8 @@ 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.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= 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= @@ -241,6 +247,8 @@ 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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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= @@ -282,6 +290,8 @@ 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.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/models/portal_navigation.go b/models/portal_navigation.go new file mode 100644 index 0000000..6328802 --- /dev/null +++ b/models/portal_navigation.go @@ -0,0 +1,17 @@ +package models + +import "time" + +// PortalNavigation 门户导航表模型 +// 用于维护门户页面展示的导航入口以及唯一首页标记 +type PortalNavigation struct { + ID uint `json:"id" gorm:"primaryKey;comment:导航ID,自增主键"` + Name string `json:"name" gorm:"size:64;not null;comment:导航名称"` + Path string `json:"path" gorm:"size:255;not null;comment:导航地址或路由路径"` + Sort int `json:"sort" gorm:"default:0;not null;comment:排序值,越小越靠前,0最优先"` + IsHome bool `json:"is_home" gorm:"default:false;comment:是否为门户首页"` + IsHidden bool `json:"is_hidden" gorm:"default:false;comment:是否隐藏"` + IsExternal bool `json:"is_external" gorm:"default:false;comment:是否外部打开"` + CreatedAt time.Time `json:"created_at" gorm:"comment:创建时间"` + UpdatedAt time.Time `json:"updated_at" gorm:"comment:更新时间"` +} diff --git a/server/admin.go b/server/admin.go index df52de0..480caef 100644 --- a/server/admin.go +++ b/server/admin.go @@ -1,108 +1,113 @@ -package server - -import ( - adminctl "NetworkAuth/controllers/admin" - - "github.com/gin-gonic/gin" -) - -// RegisterAdminRoutes 注册管理员后台相关路由 -func RegisterAdminRoutes(rg *gin.RouterGroup) { - admin := rg.Group("/admin") - - // Admin 认证相关路由 - admin.GET("/captcha", adminctl.CaptchaHandler) - admin.GET("/csrf", adminctl.CSRFTokenHandler) - admin.POST("/login", adminctl.LoginHandler) - - // 公开设置API - admin.GET("/settings/public", adminctl.SettingsPublicHandler) - - // 退出登录 - admin.POST("/logout", adminctl.LogoutHandler) - - // 需要认证的路由组 - authorized := admin.Group("/") - authorized.Use(adminctl.AdminAuthRequired()) - { - // 系统信息API - authorized.GET("/system/info", adminctl.SystemInfoHandler) - authorized.GET("/dashboard/stats", adminctl.DashboardStatsHandler) - authorized.GET("/dashboard/login-logs", adminctl.DashboardLoginLogsHandler) - - // 个人资料API - authorized.GET("/profile", adminctl.ProfileQueryHandler) - authorized.POST("/profile/update", adminctl.ProfileUpdateHandler) - authorized.POST("/profile/password", adminctl.ProfilePasswordUpdateHandler) - - // 设置API - authorized.GET("/settings", adminctl.SettingsQueryHandler) - authorized.POST("/settings/update", adminctl.SettingsUpdateHandler) - authorized.POST("/settings/generate-key", adminctl.SettingsGenerateKeyHandler) - - // 操作日志API - authorized.GET("/logs", adminctl.LogsListHandler) // 获取操作日志列表 - authorized.POST("/logs/clear", adminctl.LogsClearHandler) // 清空操作日志 - - // 登录日志API - authorized.GET("/login_logs", adminctl.LoginLogsListHandler) // 获取登录日志列表 - authorized.POST("/login_logs/clear", adminctl.LoginLogsClearHandler) // 清空登录日志 - - // 子账号相关API (Mock) - authorized.GET("/subaccounts/simple", adminctl.SubAccountSimpleListHandler) - - // 应用管理API - appsGroup := authorized.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 := authorized.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) - } - } -} +package server + +import ( + adminctl "NetworkAuth/controllers/admin" + + "github.com/gin-gonic/gin" +) + +// RegisterAdminRoutes 注册管理员后台相关路由 +func RegisterAdminRoutes(rg *gin.RouterGroup) { + admin := rg.Group("/admin") + + // Admin 认证相关路由 + admin.GET("/captcha", adminctl.CaptchaHandler) + admin.GET("/csrf", adminctl.CSRFTokenHandler) + admin.POST("/login", adminctl.LoginHandler) + + // 公开设置API + admin.GET("/settings/public", adminctl.SettingsPublicHandler) + admin.GET("/portal-navigation/public", adminctl.PortalNavigationPublicListHandler) + + // 退出登录 + admin.POST("/logout", adminctl.LogoutHandler) + + // 需要认证的路由组 + authorized := admin.Group("/") + authorized.Use(adminctl.AdminAuthRequired()) + { + // 系统信息API + authorized.GET("/system/info", adminctl.SystemInfoHandler) + authorized.GET("/dashboard/stats", adminctl.DashboardStatsHandler) + authorized.GET("/dashboard/login-logs", adminctl.DashboardLoginLogsHandler) + + // 个人资料API + authorized.GET("/profile", adminctl.ProfileQueryHandler) + authorized.POST("/profile/update", adminctl.ProfileUpdateHandler) + authorized.POST("/profile/password", adminctl.ProfilePasswordUpdateHandler) + + // 设置API + authorized.GET("/settings", adminctl.SettingsQueryHandler) + authorized.POST("/settings/update", adminctl.SettingsUpdateHandler) + authorized.POST("/settings/generate-key", adminctl.SettingsGenerateKeyHandler) + authorized.GET("/portal-navigation", adminctl.PortalNavigationListHandler) + authorized.POST("/portal-navigation/create", adminctl.PortalNavigationCreateHandler) + authorized.POST("/portal-navigation/update", adminctl.PortalNavigationUpdateHandler) + authorized.POST("/portal-navigation/delete", adminctl.PortalNavigationDeleteHandler) + + // 操作日志API + authorized.GET("/logs", adminctl.LogsListHandler) // 获取操作日志列表 + authorized.POST("/logs/clear", adminctl.LogsClearHandler) // 清空操作日志 + + // 登录日志API + authorized.GET("/login_logs", adminctl.LoginLogsListHandler) // 获取登录日志列表 + authorized.POST("/login_logs/clear", adminctl.LoginLogsClearHandler) // 清空登录日志 + + // 子账号相关API (Mock) + authorized.GET("/subaccounts/simple", adminctl.SubAccountSimpleListHandler) + + // 应用管理API + appsGroup := authorized.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 := authorized.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) + } + } +} diff --git a/services/portal_navigation.go b/services/portal_navigation.go new file mode 100644 index 0000000..34cc648 --- /dev/null +++ b/services/portal_navigation.go @@ -0,0 +1,80 @@ +package services + +import ( + "NetworkAuth/models" + "strings" + + "gorm.io/gorm" +) + +const portalNavigationAdminPath = "admin" +const portalNavigationAdminSort = 999 + +// NormalizePortalNavigation 规范化门户导航数据 +// 统一清理首尾空白,并处理首页与排序约束 +func NormalizePortalNavigation(item *models.PortalNavigation) { + item.Name = strings.TrimSpace(item.Name) + item.Path = strings.TrimSpace(item.Path) + if item.Sort < 0 { + item.Sort = 0 + } + if item.IsHome { + item.IsHidden = false + } +} + +// IsPortalNavigationAdminEntry 判断是否为管理员入口 +// 管理员入口属于系统保留导航项,不允许修改基础信息 +func IsPortalNavigationAdminEntry(item models.PortalNavigation) bool { + return strings.EqualFold(strings.TrimSpace(item.Path), portalNavigationAdminPath) +} + +// LockPortalNavigationProtectedFields 锁定系统保留导航字段 +// 管理员入口仅允许调整隐藏状态,其余字段保持系统固定值 +func LockPortalNavigationProtectedFields(item *models.PortalNavigation, exists models.PortalNavigation) { + switch IsPortalNavigationAdminEntry(exists) { + case true: + item.Name = "管理员登录" + item.Path = portalNavigationAdminPath + item.Sort = portalNavigationAdminSort + item.IsHome = false + item.IsExternal = false + default: + return + } +} + +// SavePortalNavigation 保存门户导航 +// 当当前记录被设置为门户首页时,会自动取消其他记录的首页状态 +func SavePortalNavigation(db *gorm.DB, item *models.PortalNavigation, exists ...models.PortalNavigation) error { + if len(exists) > 0 { + LockPortalNavigationProtectedFields(item, exists[0]) + } + NormalizePortalNavigation(item) + + return db.Transaction(func(tx *gorm.DB) error { + if item.IsHome { + query := tx.Model(&models.PortalNavigation{}).Where("is_home = ?", true) + if item.ID > 0 { + query = query.Where("id <> ?", item.ID) + } + if err := query.Update("is_home", false).Error; err != nil { + return err + } + } + + switch { + case item.ID == 0: + return tx.Create(item).Error + default: + return tx.Model(&models.PortalNavigation{}).Where("id = ?", item.ID).Updates(map[string]interface{}{ + "name": item.Name, + "path": item.Path, + "sort": item.Sort, + "is_home": item.IsHome, + "is_hidden": item.IsHidden, + "is_external": item.IsExternal, + }).Error + } + }) +} diff --git a/services/request/resty_client.go b/services/request/resty_client.go index 5816cb5..76fd811 100644 --- a/services/request/resty_client.go +++ b/services/request/resty_client.go @@ -4,64 +4,119 @@ import ( "bytes" "compress/flate" "compress/gzip" + "context" "encoding/json" + "errors" + "fmt" "io" "net/http" "net/http/cookiejar" - "reflect" + "net/url" "strings" "time" - "unsafe" "github.com/andybalholm/brotli" "github.com/go-resty/resty/v2" + req "github.com/imroc/req/v3" "github.com/skycheung803/go-bypasser" ) type RestyClient struct { - client *resty.Client + client *resty.Client + reqClient *req.Client + ctx context.Context + baseURL string + defaultHeaders map[string]string + proxyStr string + timeout time.Duration } func (request *RestyClient) Resty() *resty.Client { return request.client } -// NewClient 创建一个基于 uTLS 指纹与 HTTP/2 指纹的 Resty 客户端 -// baseURL 不为空则设置默认 BaseURL;proxyStr 不为空则启用 HTTP 代理(仅 HTTP/1.1) -// persistCookies 启用持久化 Cookie;followRedirect 启用重定向跟随;timeout 设置超时时间(秒,0 或负数则默认 60 秒) +// NewClient 创建一个基于 go-bypasser(req/v3) 的客户端。 +// 对外继续保留 Resty 风格接口,但底层请求不再走 resty.Transport。 func NewClient(baseURL string, proxyStr string, persistCookies bool, timeout int) *RestyClient { - rc := resty.New() - - if baseURL != "" { - rc.SetBaseURL(baseURL) - } - - if persistCookies { - jar, _ := cookiejar.New(nil) - rc.SetCookieJar(jar) - } - - // 设置请求超时时间,如果传入 0 或负数则默认 60 秒 if timeout <= 0 { timeout = 60 } - rc.SetTimeout(time.Duration(timeout) * time.Second) + timeoutDuration := time.Duration(timeout) * time.Second - // 统一设置客户端默认请求头(调用级 headers 可覆盖),字段按字母顺序排列 - rc.SetHeader("accept", "*/*") - rc.SetHeader("accept-language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6") - rc.SetHeader("connection", "keep-alive") - rc.SetHeader("pragma", "no-cache") - rc.SetHeader("priority", "u=1,i") - rc.SetHeader("sec-ch-ua", "\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"") - rc.SetHeader("sec-ch-ua-mobile", "?0") - rc.SetHeader("sec-ch-ua-platform", "\"macOS\"") - rc.SetHeader("sec-fetch-dest", "empty") - rc.SetHeader("sec-fetch-mode", "cors") - rc.SetHeader("sec-fetch-site", "same-origin") - rc.SetHeader("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") + defaultHeaders := map[string]string{ + "accept": "*/*", + "accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6", + "connection": "keep-alive", + "pragma": "no-cache", + "priority": "u=1,i", + "sec-ch-ua": "\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"", + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": "\"macOS\"", + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", + } - // 初始化 go-bypasser 替代原有的 spoofed-round-tripper + stateClient := resty.New(). + SetTimeout(timeoutDuration). + SetHeaders(defaultHeaders) + if baseURL != "" { + stateClient.SetBaseURL(baseURL) + } + + var sharedJar http.CookieJar + if persistCookies { + jar, _ := cookiejar.New(nil) + sharedJar = jar + stateClient.SetCookieJar(sharedJar) + } + + baseReqClient := mustNewReqClient(proxyStr, timeoutDuration, baseURL, defaultHeaders, sharedJar) + + return &RestyClient{ + client: stateClient, + reqClient: baseReqClient, + ctx: context.Background(), + baseURL: baseURL, + defaultHeaders: defaultHeaders, + proxyStr: proxyStr, + timeout: timeoutDuration, + } +} + +func (request *RestyClient) WithContext(ctx context.Context) *RestyClient { + if ctx == nil { + ctx = context.Background() + } + return &RestyClient{ + client: request.client, + reqClient: request.reqClient, + ctx: ctx, + baseURL: request.baseURL, + defaultHeaders: request.defaultHeaders, + proxyStr: request.proxyStr, + timeout: request.timeout, + } +} + +// SetPersistentHeader 设置持久化 Header。 +// 除 Cookie 外,其余 Header 会同步到 req 客户端的 common headers。 +func (request *RestyClient) SetPersistentHeader(key string, value string) { + if request.defaultHeaders == nil { + request.defaultHeaders = make(map[string]string) + } + lowerKey := strings.ToLower(key) + request.defaultHeaders[lowerKey] = value + if request.client != nil { + request.client.SetHeader(key, value) + } + if request.reqClient != nil && lowerKey != "cookie" { + request.reqClient.SetCommonHeader(key, value) + } +} + +func mustNewReqClient(proxyStr string, timeout time.Duration, baseURL string, defaultHeaders map[string]string, jar http.CookieJar) *req.Client { opts := []bypasser.BypasserOption{ bypasser.WithInsecureSkipVerify(true), } @@ -74,155 +129,287 @@ func NewClient(baseURL string, proxyStr string, persistCookies bool, timeout int panic(err) } - rc.SetTransport(&sanitizeTransport{t: bypass.Transport}) - - return &RestyClient{client: rc} -} - -// sanitizeTransport 包装 http.RoundTripper 以修复底层库可能违背 Go 接口约定的行为 -type sanitizeTransport struct { - t http.RoundTripper -} - -func (s *sanitizeTransport) RoundTrip(req *http.Request) (*http.Response, error) { - resp, err := s.t.RoundTrip(req) - // net/http 规定 RoundTripper 要么返回有效的 resp 和 nil error,要么返回 nil resp 和有效的 error。 - // 某些第三方库(如部分 tls-client 封装)在遇到网络小问题时会同时返回 resp 和 err。 - // 这会导致 net/http 打印 "RoundTripper returned a response & error; ignoring response" 并强制丢弃响应。 - // 在这里我们进行修正:如果已经拿到了响应(哪怕是不完整的),我们优先保留响应并将 err 置空,让上层通过读取 Body 自行发现错误。 - if resp != nil && err != nil { - err = nil - } - return resp, err -} - -// fillResponseBody 使用反射强制填充响应体 -// 当 Resty 因为重定向策略错误而提前返回时,它可能不会读取 Body -// 此方法手动读取 RawResponse.Body 并回填到 resty.Response 的私有 body 字段中 -func (request *RestyClient) fillResponseBody(resp *resty.Response) { - if resp == nil || resp.RawResponse == nil { - return - } - // 如果已经有 body 内容,则不处理 - if len(resp.Body()) > 0 { - return + rt, ok := bypass.Transport.(*bypasser.StandardRoundTripper) + if !ok || rt.Client == nil { + panic("go-bypasser did not return a StandardRoundTripper client") } - // 读取底层 Body - bodyBytes, err := io.ReadAll(resp.RawResponse.Body) + client := rt.Client + client.SetTimeout(timeout) + client.SetRedirectPolicy(req.DefaultRedirectPolicy()) + if baseURL != "" { + client.SetBaseURL(baseURL) + } + for k, v := range defaultHeaders { + if strings.ToLower(k) == "cookie" { + continue + } + client.SetCommonHeader(k, v) + } + if jar != nil { + client.SetCookieJar(jar) + } + return client +} + +func (request *RestyClient) newRequestClient(redirectCount int) *req.Client { + client := request.reqClient.Clone() + if request.baseURL != "" { + client.SetBaseURL(request.baseURL) + } + client.SetTimeout(request.timeout) + + switch { + case redirectCount == 0: + client.SetRedirectPolicy(req.NoRedirectPolicy()) + case redirectCount > 0: + client.SetRedirectPolicy(req.MaxRedirectPolicy(redirectCount)) + default: + client.SetRedirectPolicy(req.DefaultRedirectPolicy()) + } + + return client +} + +func (request *RestyClient) resolveRequestURL(path string) *url.URL { + if path == "" { + return nil + } + + parsedURL, err := url.Parse(path) if err != nil { - return + return nil } - resp.RawResponse.Body.Close() - // 重置 Body 以便后续可能得读取 - resp.RawResponse.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) - - // 使用反射设置私有字段 body - v := reflect.ValueOf(resp).Elem() - f := v.FieldByName("body") - if f.IsValid() { - // 必须使用 UnsafeAddr 获取未导出字段的地址 - rf := reflect.NewAt(f.Type(), unsafe.Pointer(f.UnsafeAddr())).Elem() - rf.SetBytes(bodyBytes) + if parsedURL.IsAbs() { + return parsedURL + } + if request.baseURL == "" { + return parsedURL } - // 设置 size 字段 - s := v.FieldByName("size") - if s.IsValid() { - rs := reflect.NewAt(s.Type(), unsafe.Pointer(s.UnsafeAddr())).Elem() - rs.SetInt(int64(len(bodyBytes))) + baseURL, err := url.Parse(request.baseURL) + if err != nil { + return parsedURL + } + return baseURL.ResolveReference(parsedURL) +} + +func cloneCookie(cookie *http.Cookie) *http.Cookie { + if cookie == nil { + return nil + } + copied := *cookie + return &copied +} + +func isSafeHTTPCookieValue(value string) bool { + if value == "" { + return true + } + for _, r := range value { + if r < 0x21 || r > 0x7e { + return false + } + switch r { + case '"', ';', '\\', ',': + return false + } + } + return true +} + +func parseRawCookieHeader(raw string) []*http.Cookie { + if raw == "" { + return nil + } + var cookies []*http.Cookie + for _, part := range strings.Split(raw, ";") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + name, value, ok := strings.Cut(part, "=") + if !ok { + continue + } + name = strings.TrimSpace(name) + value = strings.TrimSpace(value) + if name == "" { + continue + } + cookies = append(cookies, &http.Cookie{Name: name, Value: value}) + } + return cookies +} + +func buildCookieHeader(cookies []*http.Cookie) string { + if len(cookies) == 0 { + return "" + } + parts := make([]string, 0, len(cookies)) + for _, cookie := range cookies { + if cookie == nil || cookie.Name == "" { + continue + } + parts = append(parts, fmt.Sprintf("%s=%s", cookie.Name, cookie.Value)) + } + return strings.Join(parts, "; ") +} + +// decodeCompressedBody 按响应头解压正文,兼容项目里依赖明文 HTML/JSON 的解析逻辑。 +func decodeCompressedBody(body []byte, contentEncoding string) ([]byte, error) { + encoding := strings.ToLower(strings.TrimSpace(contentEncoding)) + switch { + case encoding == "", encoding == "identity": + return body, nil + case strings.Contains(encoding, "gzip"): + reader, err := gzip.NewReader(bytes.NewReader(body)) + if err != nil { + return nil, err + } + defer reader.Close() + return io.ReadAll(reader) + case strings.Contains(encoding, "deflate"): + reader := flate.NewReader(bytes.NewReader(body)) + defer reader.Close() + return io.ReadAll(reader) + case strings.Contains(encoding, "br"): + reader := brotli.NewReader(bytes.NewReader(body)) + return io.ReadAll(reader) + default: + return body, nil } } -// makeReq 构造带可选请求头的 resty.Request -// 功能:基于客户端创建请求对象,并在传入 headers 时进行设置 -// 返回:带有请求头的请求对象 -func (request *RestyClient) makeReq(headers map[string]string, cookies []*http.Cookie) *resty.Request { - req := request.client.R() +func (request *RestyClient) prepareCookies(path string, requestCookies []*http.Cookie) ([]*http.Cookie, string) { + cookieMap := make(map[string]*http.Cookie) + order := make([]string, 0) + rawCookieNames := make(map[string]struct{}) + + appendCookie := func(cookie *http.Cookie) { + if cookie == nil || cookie.Name == "" { + return + } + if _, exists := cookieMap[cookie.Name]; !exists { + order = append(order, cookie.Name) + } + cloned := cloneCookie(cookie) + cookieMap[cookie.Name] = cloned + if cloned != nil && !isSafeHTTPCookieValue(cloned.Value) { + rawCookieNames[cloned.Name] = struct{}{} + } + } + + parsedURL := request.resolveRequestURL(path) + if request.client != nil && request.client.GetClient() != nil && request.client.GetClient().Jar != nil && parsedURL != nil { + for _, cookie := range request.client.GetClient().Jar.Cookies(parsedURL) { + appendCookie(cookie) + } + } + if request.client != nil { + for _, cookie := range request.client.Cookies { + appendCookie(cookie) + } + } + for _, cookie := range requestCookies { + appendCookie(cookie) + } + + rawCookies := parseRawCookieHeader(request.defaultHeaders["cookie"]) + for _, cookie := range rawCookies { + if cookie == nil || cookie.Name == "" { + continue + } + rawCookieNames[cookie.Name] = struct{}{} + if _, exists := cookieMap[cookie.Name]; !exists { + order = append(order, cookie.Name) + } + cookieMap[cookie.Name] = cookie + } + + mergedCookies := make([]*http.Cookie, 0, len(order)) + for _, name := range order { + if cookie := cookieMap[name]; cookie != nil { + mergedCookies = append(mergedCookies, cloneCookie(cookie)) + } + } + + if len(rawCookieNames) == 0 { + return mergedCookies, "" + } + return mergedCookies, buildCookieHeader(mergedCookies) +} + +func (request *RestyClient) buildReqRequest(client *req.Client, path string, headers map[string]string, cookies []*http.Cookie) *req.Request { + r := client.R().SetContext(request.ctx) if len(headers) > 0 { - req = req.SetHeaders(headers) + r.SetHeaders(headers) } - if len(cookies) > 0 { - req = req.SetCookies(cookies) + + mergedCookies, rawCookieHeader := request.prepareCookies(path, cookies) + if rawCookieHeader != "" { + r.SetHeader("Cookie", rawCookieHeader) + } else if len(mergedCookies) > 0 { + r.SetCookies(mergedCookies...) } - return req + return r } -// doWithEncodingFallback 封装请求发送并在出现压缩相关错误时进行一次降级重试 -// 逻辑:首次请求失败且错误包含 gzip/zstd/brotli/magic number mismatch 时,设置 accept-encoding 为 identity 重试一次 -func (request *RestyClient) doWithEncodingFallback(headers map[string]string, cookies []*http.Cookie, allowRedirect bool, do func(*resty.Request) (*resty.Response, error)) (*resty.Response, error) { - req := request.makeReq(headers, cookies) - if allowRedirect { - request.client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(10)) - } else { - // 使用 http.ErrUseLastResponse 确保 302 响应被返回且 Body 可读,而不是报错 - request.client.SetRedirectPolicy(resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - })) - } - resp, err := do(req) - - // 尝试补救响应体(特别是当重定向被禁用导致报错时) - request.fillResponseBody(resp) - - if err == nil { - return resp, nil - } - s := err.Error() - if strings.Contains(s, "gzip: invalid header") || strings.Contains(s, "magic number mismatch") || strings.Contains(s, "zstd") || strings.Contains(s, "brotli") { - h2 := map[string]string{} - for k, v := range headers { - if strings.ToLower(k) != "accept-encoding" { - h2[k] = v - } - } - h2["Accept-Encoding"] = "identity" - req2 := request.makeReq(h2, cookies) - resp2, err2 := do(req2) - request.fillResponseBody(resp2) - if err2 == nil { - return resp2, nil - } - } - return resp, err -} - -// decodeResponse 处理响应解压与 JSON 解析 -// 功能:自动识别 gzip 压缩并解压;在 result 非空时按 JSON 解析到 result -// 返回:解析错误(成功时为 nil) -func (request *RestyClient) decodeResponse(resp *resty.Response, result interface{}) error { +func (request *RestyClient) adaptReqResponse(path string, method string, data any, headers map[string]string, cookies []*http.Cookie, resp *req.Response) (*resty.Response, error) { if resp == nil { + return nil, nil + } + + body, err := resp.ToBytes() + if err != nil && resp.Response == nil { + return nil, err + } + + rawResponse := resp.Response + if rawResponse != nil { + decodedBody, decodeErr := decodeCompressedBody(body, rawResponse.Header.Get("Content-Encoding")) + if decodeErr != nil { + return nil, decodeErr + } + body = decodedBody + rawResponse.Body = io.NopCloser(bytes.NewReader(body)) + rawResponse.Header.Del("Content-Encoding") + rawResponse.ContentLength = int64(len(body)) + } + + restyReq := request.client.R() + restyReq.Method = method + restyReq.URL = path + restyReq.Body = data + restyReq.Header = make(http.Header) + for k, v := range headers { + restyReq.Header.Set(k, v) + } + mergedCookies, rawCookieHeader := request.prepareCookies(path, cookies) + if rawCookieHeader != "" { + restyReq.Header.Set("Cookie", rawCookieHeader) + } else { + restyReq.Cookies = mergedCookies + } + + restyResp := &resty.Response{ + Request: restyReq, + RawResponse: rawResponse, + } + restyResp.SetBody(body) + return restyResp, err +} + +func (request *RestyClient) decodeResponse(resp *resty.Response, result any) error { + if resp == nil || result == nil { + return nil + } + body := resp.Body() + if len(body) == 0 { return nil } ct := strings.ToLower(resp.Header().Get("Content-Type")) - ce := strings.ToLower(resp.Header().Get("Content-Encoding")) - body := resp.Body() - if strings.Contains(ce, "gzip") && len(body) > 0 { - gr, gerr := gzip.NewReader(bytes.NewReader(body)) - if gerr == nil { - defer gr.Close() - if dec, derr := io.ReadAll(gr); derr == nil { - body = dec - resp.SetBody(body) - } - } - } else if strings.Contains(ce, "deflate") && len(body) > 0 { - // 处理 deflate 压缩 - dr := flate.NewReader(bytes.NewReader(body)) - defer dr.Close() - if dec, derr := io.ReadAll(dr); derr == nil { - body = dec - resp.SetBody(body) - } - } else if strings.Contains(ce, "br") && len(body) > 0 { - // 处理 brotli 压缩 - br := brotli.NewReader(bytes.NewReader(body)) - if dec, derr := io.ReadAll(br); derr == nil { - body = dec - resp.SetBody(body) // 将解压后的 body 写回 response - } - } - if result != nil && (strings.Contains(ct, "application/json") || json.Valid(body)) { + if strings.Contains(ct, "application/json") || json.Valid(body) { if err := json.Unmarshal(body, result); err != nil { return err } @@ -230,114 +417,111 @@ func (request *RestyClient) decodeResponse(resp *resty.Response, result interfac return nil } -// RestyGet 发送 GET 请求 -func (request *RestyClient) RestyGet(path string, result interface{}, headers map[string]string, cookies []*http.Cookie, allowRedirect bool) (*resty.Response, error) { - resp, err := request.doWithEncodingFallback(headers, cookies, allowRedirect, func(r *resty.Request) (*resty.Response, error) { - return r.Get(path) - }) - if resp == nil && err != nil { - return nil, err +func (request *RestyClient) execute(method string, path string, data any, result any, headers map[string]string, cookies []*http.Cookie, redirectCount int) (*resty.Response, error) { + client := request.newRequestClient(redirectCount) + + doRequest := func(extraHeaders map[string]string) (*resty.Response, error) { + r := request.buildReqRequest(client, path, extraHeaders, cookies) + if data != nil { + r.SetBody(data) + } + + var ( + resp *req.Response + err error + ) + switch method { + case http.MethodGet: + resp, err = r.Get(path) + case http.MethodPost: + resp, err = r.Post(path) + case http.MethodPut: + resp, err = r.Put(path) + case http.MethodPatch: + resp, err = r.Patch(path) + case http.MethodDelete: + resp, err = r.Delete(path) + case http.MethodHead: + resp, err = r.Head(path) + case http.MethodOptions: + resp, err = r.Options(path) + default: + return nil, fmt.Errorf("unsupported method: %s", method) + } + + restyResp, adaptErr := request.adaptReqResponse(path, method, data, extraHeaders, cookies, resp) + if err != nil && errors.Is(err, http.ErrUseLastResponse) { + err = nil + } + if adaptErr != nil && err == nil { + err = adaptErr + } + return restyResp, err } - if err := request.decodeResponse(resp, result); err != nil { - return nil, err + resp, err := doRequest(headers) + if err == nil && resp != nil && strings.Contains(strings.ToLower(resp.Header().Get("Content-Encoding")), "zstd") { + err = fmt.Errorf("zstd body requires identity fallback") + } + if err == nil { + if decodeErr := request.decodeResponse(resp, result); decodeErr != nil { + return nil, decodeErr + } + return resp, nil } - return resp, err + errStr := err.Error() + if strings.Contains(errStr, "gzip") || strings.Contains(errStr, "magic number mismatch") || strings.Contains(errStr, "zstd") || strings.Contains(errStr, "brotli") || strings.Contains(errStr, "flate") { + h2 := map[string]string{} + for k, v := range headers { + if strings.ToLower(k) != "accept-encoding" { + h2[k] = v + } + } + h2["Accept-Encoding"] = "identity" + + resp2, err2 := doRequest(h2) + if err2 == nil { + if decodeErr := request.decodeResponse(resp2, result); decodeErr != nil { + return nil, decodeErr + } + return resp2, nil + } + if resp2 != nil { + return resp2, err2 + } + } + + if resp != nil { + return resp, err + } + return nil, err } -// RestyPost 发送 POST 请求 -func (request *RestyClient) RestyPost(path string, data any, result interface{}, headers map[string]string, cookies []*http.Cookie, allowRedirect bool) (*resty.Response, error) { - resp, err := request.doWithEncodingFallback(headers, cookies, allowRedirect, func(r *resty.Request) (*resty.Response, error) { - return r.SetBody(data).Post(path) - }) - if resp == nil && err != nil { - return nil, err - } - - if err := request.decodeResponse(resp, result); err != nil { - return nil, err - } - - return resp, err +func (request *RestyClient) RestyGet(path string, result any, headers map[string]string, cookies []*http.Cookie, redirectCount int) (*resty.Response, error) { + return request.execute(http.MethodGet, path, nil, result, headers, cookies, redirectCount) } -// RestyPut 发送 PUT 请求 -// 功能:发送 PUT,支持请求级 headers 覆盖客户端默认,自动识别 gzip 并解析 JSON -// 返回:响应对象与错误信息 -func (request *RestyClient) RestyPut(path string, data any, result interface{}, headers map[string]string, cookies []*http.Cookie, allowRedirect bool) (*resty.Response, error) { - resp, err := request.doWithEncodingFallback(headers, cookies, allowRedirect, func(r *resty.Request) (*resty.Response, error) { - return r.SetBody(data).Put(path) - }) - if resp == nil && err != nil { - return nil, err - } - - if err := request.decodeResponse(resp, result); err != nil { - return nil, err - } - - return resp, err +func (request *RestyClient) RestyPost(path string, data any, result any, headers map[string]string, cookies []*http.Cookie, redirectCount int) (*resty.Response, error) { + return request.execute(http.MethodPost, path, data, result, headers, cookies, redirectCount) } -// RestyPatch 发送 PATCH 请求 -// 功能:发送 PATCH,支持请求级 headers 覆盖客户端默认,自动识别 gzip 并解析 JSON -// 返回:响应对象与错误信息 -func (request *RestyClient) RestyPatch(path string, data any, result interface{}, headers map[string]string, cookies []*http.Cookie, allowRedirect bool) (*resty.Response, error) { - resp, err := request.doWithEncodingFallback(headers, cookies, allowRedirect, func(r *resty.Request) (*resty.Response, error) { - return r.SetBody(data).Patch(path) - }) - if resp == nil && err != nil { - return nil, err - } - - if err := request.decodeResponse(resp, result); err != nil { - return nil, err - } - - return resp, err +func (request *RestyClient) RestyPut(path string, data any, result any, headers map[string]string, cookies []*http.Cookie, redirectCount int) (*resty.Response, error) { + return request.execute(http.MethodPut, path, data, result, headers, cookies, redirectCount) } -// RestyDelete 发送 DELETE 请求 -// 功能:发送 DELETE,支持请求级 headers 覆盖客户端默认,自动识别 gzip 并解析 JSON -// 返回:响应对象与错误信息 -func (request *RestyClient) RestyDelete(path string, result interface{}, headers map[string]string, cookies []*http.Cookie, allowRedirect bool) (*resty.Response, error) { - resp, err := request.doWithEncodingFallback(headers, cookies, allowRedirect, func(r *resty.Request) (*resty.Response, error) { - return r.Delete(path) - }) - if resp == nil && err != nil { - return nil, err - } - - if err := request.decodeResponse(resp, result); err != nil { - return nil, err - } - - return resp, err +func (request *RestyClient) RestyPatch(path string, data any, result any, headers map[string]string, cookies []*http.Cookie, redirectCount int) (*resty.Response, error) { + return request.execute(http.MethodPatch, path, data, result, headers, cookies, redirectCount) } -// RestyHead 发送 HEAD 请求 -// 功能:发送 HEAD,支持请求级 headers 覆盖客户端默认;HEAD 通常无正文 -// 返回:响应对象与错误信息 -func (request *RestyClient) RestyHead(path string, headers map[string]string, cookies []*http.Cookie, allowRedirect bool) (*resty.Response, error) { - resp, err := request.doWithEncodingFallback(headers, cookies, allowRedirect, func(r *resty.Request) (*resty.Response, error) { - return r.Head(path) - }) - if resp == nil && err != nil { - return nil, err - } - return resp, err +func (request *RestyClient) RestyDelete(path string, result any, headers map[string]string, cookies []*http.Cookie, redirectCount int) (*resty.Response, error) { + return request.execute(http.MethodDelete, path, nil, result, headers, cookies, redirectCount) } -// RestyOptions 发送 OPTIONS 请求 -// 功能:发送 OPTIONS,支持请求级 headers 覆盖客户端默认 -// 返回:响应对象与错误信息 -func (request *RestyClient) RestyOptions(path string, headers map[string]string, cookies []*http.Cookie, allowRedirect bool) (*resty.Response, error) { - resp, err := request.doWithEncodingFallback(headers, cookies, allowRedirect, func(r *resty.Request) (*resty.Response, error) { - return r.Options(path) - }) - if resp == nil && err != nil { - return nil, err - } - return resp, err +func (request *RestyClient) RestyHead(path string, headers map[string]string, cookies []*http.Cookie, redirectCount int) (*resty.Response, error) { + return request.execute(http.MethodHead, path, nil, nil, headers, cookies, redirectCount) +} + +func (request *RestyClient) RestyOptions(path string, headers map[string]string, cookies []*http.Cookie, redirectCount int) (*resty.Response, error) { + return request.execute(http.MethodOptions, path, nil, nil, headers, cookies, redirectCount) } diff --git a/utils/path.go b/utils/path.go index 9460310..2c0bfc2 100644 --- a/utils/path.go +++ b/utils/path.go @@ -62,3 +62,24 @@ func GetRootDir() string { return baseDir } + +// DisplayPath 返回适合日志展示的路径。 +// 对项目根目录内的文件保留相对路径;其他路径退化为文件名,避免泄露绝对安装目录。 +func DisplayPath(path string) string { + if path == "" { + return "" + } + + cleanPath := filepath.Clean(path) + if !filepath.IsAbs(cleanPath) { + return cleanPath + } + + rootDir := filepath.Clean(GetRootDir()) + rel, err := filepath.Rel(rootDir, cleanPath) + if err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return rel + } + + return filepath.Base(cleanPath) +}