增加 自定义导航栏模块

This commit is contained in:
2026-04-17 03:12:28 +08:00
parent 792f547dc3
commit ec508e6a32
18 changed files with 1274 additions and 653 deletions

View File

@@ -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)

View File

@@ -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)
}
logDir := filepath.Dir(path)
if normalizedPath, err := filepath.Abs(absPath); err == nil {
absPath = normalizedPath
}
// 确保日志目录存在
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"),

View File

@@ -82,6 +82,9 @@ func runServer(cmd *cobra.Command, args []string) {
if err := database.SeedDefaultSettings(); err != nil {
logrus.WithError(err).Fatal("默认系统设置初始化失败")
}
if err := database.SeedDefaultPortalNavigation(); err != nil {
logrus.WithError(err).Fatal("默认门户导航初始化失败")
}
// 初始化加密管理器
// 从数据库设置中获取加密密钥

View File

@@ -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("使用配置文件")

View File

@@ -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)
}

View File

@@ -244,7 +244,7 @@ func SettingsPublicHandler(c *gin.Context) {
var list []models.Settings
// 查询公开的基本信息、维护模式和所有前端平台配置
if err := db.Where("name IN ? OR name LIKE ?", []string{"site_title", "site_description", "site_keywords", "site_logo", "contact_email", "maintenance_mode", "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
}

View File

@@ -98,6 +98,7 @@ func InstallSubmitHandler(c *gin.Context) {
// 初始化系统默认设置
database.SeedDefaultSettings()
database.SeedDefaultPortalNavigation()
// 3. 生成新的管理员密码哈希和盐值
adminSalt, err := utils.GenerateRandomSalt()

View File

@@ -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
}

View File

@@ -16,6 +16,7 @@ func AutoMigrate() error {
}
if err := db.AutoMigrate(
&models.Settings{},
&models.PortalNavigation{},
&models.OperationLog{},
&models.LoginLog{},
&models.User{},

View File

@@ -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
}

View File

@@ -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
}

23
go.mod
View File

@@ -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

54
go.sum
View File

@@ -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=

View File

@@ -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:更新时间"`
}

View File

@@ -17,6 +17,7 @@ func RegisterAdminRoutes(rg *gin.RouterGroup) {
// 公开设置API
admin.GET("/settings/public", adminctl.SettingsPublicHandler)
admin.GET("/portal-navigation/public", adminctl.PortalNavigationPublicListHandler)
// 退出登录
admin.POST("/logout", adminctl.LogoutHandler)
@@ -39,13 +40,17 @@ func RegisterAdminRoutes(rg *gin.RouterGroup) {
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.GET("/logs", adminctl.LogsListHandler) // 获取操作日志列表
authorized.POST("/logs/clear", adminctl.LogsClearHandler) // 清空操作日志
// 登录日志API
authorized.GET("/login_logs", adminctl.LoginLogsListHandler) // 获取登录日志列表
authorized.GET("/login_logs", adminctl.LoginLogsListHandler) // 获取登录日志列表
authorized.POST("/login_logs/clear", adminctl.LoginLogsClearHandler) // 清空登录日志
// 子账号相关API (Mock)

View File

@@ -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
}
})
}

View File

@@ -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 不为空则设置默认 BaseURLproxyStr 不为空则启用 HTTP 代理(仅 HTTP/1.1
// persistCookies 启用持久化 CookiefollowRedirect 启用重定向跟随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)
}

View File

@@ -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)
}