b 4 miesięcy temu
rodzic
commit
f7e8d5dd13
53 zmienionych plików z 1935 dodań i 0 usunięć
  1. 164 0
      src/gin/jwtx/InitGroup.go
  2. 44 0
      src/gin/jwtx/Singleton.go
  3. 0 0
      src/gin/jwtx/example/demo.yaml
  4. 0 0
      src/gin/jwtx/test/Tool_test.go
  5. 0 0
      src/gin/jwtx/tool/GeneratePrivateKey.go
  6. 0 0
      src/gin/jwtx/tool/GenerateToken.go
  7. 0 0
      src/gin/jwtx/tool/GetPrivateKey.go
  8. 0 0
      src/gin/jwtx/tool/ParseToken.go
  9. 0 0
      src/gin/jwtx2/CurrentAuth.go
  10. 0 0
      src/gin/jwtx2/InitGroup.go
  11. 0 0
      src/gin/jwtx2/README.md
  12. 0 0
      src/gin/jwtx2/Singleton.go
  13. 0 0
      src/gin/jwtx2/db/Makefile
  14. 0 0
      src/gin/jwtx2/db/dao/model/jwtx_tokens.gen.go
  15. 0 0
      src/gin/jwtx2/db/dao/query/gen.go
  16. 0 0
      src/gin/jwtx2/db/dao/query/jwtx_tokens.gen.go
  17. 0 0
      src/gin/jwtx2/db/gen.yml
  18. 0 0
      src/gin/jwtx2/db/mysql/jwtx_tokens.sql
  19. 24 0
      src/gin/jwtx2/example/demo.yaml
  20. 0 0
      src/gin/jwtx2/s.Login.go
  21. 0 0
      src/gin/jwtx2/s.Logout.go
  22. 0 0
      src/gin/jwtx2/s.Middleware.go
  23. 0 0
      src/gin/jwtx2/test/GenerateKey_test.go
  24. 0 0
      src/gin/jwtx2/test/GenerateToken_test.go
  25. 0 0
      src/gin/jwtx2/test/jwtx/admin.key
  26. 0 0
      src/gin/jwtx2/test/jwtx/admin.yaml
  27. 0 0
      src/gin/jwtx2/tool/GenerateToken.go
  28. 0 0
      src/gin/jwtx2/tool/GetPrivateKey.go
  29. 0 0
      src/gin/jwtx2/tool/ParseToken.go
  30. 0 0
      src/gin/jwtx2/tool/const.go
  31. 120 0
      src/gorm/mysqlx/DefaultConfig.go
  32. 44 0
      src/gorm/mysqlx/New.go
  33. 85 0
      src/gorm/mysqlx/NewWithConfig.go
  34. 39 0
      src/logx/Debug.go
  35. 39 0
      src/logx/Error.go
  36. 39 0
      src/logx/Gin.go
  37. 46 0
      src/logx/Gorm.go
  38. 39 0
      src/logx/Info.go
  39. 62 0
      src/logx/Nano.go
  40. 27 0
      src/logx/main.DefaultWriter.go
  41. 40 0
      src/logx/main.InitDefault.go
  42. 39 0
      src/logx/main.NewWriter.go
  43. 13 0
      src/logx/main.SetLocation.go
  44. 24 0
      src/logx/tool/FileRollingWriter.go
  45. 19 0
      src/logx/tool/WrapAsyncWriter.go
  46. 23 0
      src/logx/tool/ZerologFormatTimestamp.go
  47. 76 0
      src/utilx/CreateFileIfNotExists.go
  48. 59 0
      src/utilx/CreateFileIfNotExistsWithBytes.go
  49. 49 0
      src/utilx/FixMySqlDatetime.go
  50. 189 0
      src/utilx/GetRequestIP.go
  51. 191 0
      src/utilx/Md5.go
  52. 245 0
      src/utilx/Time.go
  53. 196 0
      src/utilx/test/Time_test.go

+ 164 - 0
src/gin/jwtx/InitGroup.go

@@ -0,0 +1,164 @@
+package jwtx
+
+import (
+	"crypto/ed25519"
+	"crypto/rand"
+	"encoding/pem"
+	"time"
+
+	"git.ooo.ink/root/go-kit/gin/jwtx/db/dao/model"
+	"git.ooo.ink/root/go-kit/gin/jwtx/tool"
+	"git.ooo.ink/root/go-kit/src/gorm/mysqlx"
+	"git.ooo.ink/root/go-kit/src/utilx"
+	"github.com/5-say/go-tool/logx"
+	"github.com/jinzhu/configor"
+	"gorm.io/gorm"
+	"gorm.io/gorm/logger"
+
+	_ "embed"
+)
+
+// 初始化分组(支持多次调用)
+//
+//	group          string 分组名称
+//	configPath     string 分组配置文件路径
+//	privateKeyPath string 分组私钥文件路径
+//
+// e.g.
+//
+//	jwtx.InitGroup("admin", "jwtx/config/admin.yaml", "jwtx/config/admin.key")
+//	jwtx.InitGroup("user",  "jwtx/config/user.yaml",  "jwtx/config/user.key")
+func InitGroup(group, configPath, privateKeyPath string) *SingletonT {
+	// 配置文件不存在则创建
+	createConfigFileIfNotExist(configPath)
+
+	// 私钥文件不存在则创建
+	createKeyFileIfNotExist(privateKeyPath)
+
+	// 取得单例
+	if Singleton == nil {
+		Singleton = &SingletonT{}
+		Singleton.DB = make(map[string]*gorm.DB)
+		Singleton.Config = make(map[string]GroupConfig)
+		Singleton.PrivateKey = make(map[string]ed25519.PrivateKey)
+	}
+
+	// 引用简化
+	var s = Singleton
+
+	// 初始化分组配置,每秒自动更新
+	var config GroupConfig
+	err := configor.New(&configor.Config{AutoReload: true}).Load(&config, configPath)
+	if err != nil {
+		panic(err)
+	}
+	s.Config[group] = config
+
+	// 初始化分组数据库连接
+	s.DB[group], err = mysqlx.New(config.MysqlDSN, logx.GormLogger(logger.Config{
+		SlowThreshold:             200 * time.Millisecond,
+		IgnoreRecordNotFoundError: false,
+		ParameterizedQueries:      false,
+		LogLevel:                  logger.Warn,
+	}))
+	if err != nil {
+		panic(err)
+	}
+
+	// 初始化数据库结构
+	s.DB[group].AutoMigrate(&model.JwtxToken{})
+
+	// 初始化分组私钥
+	privateKey, err := tool.GetPrivateKey(privateKeyPath)
+	if err != nil {
+		panic(err)
+	}
+	s.PrivateKey[group] = privateKey
+
+	return s
+}
+
+//go:embed example/demo.yaml
+var demoConfig string
+
+// createConfigFileIfNotExist 创建配置文件(如果不存在)
+//
+// 参数:
+//   - filePath: 配置文件路径
+//
+// 功能说明:
+//   - 检查指定路径的配置文件是否存在
+//   - 如果文件不存在,则创建配置文件并使用示例配置内容填充
+//   - 使用嵌入的示例配置文件 (demo.yaml) 作为默认内容
+//   - 文件权限设置为 0755(目录)和 0644(文件)
+//
+// 使用场景:
+//   - 在 JWT 分组初始化时自动创建默认配置文件
+//   - 确保应用启动时必要的配置文件存在
+func createConfigFileIfNotExist(filePath string) {
+	utilx.CreateFileIfNotExists(filePath, 0755, 0644, demoConfig)
+}
+
+// createKeyFileIfNotExist 创建私钥文件(如果不存在)
+//
+// 参数:
+//   - filePath: 私钥文件路径
+//
+// 功能说明:
+//   - 检查指定路径的私钥文件是否存在
+//   - 如果文件不存在,则生成新的 ED25519 密钥对并创建私钥文件
+//   - 使用 PEM 格式编码私钥,便于存储和传输
+//   - 在文件内容中添加密钥格式说明,便于识别
+//
+// 密钥生成流程:
+//  1. 使用 crypto/rand 生成安全的随机数
+//  2. 调用 ed25519.GenerateKey 生成 ED25519 密钥对
+//  3. 将私钥编码为 PEM 格式(PRIVATE KEY 类型)
+//  4. 构建包含格式说明和 PEM 编码私钥的文件内容
+//  5. 使用 utilx.CreateFileIfNotExists 创建文件
+//
+// 错误处理:
+//   - 如果密钥生成失败,创建空文件并返回
+//   - 文件权限设置为 0755(目录)和 0644(文件)
+//
+// 使用场景:
+//   - 在 JWT 分组初始化时自动创建私钥文件
+//   - 确保应用启动时必要的私钥文件存在
+//   - 支持 ED25519 算法的 JWT 签名和验证
+//
+// 文件格式示例:
+//
+//	\n
+//	密钥格式: ED25519
+//	\n\n
+//	-----BEGIN PRIVATE KEY-----
+//	MC4CAQAwBQYDK2VwBCIEILTLm0ocOFC3W1pQn6nUz1iJf4Jx2z6Y1T6X8Kj7
+//	-----END PRIVATE KEY-----
+//	\n
+//	密钥格式: ED25519
+//	\n
+func createKeyFileIfNotExist(filePath string) {
+	// 生成ed25519密钥对
+	_, privateKey, err := ed25519.GenerateKey(rand.Reader)
+	if err != nil {
+		// 如果生成失败,使用空内容创建文件
+		utilx.CreateFileIfNotExists(filePath, 0755, 0644, "")
+		return
+	}
+
+	// 将私钥编码为PEM格式
+	privateKeyPEMBytes := pem.EncodeToMemory(&pem.Block{
+		Type:  "PRIVATE KEY",
+		Bytes: privateKey,
+	})
+
+	content := "\n"
+	content += "密钥格式: ED25519"
+	content += "\n\n"
+	content += string(privateKeyPEMBytes)
+	content += "\n"
+	content += "密钥格式: ED25519"
+	content += "\n"
+
+	utilx.CreateFileIfNotExists(filePath, 0755, 0644, content)
+}

+ 44 - 0
src/gin/jwtx/Singleton.go

@@ -0,0 +1,44 @@
+package jwtx
+
+import (
+	"crypto/ed25519"
+
+	"gorm.io/gorm"
+)
+
+// 单例
+var Singleton *SingletonT
+
+// SingletonT ..
+type SingletonT struct {
+	DB         map[string]*gorm.DB
+	Config     map[string]GroupConfig
+	PrivateKey map[string]ed25519.PrivateKey
+}
+
+// 分组配置
+type GroupConfig struct {
+	// consult[https://gorm.io/docs/connecting_to_the_database.html]"
+	MysqlDSN string `yaml:"MysqlDSN"`
+
+	// 是否开启 IP一致性校验,当前 IP 必须与登录时 IP 一致
+	CheckIP bool `yaml:"CheckIP"`
+	// 是否开启 单端登录
+	SingleEnd bool `yaml:"SingleEnd"`
+
+	// 刷新时间间隔(秒)
+	//
+	// 超过该时间即可触发 token 刷新、旧 token 进入容错,间隔越小安全性越高
+	RefreshInterval int64 `yaml:"RefreshInterval"`
+	// 并发容错时间(秒)
+	FaultTolerance int64 `yaml:"FaultTolerance"`
+
+	// 是否开启 自动续期
+	//
+	// 假设:单次登录有效期(7天)
+	// true  只要 7天 内有操作触发刷新,登录状态就一直延续
+	// false 不论 7天 内是否操作,7天 后必定要求重新登录
+	AutomaticRenewal bool `yaml:"AutomaticRenewal"`
+	// 单次登录有效期(小时)
+	AccessExpireByHour int64 `yaml:"AccessExpireByHour"`
+}

+ 0 - 0
gin/jwtx2/example/demo.yaml → src/gin/jwtx/example/demo.yaml


+ 0 - 0
gin/jwtx/test/Tool_test.go → src/gin/jwtx/test/Tool_test.go


+ 0 - 0
gin/jwtx/tool/GeneratePrivateKey.go → src/gin/jwtx/tool/GeneratePrivateKey.go


+ 0 - 0
gin/jwtx/tool/GenerateToken.go → src/gin/jwtx/tool/GenerateToken.go


+ 0 - 0
gin/jwtx/tool/GetPrivateKey.go → src/gin/jwtx/tool/GetPrivateKey.go


+ 0 - 0
gin/jwtx/tool/ParseToken.go → src/gin/jwtx/tool/ParseToken.go


+ 0 - 0
gin/jwtx2/CurrentAuth.go → src/gin/jwtx2/CurrentAuth.go


+ 0 - 0
gin/jwtx2/InitGroup.go → src/gin/jwtx2/InitGroup.go


+ 0 - 0
gin/jwtx2/README.md → src/gin/jwtx2/README.md


+ 0 - 0
gin/jwtx2/Singleton.go → src/gin/jwtx2/Singleton.go


+ 0 - 0
gin/jwtx2/db/Makefile → src/gin/jwtx2/db/Makefile


+ 0 - 0
gin/jwtx2/db/dao/model/jwtx_tokens.gen.go → src/gin/jwtx2/db/dao/model/jwtx_tokens.gen.go


+ 0 - 0
gin/jwtx2/db/dao/query/gen.go → src/gin/jwtx2/db/dao/query/gen.go


+ 0 - 0
gin/jwtx2/db/dao/query/jwtx_tokens.gen.go → src/gin/jwtx2/db/dao/query/jwtx_tokens.gen.go


+ 0 - 0
gin/jwtx2/db/gen.yml → src/gin/jwtx2/db/gen.yml


+ 0 - 0
gin/jwtx2/db/mysql/jwtx_tokens.sql → src/gin/jwtx2/db/mysql/jwtx_tokens.sql


+ 24 - 0
src/gin/jwtx2/example/demo.yaml

@@ -0,0 +1,24 @@
+
+# consult[https://gorm.io/docs/connecting_to_the_database.html]"
+MysqlDSN : "root:root@tcp(mysql:3306)/demo?charset=utf8mb4&parseTime=True&loc=Local"
+
+# 是否开启 IP一致性校验,当前 IP 必须与登录时 IP 一致
+CheckIP: true
+# 是否开启 单端登录
+SingleEnd: true
+
+# 刷新时间间隔(秒)
+#
+# 超过该时间即可触发 token 刷新、旧 token 进入容错,间隔越小安全性越高
+RefreshInterval: 300
+# 并发容错时间(秒)
+FaultTolerance: 3
+
+# 是否开启 自动续期
+#
+# 假设:单次登录有效期(7天)
+# true  只要 7天 内有操作触发刷新,登录状态就一直延续
+# false 不论 7天 内是否操作,7天 后必定要求重新登录
+AutomaticRenewal: true
+# 单次登录有效期(小时)
+AccessExpireByHour: 8

+ 0 - 0
gin/jwtx2/s.Login.go → src/gin/jwtx2/s.Login.go


+ 0 - 0
gin/jwtx2/s.Logout.go → src/gin/jwtx2/s.Logout.go


+ 0 - 0
gin/jwtx2/s.Middleware.go → src/gin/jwtx2/s.Middleware.go


+ 0 - 0
gin/jwtx2/test/GenerateKey_test.go → src/gin/jwtx2/test/GenerateKey_test.go


+ 0 - 0
gin/jwtx2/test/GenerateToken_test.go → src/gin/jwtx2/test/GenerateToken_test.go


+ 0 - 0
gin/jwtx2/test/jwtx/admin.key → src/gin/jwtx2/test/jwtx/admin.key


+ 0 - 0
gin/jwtx2/test/jwtx/admin.yaml → src/gin/jwtx2/test/jwtx/admin.yaml


+ 0 - 0
gin/jwtx2/tool/GenerateToken.go → src/gin/jwtx2/tool/GenerateToken.go


+ 0 - 0
gin/jwtx2/tool/GetPrivateKey.go → src/gin/jwtx2/tool/GetPrivateKey.go


+ 0 - 0
gin/jwtx2/tool/ParseToken.go → src/gin/jwtx2/tool/ParseToken.go


+ 0 - 0
gin/jwtx2/tool/const.go → src/gin/jwtx2/tool/const.go


+ 120 - 0
src/gorm/mysqlx/DefaultConfig.go

@@ -0,0 +1,120 @@
+package mysqlx
+
+import (
+	"time"
+
+	"gorm.io/gorm/logger"
+)
+
+// DBConfig 数据库连接配置结构体
+//
+// 该结构体提供了完整的数据库连接配置选项,支持生产环境下的高性能和稳定性要求
+//
+// 字段说明:
+//   - DSN: 数据库连接字符串 (Data Source Name)
+//   - Logger: GORM 日志记录器接口
+//   - LogLevel: 日志级别,控制日志输出的详细程度
+//   - SlowThreshold: 慢查询阈值,超过此时间的查询会被记录为慢查询
+//   - MaxOpenConns: 数据库最大打开连接数,影响并发性能
+//   - MaxIdleConns: 数据库最大空闲连接数,影响连接复用效率
+//   - ConnMaxLifetime: 连接最大生命周期,避免长时间占用连接
+//   - DefaultStringSize: 字符串类型字段的默认长度
+//   - DisableDatetimePrecision: 是否禁用 datetime 精度,用于兼容旧版本 MySQL
+//   - DontSupportRenameIndex: 是否不支持重命名索引,用于兼容旧版本数据库
+//   - DontSupportRenameColumn: 是否不支持重命名列,用于兼容旧版本数据库
+//   - SkipInitializeWithVersion: 是否跳过根据 MySQL 版本自动配置
+//   - DisableForeignKeyConstraintWhenMigrating: 迁移时是否禁用外键约束
+//   - SkipDefaultTransaction: 是否跳过默认事务
+//
+// 使用示例:
+//
+//	config := mysqlx.DefaultConfig()
+//	config.DSN = "user:pass@tcp(localhost:3306)/db"
+//	config.LogLevel = logger.Warn
+//	db, err := mysqlx.NewWithConfig(config)
+type DBConfig struct {
+	DSN                                      string
+	Logger                                   logger.Interface
+	LogLevel                                 logger.LogLevel
+	SlowThreshold                            time.Duration
+	MaxOpenConns                             int
+	MaxIdleConns                             int
+	ConnMaxLifetime                          time.Duration
+	DefaultStringSize                        uint
+	DisableDatetimePrecision                 bool
+	DontSupportRenameIndex                   bool
+	DontSupportRenameColumn                  bool
+	SkipInitializeWithVersion                bool
+	DisableForeignKeyConstraintWhenMigrating bool
+	SkipDefaultTransaction                   bool
+}
+
+// DefaultConfig 返回默认的数据库配置
+//
+// 功能说明:
+//
+//	此函数返回一个预配置的 DBConfig 对象,包含生产环境推荐的默认值。
+//	这些默认值经过优化,适合大多数应用场景,同时考虑了性能、稳定性和兼容性。
+//
+// 返回值:
+//   - DBConfig: 包含生产环境推荐配置的默认配置对象
+//
+// 默认配置详解:
+//   - LogLevel: logger.Warn - 仅记录警告和错误级别的日志,避免过多日志输出影响性能
+//   - SlowThreshold: 200ms - 慢查询阈值,执行时间超过200毫秒的查询会被记录为慢查询
+//   - MaxOpenConns: 100 - 数据库最大打开连接数,适合中等负载的Web应用
+//   - MaxIdleConns: 10 - 数据库最大空闲连接数,平衡连接复用效率和资源占用
+//   - ConnMaxLifetime: 1小时 - 连接最大生命周期,防止连接长时间占用导致资源浪费
+//   - DefaultStringSize: 256 - 字符串类型字段的默认长度,适合大多数业务场景
+//   - DisableDatetimePrecision: true - 禁用datetime精度,兼容MySQL 5.6以下版本
+//   - DontSupportRenameIndex: true - 不支持重命名索引,采用删除重建方式,兼容旧版本数据库
+//   - DontSupportRenameColumn: true - 不支持重命名列,使用change方式,兼容MySQL 8以下版本
+//   - SkipInitializeWithVersion: false - 启用根据MySQL版本自动配置功能
+//   - DisableForeignKeyConstraintWhenMigrating: true - 迁移时禁用外键约束,提高迁移性能
+//   - SkipDefaultTransaction: true - 跳过默认事务,提升简单数据库操作的性能
+//
+// 设计原则:
+//  1. 性能优先 - 默认配置针对性能进行了优化
+//  2. 生产就绪 - 配置参数适合生产环境使用
+//  3. 向下兼容 - 考虑了旧版本MySQL和MariaDB的兼容性
+//  4. 资源合理 - 连接池参数平衡了性能和资源消耗
+//
+// 使用场景:
+//   - 快速启动项目,无需关心详细的数据库配置
+//   - 作为配置模板,可根据具体需求进行微调
+//   - 生产环境的标准配置基准
+//
+// 使用示例:
+//
+//	// 基本使用
+//	config := mysqlx.DefaultConfig()
+//	config.DSN = "user:pass@tcp(localhost:3306)/db"
+//	db, err := mysqlx.NewWithConfig(config)
+//
+//	// 自定义配置(基于默认配置调整)
+//	config := mysqlx.DefaultConfig()
+//	config.DSN = "user:pass@tcp(localhost:3306)/db"
+//	config.MaxOpenConns = 200 // 提高并发连接数
+//	config.LogLevel = logger.Error // 仅记录错误日志
+//	db, err := mysqlx.NewWithConfig(config)
+//
+// 注意事项:
+//   - 默认配置适用于大多数场景,但应根据具体业务需求进行调整
+//   - 高并发场景可能需要调整连接池参数
+//   - 调试阶段可降低日志级别以获得更详细的信息
+func DefaultConfig() DBConfig {
+	return DBConfig{
+		LogLevel:                                 logger.Warn,
+		SlowThreshold:                            200 * time.Millisecond,
+		MaxOpenConns:                             100,
+		MaxIdleConns:                             10,
+		ConnMaxLifetime:                          time.Hour,
+		DefaultStringSize:                        256,
+		DisableDatetimePrecision:                 true,
+		DontSupportRenameIndex:                   true,
+		DontSupportRenameColumn:                  true,
+		SkipInitializeWithVersion:                false,
+		DisableForeignKeyConstraintWhenMigrating: true,
+		SkipDefaultTransaction:                   true,
+	}
+}

+ 44 - 0
src/gorm/mysqlx/New.go

@@ -0,0 +1,44 @@
+package mysqlx
+
+import (
+	"gorm.io/gorm"
+	"gorm.io/gorm/logger"
+)
+
+// New 快速创建数据库连接(简化版本)
+//
+// 参数:
+//   - dsn: 数据库连接字符串 (Data Source Name)
+//   - useLogger: GORM 日志记录器接口
+//
+// 返回值:
+//   - *gorm.DB: 成功创建的数据库连接对象
+//   - error: 创建过程中出现的错误
+//
+// 功能说明:
+//   - 使用默认配置快速创建数据库连接
+//   - 保持向后兼容性,简化使用方式
+//   - 内部调用 NewWithConfig 方法,包含完整的错误处理和连接池配置
+//
+// 使用示例:
+//
+//	// 禁用日志
+//	db, err := mysqlx.New("user:pass@tcp(localhost:3306)/db", logger.Discard)
+//
+//	// 仅记录错误日志
+//	db, err := mysqlx.New("user:pass@tcp(localhost:3306)/db", logger.Default.LogMode(logger.Error))
+//
+//	// 自定义日志输出
+//	customLogger := logger.New(log.New(os.Stdout, "\r\n", log.LstdFlags), logger.Config{})
+//	db, err := mysqlx.New("user:pass@tcp(localhost:3306)/db", customLogger)
+//
+// 注意:
+//   - 此方法使用生产环境推荐的默认配置
+//   - 如需更精细的控制,请使用 NewWithConfig 方法
+func New(dsn string, useLogger logger.Interface) (*gorm.DB, error) {
+	config := DefaultConfig()
+	config.DSN = dsn
+	config.Logger = useLogger
+
+	return NewWithConfig(config)
+}

+ 85 - 0
src/gorm/mysqlx/NewWithConfig.go

@@ -0,0 +1,85 @@
+package mysqlx
+
+import (
+	"fmt"
+
+	"gorm.io/driver/mysql"
+	"gorm.io/gorm"
+)
+
+// NewWithConfig 使用配置对象创建数据库连接
+//
+// 参数:
+//   - config: DBConfig 配置对象,包含完整的数据库连接配置
+//
+// 返回值:
+//   - *gorm.DB: 成功创建的数据库连接对象
+//   - error: 创建过程中出现的错误,包含详细的错误信息
+//
+// 功能说明:
+//  1. 根据配置创建 MySQL 驱动配置
+//  2. 创建 GORM 配置,包括日志和事务设置
+//  3. 建立数据库连接并进行健康检查
+//  4. 配置连接池参数,优化性能和稳定性
+//  5. 返回配置完成的数据库连接对象
+//
+// 错误处理:
+//   - 连接失败时返回详细的错误信息,便于问题排查
+//   - 包含连接池配置和健康检查的完整错误链
+//
+// 使用示例:
+//
+//	config := mysqlx.DefaultConfig()
+//	config.DSN = "user:pass@tcp(localhost:3306)/db"
+//	config.LogLevel = logger.Error
+//	db, err := mysqlx.NewWithConfig(config)
+//	if err != nil {
+//	    log.Fatal("数据库连接失败:", err)
+//	}
+func NewWithConfig(config DBConfig) (*gorm.DB, error) {
+	// 验证 DSN 是否为空
+	if config.DSN == "" {
+		return nil, fmt.Errorf("数据库连接字符串 (DSN) 不能为空")
+	}
+
+	// 创建 MySQL 驱动配置
+	mysqlConfig := mysql.Config{
+		DSN:                       config.DSN,
+		DefaultStringSize:         config.DefaultStringSize,
+		DisableDatetimePrecision:  config.DisableDatetimePrecision,
+		DontSupportRenameIndex:    config.DontSupportRenameIndex,
+		DontSupportRenameColumn:   config.DontSupportRenameColumn,
+		SkipInitializeWithVersion: config.SkipInitializeWithVersion,
+	}
+
+	// 创建 GORM 配置
+	gormConfig := &gorm.Config{
+		DisableForeignKeyConstraintWhenMigrating: config.DisableForeignKeyConstraintWhenMigrating,
+		SkipDefaultTransaction:                   config.SkipDefaultTransaction,
+		Logger:                                   config.Logger,
+	}
+
+	// 建立数据库连接
+	db, err := gorm.Open(mysql.New(mysqlConfig), gormConfig)
+	if err != nil {
+		return nil, fmt.Errorf("创建数据库连接失败: %w", err)
+	}
+
+	// 获取底层 SQL DB 对象以配置连接池
+	sqlDB, err := db.DB()
+	if err != nil {
+		return nil, fmt.Errorf("获取底层数据库连接失败: %w", err)
+	}
+
+	// 配置连接池参数
+	sqlDB.SetMaxOpenConns(config.MaxOpenConns)
+	sqlDB.SetMaxIdleConns(config.MaxIdleConns)
+	sqlDB.SetConnMaxLifetime(config.ConnMaxLifetime)
+
+	// 执行健康检查(ping 数据库)
+	if err := sqlDB.Ping(); err != nil {
+		return nil, fmt.Errorf("数据库健康检查失败: %w", err)
+	}
+
+	return db, nil
+}

+ 39 - 0
src/logx/Debug.go

@@ -0,0 +1,39 @@
+package logx
+
+import (
+	"io"
+	"os"
+
+	"github.com/5-say/go-tool/logx/tool"
+	"github.com/rs/zerolog"
+)
+
+// 单例
+var (
+	debugLogger *zerolog.Logger
+)
+
+// InitDebugWriter
+//
+// e.g.
+//
+//	logx.InitDebugWriter(logx.DefaultWriter("debug.log", true))
+func InitDebugWriter(writer io.Writer) {
+	l := zerolog.New(zerolog.ConsoleWriter{Out: writer, NoColor: true, FormatTimestamp: tool.ZerologFormatTimestamp(loggingLocationName)}).With().Timestamp().Caller().Logger()
+	// 存储单例
+	debugLogger = &l
+}
+
+// Debug
+//
+// e.g.
+//
+//	logx.Debug().Msg("")
+//	logx.Debug().Any("data", data).Send()
+func Debug() *zerolog.Event {
+	if debugLogger != nil {
+		return debugLogger.Debug()
+	}
+	l := zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout, NoColor: true, FormatTimestamp: tool.ZerologFormatTimestamp(loggingLocationName)}).With().Timestamp().Caller().Logger()
+	return l.Debug()
+}

+ 39 - 0
src/logx/Error.go

@@ -0,0 +1,39 @@
+package logx
+
+import (
+	"io"
+	"os"
+
+	"github.com/5-say/go-tool/logx/tool"
+	"github.com/rs/zerolog"
+)
+
+// 单例
+var (
+	errorLogger *zerolog.Logger
+)
+
+// InitErrorWriter
+//
+// e.g.
+//
+//	logx.InitErrorWriter(logx.DefaultWriter("error.log", true))
+func InitErrorWriter(writer io.Writer) {
+	l := zerolog.New(zerolog.ConsoleWriter{Out: writer, NoColor: true, FormatTimestamp: tool.ZerologFormatTimestamp(loggingLocationName)}).With().Timestamp().Caller().Logger()
+	// 存储单例
+	errorLogger = &l
+}
+
+// Error
+//
+// e.g.
+//
+//	logx.Error().Msg("")
+//	logx.Error().Any("data", data).Send()
+func Error() *zerolog.Event {
+	if errorLogger != nil {
+		return errorLogger.Error()
+	}
+	l := zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout, NoColor: false, FormatTimestamp: tool.ZerologFormatTimestamp(loggingLocationName)}).With().Timestamp().Caller().Logger()
+	return l.Error()
+}

+ 39 - 0
src/logx/Gin.go

@@ -0,0 +1,39 @@
+package logx
+
+import (
+	"io"
+	"os"
+
+	"github.com/5-say/go-tool/logx/tool"
+	"github.com/rs/zerolog"
+)
+
+// 单例
+var (
+	ginWriter io.Writer
+)
+
+// InitGinWriter
+//
+// e.g.
+//
+//	logx.InitGinWriter(logx.DefaultWriter("gin.log", true))
+func InitGinWriter(writer io.Writer) {
+	l := zerolog.New(zerolog.ConsoleWriter{Out: writer, NoColor: true, FormatTimestamp: tool.ZerologFormatTimestamp(loggingLocationName)}).With().Timestamp().Caller().Logger()
+	// 存储单例
+	ginWriter = l
+}
+
+// GinWriter
+//
+// e.g.
+//
+//	gin.DefaultWriter = logx.GinWriter()
+//	gin.DefaultErrorWriter = logx.GinWriter()
+//	r := gin.Default()
+func GinWriter() io.Writer {
+	if ginWriter != nil {
+		return ginWriter
+	}
+	return zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout, NoColor: false, FormatTimestamp: tool.ZerologFormatTimestamp(loggingLocationName)}).With().Timestamp().Caller().Logger()
+}

+ 46 - 0
src/logx/Gorm.go

@@ -0,0 +1,46 @@
+package logx
+
+import (
+	"io"
+	"os"
+
+	"github.com/5-say/go-tool/logx/tool"
+	"github.com/rs/zerolog"
+	"gorm.io/gorm/logger"
+)
+
+// 单例
+var (
+	gormWriter logger.Writer
+)
+
+// InitGormWriter
+//
+// e.g.
+//
+//	logx.InitGormWriter(logx.DefaultWriter("gorm.log", true))
+func InitGormWriter(writer io.Writer) {
+	l := zerolog.New(zerolog.ConsoleWriter{Out: writer, NoColor: true, FormatTimestamp: tool.ZerologFormatTimestamp(loggingLocationName)}).With().Timestamp().Logger()
+	// 存储单例
+	gormWriter = &l
+}
+
+// GormLogger
+//
+// e.g.
+//
+//	logx.GormLogger(logger.Config{
+//		SlowThreshold:             200 * time.Millisecond,
+//		IgnoreRecordNotFoundError: false,
+//		ParameterizedQueries:      false,
+//		LogLevel:                  logger.Warn,
+//	})
+func GormLogger(config logger.Config) logger.Interface {
+	if gormWriter != nil {
+		config.Colorful = false
+		return logger.New(gormWriter, config)
+	}
+	l := zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout, NoColor: false, FormatTimestamp: tool.ZerologFormatTimestamp(loggingLocationName)}).With().Timestamp().Logger()
+	config.Colorful = true
+	return logger.New(&l, config)
+}

+ 39 - 0
src/logx/Info.go

@@ -0,0 +1,39 @@
+package logx
+
+import (
+	"io"
+	"os"
+
+	"github.com/5-say/go-tool/logx/tool"
+	"github.com/rs/zerolog"
+)
+
+// 单例
+var (
+	infoLogger *zerolog.Logger
+)
+
+// InitInfoWriter
+//
+// e.g.
+//
+//	logx.InitInfoWriter(logx.DefaultWriter("info.log", true))
+func InitInfoWriter(writer io.Writer) {
+	l := zerolog.New(zerolog.ConsoleWriter{Out: writer, NoColor: true, FormatTimestamp: tool.ZerologFormatTimestamp(loggingLocationName)}).With().Timestamp().Caller().Logger()
+	// 存储单例
+	infoLogger = &l
+}
+
+// Info
+//
+// e.g.
+//
+//	logx.Info().Msg("")
+//	logx.Info().Any("data", data).Send()
+func Info() *zerolog.Event {
+	if infoLogger != nil {
+		return infoLogger.Info()
+	}
+	l := zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout, NoColor: false, FormatTimestamp: tool.ZerologFormatTimestamp(loggingLocationName)}).With().Timestamp().Caller().Logger()
+	return l.Info()
+}

+ 62 - 0
src/logx/Nano.go

@@ -0,0 +1,62 @@
+package logx
+
+import (
+	"fmt"
+	"io"
+	"os"
+
+	"github.com/5-say/go-tool/logx/tool"
+	"github.com/rs/zerolog"
+)
+
+type nanoLogger struct{}
+
+func (nanoLogger) Println(v ...interface{}) {
+	if e := nanoWriter.Info(); e.Enabled() {
+		e.CallerSkipFrame(1).Msg(fmt.Sprint(v...))
+	}
+}
+
+func (nanoLogger) Fatal(v ...interface{}) {
+	nanoWriter.Fatal().Msg(fmt.Sprint(v...))
+}
+
+func (nanoLogger) Fatalf(format string, v ...interface{}) {
+	nanoWriter.Fatal().Msgf(format, v...)
+}
+
+// 单例
+var (
+	nanoWriter *zerolog.Logger
+)
+
+// InitNanoWriter
+//
+// e.g.
+//
+//	logx.InitNanoWriter(logx.DefaultWriter("nano.log", true))
+func InitNanoWriter(writer io.Writer) {
+	l := zerolog.New(zerolog.ConsoleWriter{
+		Out: writer, NoColor: true,
+		FormatTimestamp: tool.ZerologFormatTimestamp(loggingLocationName),
+	}).With().Timestamp().Caller().CallerWithSkipFrameCount(3).Logger()
+	// 存储单例
+	nanoWriter = &l
+}
+
+// NanoLogger
+//
+// e.g.
+//
+//	nano.WithLogger(logx.NanoLogger())
+func NanoLogger() nanoLogger {
+	if nanoWriter == nil {
+		l := zerolog.New(zerolog.ConsoleWriter{
+			Out: os.Stdout, NoColor: false,
+			FormatTimestamp: tool.ZerologFormatTimestamp(loggingLocationName),
+		}).With().Timestamp().Caller().CallerWithSkipFrameCount(3).Logger()
+		// 存储单例
+		nanoWriter = &l
+	}
+	return nanoLogger{}
+}

+ 27 - 0
src/logx/main.DefaultWriter.go

@@ -0,0 +1,27 @@
+package logx
+
+import "io"
+
+// DefaultWriter ..
+//
+// e.g.
+//
+//	logx.DefaultWriter("logs/demo.log", true)
+func DefaultWriter(fileName string, useAsync bool) io.Writer {
+	config := WriterConfig{
+		Rolling: WriterRollingConfig{
+			MaxMegabytes: 1,
+			MaxDays:      10,
+			MaxBackups:   10,
+			Compress:     false,
+		},
+	}
+	if useAsync {
+		config.Async = &WriterAsyncConfig{
+			BufferSize:   1000,
+			PollInterval: 0,
+			Alerter:      nil,
+		}
+	}
+	return NewWriter(fileName, config)
+}

+ 40 - 0
src/logx/main.InitDefault.go

@@ -0,0 +1,40 @@
+package logx
+
+import (
+	"io"
+
+	"github.com/rs/zerolog"
+	"github.com/rs/zerolog/pkgerrors"
+)
+
+// 初始化日志,使用默认配置快速初始化
+//
+// e.g.
+//
+//	logx.InitDefault("logs/app", "Asia/Shanghai", useAsync, "info", "error", "debug", "gorm", "gin")
+//	logx.InitDefault("logs/app", "Asia/Shanghai", useAsync, "info", "error", "debug", "gorm", "nano")
+func InitDefault(filePath, locationName string, useAsync bool, tags ...string) {
+
+	zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
+
+	loggingLocationName = locationName
+	w := func(tag string) io.Writer {
+		return DefaultWriter(filePath+"."+tag+".log", useAsync)
+	}
+	for _, tag := range tags {
+		switch tag {
+		case "info":
+			InitInfoWriter(w(tag))
+		case "error":
+			InitErrorWriter(w(tag))
+		case "debug":
+			InitDebugWriter(w(tag))
+		case "gin":
+			InitGinWriter(w(tag))
+		case "gorm":
+			InitGormWriter(w(tag))
+		case "nano":
+			InitNanoWriter(w(tag))
+		}
+	}
+}

+ 39 - 0
src/logx/main.NewWriter.go

@@ -0,0 +1,39 @@
+package logx
+
+import (
+	"io"
+	"time"
+
+	"github.com/5-say/go-tool/logx/tool"
+	"github.com/rs/zerolog/diode"
+)
+
+// WriterConfig ..
+type WriterConfig struct {
+	Rolling WriterRollingConfig
+	Async   *WriterAsyncConfig
+}
+
+// 滚动写入配置
+type WriterRollingConfig struct {
+	MaxMegabytes int  // 单文件最大存储容量(兆字节)
+	MaxDays      int  // 最大存储天数
+	MaxBackups   int  // 最大备份文件数量
+	Compress     bool // 备份文件是否压缩
+}
+
+// 异步写入配置
+type WriterAsyncConfig struct {
+	BufferSize   int           // 生产者缓冲区大小
+	PollInterval time.Duration // 消费者轮询间隔
+	Alerter      diode.Alerter // 丢弃告警函数
+}
+
+// 实例化日志写入器,支持 滚动 与 异步
+func NewWriter(fileName string, c WriterConfig) io.Writer {
+	var w = tool.FileRollingWriter(fileName, c.Rolling.MaxBackups, c.Rolling.MaxDays, c.Rolling.MaxMegabytes, c.Rolling.Compress)
+	if c.Async != nil {
+		return tool.WrapAsyncWriter(w, c.Async.BufferSize, c.Async.PollInterval, c.Async.Alerter)
+	}
+	return w
+}

+ 13 - 0
src/logx/main.SetLocation.go

@@ -0,0 +1,13 @@
+package logx
+
+// loggingLocationName
+var loggingLocationName = "UTC"
+
+// SetLocation
+//
+// e.g.
+//
+//	logx.SetLocation("Asia/Shanghai")
+func SetLocation(locationName string) {
+	loggingLocationName = locationName
+}

+ 24 - 0
src/logx/tool/FileRollingWriter.go

@@ -0,0 +1,24 @@
+package tool
+
+import (
+	"io"
+
+	"github.com/natefinch/lumberjack"
+)
+
+// 文件滚动写入器
+//
+//	fileName     string // 包含完整路径的文件名
+//	MaxMegabytes int    // 单文件最大存储容量(兆字节)
+//	MaxDays      int    // 最大存储天数
+//	MaxBackups   int    // 最大备份文件数量
+//	Compress     bool   // 备份文件是否压缩
+func FileRollingWriter(fileName string, maxMegabytes, maxDays, maxBackups int, compress bool) io.Writer {
+	return &lumberjack.Logger{
+		Filename:   fileName,
+		MaxSize:    maxMegabytes,
+		MaxAge:     maxDays,
+		MaxBackups: maxBackups,
+		Compress:   compress,
+	}
+}

+ 19 - 0
src/logx/tool/WrapAsyncWriter.go

@@ -0,0 +1,19 @@
+package tool
+
+import (
+	"io"
+	"time"
+
+	"github.com/rs/zerolog/diode"
+)
+
+// 包装异步写入器
+//
+//	writer       io.Writer
+//	bufferSize   int           // 生产者缓冲区大小
+//	pollInterval time.Duration // 消费者轮询间隔
+//	alerter      diode.Alerter // 丢弃告警函数
+func WrapAsyncWriter(writer io.Writer, bufferSize int, pollInterval time.Duration, alerter diode.Alerter) io.Writer {
+	// 线程安全、无锁、无阻塞
+	return diode.NewWriter(writer, bufferSize, pollInterval, alerter)
+}

+ 23 - 0
src/logx/tool/ZerologFormatTimestamp.go

@@ -0,0 +1,23 @@
+package tool
+
+import (
+	"time"
+
+	"github.com/rs/zerolog"
+)
+
+// ZerologFormatTimestamp ..
+func ZerologFormatTimestamp(loggingLocationName string) zerolog.Formatter {
+
+	return func(i interface{}) string {
+		t, err := time.Parse(zerolog.TimeFieldFormat, i.(string))
+		if err != nil {
+			return err.Error()
+		}
+		location, err := time.LoadLocation(loggingLocationName)
+		if err != nil {
+			return err.Error()
+		}
+		return "\r\n" + t.In(location).Format("MST 2006-01-02 15:04:05")
+	}
+}

+ 76 - 0
src/utilx/CreateFileIfNotExists.go

@@ -0,0 +1,76 @@
+package utilx
+
+import (
+	"io/fs"
+	"os"
+	"path/filepath"
+)
+
+// CreateFileIfNotExists 创建文件(如果不存在)并写入内容
+//
+// 这是一个安全的文件创建工具函数,专门用于处理文本文件的创建和初始化。
+// 主要特性:
+// • 自动创建所需的目录结构
+// • 仅在文件不存在时执行写入操作,避免数据覆盖
+// • 支持跨平台路径处理(Windows/Linux/macOS)
+// • 提供明确的操作结果反馈
+//
+// 参数:
+//   - filePath: 文件路径,支持绝对路径和相对路径
+//   - dirPerm: 目录权限,通常设置为 0755(rwxr-xr-x)
+//   - filePerm: 文件权限,通常设置为 0644(rw-r--r--)
+//   - content: 要写入的文本内容,支持多行字符串和特殊字符
+//
+// 返回值:
+//   - created: 布尔值,true 表示成功创建了新文件,false 表示文件已存在
+//   - err: 错误信息,操作成功时返回 nil
+//
+// 使用场景:
+// • 配置文件初始化
+// • 日志文件创建
+// • 模板文件生成
+// • 缓存文件设置
+//
+// 示例:
+//
+//	// 创建配置文件
+//	configContent := `{
+//	  "database": {
+//	    "host": "localhost",
+//	    "port": 5432
+//	  }
+//	}`
+//	created, err := CreateFileIfNotExists("/etc/app/config.json", 0755, 0644, configContent)
+//	if err != nil {
+//	    log.Fatal("配置文件创建失败:", err)
+//	}
+//	if created {
+//	    log.Println("配置文件已初始化")
+//	}
+//
+// 注意事项:
+// • 函数会自动创建所有必要的父级目录
+// • 如果文件已存在,函数不会进行任何修改操作
+// • 目录权限建议使用 0755,文件权限建议使用 0644
+// • 对于二进制内容,请使用 CreateFileIfNotExistsWithBytes 函数
+func CreateFileIfNotExists(filePath string, dirPerm, filePerm fs.FileMode, content string) (created bool, err error) {
+	// 使用标准库解析目录路径(跨平台兼容)
+	dirPath := filepath.Dir(filePath)
+
+	// 创建目录(如果不存在)
+	if err := os.MkdirAll(dirPath, dirPerm); err != nil {
+		return false, err
+	}
+
+	// 检查文件是否存在
+	if _, err := os.Stat(filePath); os.IsNotExist(err) {
+		// 文件不存在,创建并写入内容
+		if err := os.WriteFile(filePath, []byte(content), filePerm); err != nil {
+			return false, err
+		}
+		return true, nil
+	}
+
+	// 文件已存在,不进行任何操作
+	return false, nil
+}

+ 59 - 0
src/utilx/CreateFileIfNotExistsWithBytes.go

@@ -0,0 +1,59 @@
+package utilx
+
+import (
+	"io/fs"
+	"os"
+	"path/filepath"
+)
+
+// CreateFileIfNotExistsWithBytes 创建文件(如果不存在)并写入字节内容
+//
+// 这是一个安全的文件创建函数,具有以下特性:
+// 1. 自动创建所需的目录结构
+// 2. 仅在文件不存在时创建文件,避免覆盖现有文件
+// 3. 支持字节数组内容写入
+// 4. 提供明确的返回值指示操作结果
+//
+// 参数:
+//   - filePath: 文件路径,可以是绝对路径或相对路径
+//   - dirPerm: 目录权限,通常设置为 0755(rwxr-xr-x)
+//   - filePerm: 文件权限,通常设置为 0644(rw-r--r--)
+//   - content: 要写入的字节内容,可以是任意二进制数据
+//
+// 返回值:
+//   - created: 布尔值,true 表示创建了新文件,false 表示文件已存在
+//   - err: 错误信息,如果操作成功则为 nil
+//
+// 使用示例:
+//
+//	// 创建二进制配置文件
+//	configData := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} // PNG 文件头
+//	created, err := CreateFileIfNotExistsWithBytes("/path/to/image.png", 0755, 0644, configData)
+//	if err != nil {
+//	    log.Fatal("创建文件失败:", err)
+//	}
+//	if created {
+//	    log.Println("文件已成功创建")
+//	}
+//
+// 注意事项:
+//   - 函数会自动创建所需的父目录
+//   - 如果文件已存在,函数不会进行任何操作
+//   - 目录权限和文件权限应该根据安全需求合理设置
+//   - 对于文本内容,建议使用 CreateFileIfNotExists 函数
+func CreateFileIfNotExistsWithBytes(filePath string, dirPerm, filePerm fs.FileMode, content []byte) (created bool, err error) {
+	dirPath := filepath.Dir(filePath)
+
+	if err := os.MkdirAll(dirPath, dirPerm); err != nil {
+		return false, err
+	}
+
+	if _, err := os.Stat(filePath); os.IsNotExist(err) {
+		if err := os.WriteFile(filePath, content, filePerm); err != nil {
+			return false, err
+		}
+		return true, nil
+	}
+
+	return false, nil
+}

+ 49 - 0
src/utilx/FixMySqlDatetime.go

@@ -0,0 +1,49 @@
+package utilx
+
+import (
+	"time"
+)
+
+// FixMySqlDatetime 修正 MySQL DATETIME 字段存储时的微秒四舍五入问题
+//
+// MySQL DATETIME 类型支持微秒精度(最多6位小数),但在存储时会自动对微秒部分进行四舍五入。
+// 这可能导致时间戳在存储到数据库后与原始值不一致。本函数通过预判四舍五入行为,
+// 返回修正后的时间戳,确保与数据库存储结果一致。
+//
+// 参数:
+//   - timeObj: 原始时间对象,包含纳秒精度的时间信息
+//
+// 返回值:
+//   - fixedTimestamp: 修正后的 Unix 时间戳(秒级精度)
+//
+// 算法原理:
+// 1. 提取时间的纳秒部分(0-999999999)
+// 2. 转换为微秒(0-999999),对应 MySQL DATETIME 的精度
+// 3. 如果微秒部分 >= 500,则向上舍入1秒
+// 4. 否则保持原时间戳不变
+//
+// 使用示例:
+//
+//	now := time.Now()
+//	// 原始时间: 2023-10-01 12:30:45.500123
+//	fixedTs := FixMySqlDatetime(now)
+//	// 修正后时间戳: 如果微秒500123 >= 500,则返回原时间戳+1秒
+//
+// 注意事项:
+// - 函数处理的是秒级时间戳,适用于 DATETIME 字段的秒级存储
+// - 对于需要更高精度的场景,应考虑使用 TIMESTAMP(6) 等字段类型
+// - 此修正主要用于确保数据一致性,避免因数据库四舍五入导致的意外行为
+func FixMySqlDatetime(timeObj time.Time) (fixedTimestamp int64) {
+	originalTimestamp := timeObj.Unix()
+
+	// 获取纳秒部分并转换为微秒(MySQL DATETIME 的精度)
+	nanos := timeObj.Nanosecond()
+	micros := nanos / 1000 // 转换为微秒(0-999999)
+
+	// 如果微秒部分 >= 500,则向上舍入1秒
+	if micros >= 500 {
+		return originalTimestamp + 1
+	}
+
+	return originalTimestamp
+}

+ 189 - 0
src/utilx/GetRequestIP.go

@@ -0,0 +1,189 @@
+package utilx
+
+import (
+	"net"
+	"net/http"
+	"strings"
+)
+
+/*
+IP 地址检测相关资源:
+https://ipinfo.io
+https://github.com/ipinfo/go
+*/
+
+// GetRequestIP 获取客户端真实 IP 地址
+//
+// 该函数按照标准代理链优先级获取客户端 IP,支持多种代理头部:
+// 1. X-Real-IP: 单 IP 头部,通常由反向代理设置
+// 2. X-Forwarded-For: 代理链 IP 列表,取第一个有效公网 IP
+// 3. X-Forwarded-For (RFC 7239): 标准格式代理链
+// 4. RemoteAddr: 直接连接地址(最后备选)
+//
+// 参数:
+//   - r: HTTP 请求对象
+//
+// 返回值:
+//   - ip: 客户端 IP 地址,如果无法获取则返回空字符串
+//
+// 算法特点:
+// • 自动跳过私有 IP 和保留 IP
+// • 支持 IPv4 和 IPv6 地址
+// • 处理代理链中的格式问题(空格、引号等)
+// • 符合网络安全最佳实践
+//
+// 使用示例:
+//
+//	func handler(w http.ResponseWriter, r *http.Request) {
+//	    clientIP := GetRequestIP(r)
+//	    if clientIP == "" {
+//	        http.Error(w, "无法获取客户端IP", http.StatusBadRequest)
+//	        return
+//	    }
+//	    fmt.Fprintf(w, "客户端IP: %s", clientIP)
+//	}
+//
+// 代理环境配置示例(Nginx):
+//
+//	location / {
+//	    proxy_set_header X-Real-IP $remote_addr;
+//	    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+//	    proxy_pass http://backend;
+//	}
+func GetRequestIP(r *http.Request) string {
+	// 1. 检查 X-Real-IP 头部(单 IP 格式)
+	if ip := getIPFromHeader(r, "X-Real-IP"); ip != "" {
+		return ip
+	}
+
+	// 2. 检查 X-Forwarded-For 头部(代理链格式)
+	if ip := getIPFromForwardedFor(r, "X-Forwarded-For"); ip != "" {
+		return ip
+	}
+
+	// 3. 检查标准 Forwarded 头部(RFC 7239)
+	if ip := getIPFromForwardedHeader(r, "Forwarded"); ip != "" {
+		return ip
+	}
+
+	// 4. 最后检查 RemoteAddr
+	if ip := getIPFromRemoteAddr(r); ip != "" {
+		return ip
+	}
+
+	return ""
+}
+
+// getIPFromHeader 从单个 IP 头部获取有效 IP 地址
+func getIPFromHeader(r *http.Request, headerName string) string {
+	ipStr := strings.TrimSpace(r.Header.Get(headerName))
+	if ipStr == "" {
+		return ""
+	}
+
+	ip := net.ParseIP(ipStr)
+	if ip != nil && isValidPublicIP(ip) {
+		return ip.String()
+	}
+
+	return ""
+}
+
+// getIPFromForwardedFor 从 X-Forwarded-For 头部获取第一个有效公网 IP
+func getIPFromForwardedFor(r *http.Request, headerName string) string {
+	ipsStr := r.Header.Get(headerName)
+	if ipsStr == "" {
+		return ""
+	}
+
+	// 分割 IP 列表并清理格式
+	ipList := strings.Split(ipsStr, ",")
+	for _, ipStr := range ipList {
+		// 清理空格和引号
+		ipStr = strings.TrimSpace(ipStr)
+		ipStr = strings.Trim(ipStr, `"'`)
+
+		ip := net.ParseIP(ipStr)
+		if ip != nil && isValidPublicIP(ip) {
+			return ip.String()
+		}
+	}
+
+	return ""
+}
+
+// getIPFromForwardedHeader 从 RFC 7239 Forwarded 头部解析 IP
+func getIPFromForwardedHeader(r *http.Request, headerName string) string {
+	forwarded := r.Header.Get(headerName)
+	if forwarded == "" {
+		return ""
+	}
+
+	// 简化解析:查找 "for=" 后面的 IP
+	parts := strings.Split(forwarded, ";")
+	for _, part := range parts {
+		part = strings.TrimSpace(part)
+		if strings.HasPrefix(strings.ToLower(part), "for=") {
+			ipStr := strings.TrimPrefix(part, "for=")
+			ipStr = strings.Trim(ipStr, `"'[]`)
+
+			ip := net.ParseIP(ipStr)
+			if ip != nil && isValidPublicIP(ip) {
+				return ip.String()
+			}
+		}
+	}
+
+	return ""
+}
+
+// getIPFromRemoteAddr 从 RemoteAddr 获取 IP
+func getIPFromRemoteAddr(r *http.Request) string {
+	if r.RemoteAddr == "" {
+		return ""
+	}
+
+	host, _, err := net.SplitHostPort(r.RemoteAddr)
+	if err != nil {
+		// 如果没有端口号,直接尝试解析
+		if ip := net.ParseIP(r.RemoteAddr); ip != nil && isValidPublicIP(ip) {
+			return ip.String()
+		}
+		return ""
+	}
+
+	ip := net.ParseIP(host)
+	if ip != nil && isValidPublicIP(ip) {
+		return ip.String()
+	}
+
+	return ""
+}
+
+// isValidPublicIP 检查 IP 是否为有效的公网 IP
+func isValidPublicIP(ip net.IP) bool {
+	if ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
+		return false
+	}
+
+	// 检查私有 IP 范围
+	if ip4 := ip.To4(); ip4 != nil {
+		switch {
+		case ip4[0] == 10: // 10.0.0.0/8
+			return false
+		case ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31: // 172.16.0.0/12
+			return false
+		case ip4[0] == 192 && ip4[1] == 168: // 192.168.0.0/16
+			return false
+		case ip4[0] == 169 && ip4[1] == 254: // 169.254.0.0/16 (链路本地)
+			return false
+		}
+	}
+
+	// 检查 IPv6 私有范围
+	if ip.IsPrivate() {
+		return false
+	}
+
+	return true
+}

+ 191 - 0
src/utilx/Md5.go

@@ -0,0 +1,191 @@
+package utilx
+
+import (
+	"crypto/md5"
+	"encoding/hex"
+	"fmt"
+	"io"
+	"os"
+)
+
+// Md5 全局 MD5 工具实例
+var Md5 Md5T
+
+// Md5T MD5 工具类型,提供多种 MD5 计算功能
+type Md5T struct{}
+
+// String 计算字符串的 MD5 哈希值
+//
+// 参数:
+//   - str: 要计算 MD5 的字符串
+//
+// 返回值:
+//   - md5String: 32位十六进制 MD5 字符串
+//
+// 示例:
+//
+//	hash := Md5.String("hello world")
+//	// 输出: 5eb63bbbe01eeed093cb22bb8f5acdc3
+func (Md5T) String(str string) string {
+	hasher := md5.New()
+	hasher.Write([]byte(str))
+	return hex.EncodeToString(hasher.Sum(nil))
+}
+
+// Bytes 计算字节切片的 MD5 哈希值
+//
+// 参数:
+//   - data: 要计算 MD5 的字节数据
+//
+// 返回值:
+//   - md5String: 32位十六进制 MD5 字符串
+//
+// 示例:
+//
+//	data := []byte{0x48, 0x65, 0x6c, 0x6c, 0x6f} // "Hello"
+//	hash := Md5.Bytes(data)
+func (Md5T) Bytes(data []byte) string {
+	hasher := md5.New()
+	hasher.Write(data)
+	return hex.EncodeToString(hasher.Sum(nil))
+}
+
+// File 计算文件的 MD5 哈希值
+//
+// 参数:
+//   - filePath: 文件路径
+//
+// 返回值:
+//   - md5String: 文件的 MD5 哈希值
+//   - err: 错误信息
+//
+// 示例:
+//
+//	hash, err := Md5.File("/path/to/file.txt")
+//	if err != nil {
+//	    // 处理错误
+//	}
+func (Md5T) File(filePath string) (string, error) {
+	file, err := os.Open(filePath)
+	if err != nil {
+		return "", fmt.Errorf("打开文件失败: %w", err)
+	}
+	defer file.Close()
+
+	hasher := md5.New()
+	if _, err := io.Copy(hasher, file); err != nil {
+		return "", fmt.Errorf("计算文件MD5失败: %w", err)
+	}
+
+	return hex.EncodeToString(hasher.Sum(nil)), nil
+}
+
+// StringToBytes 计算字符串的 MD5 并返回字节形式
+//
+// 参数:
+//   - str: 要计算 MD5 的字符串
+//
+// 返回值:
+//   - md5Bytes: 16字节的 MD5 哈希值
+//
+// 示例:
+//
+//	hashBytes := Md5.StringToBytes("hello")
+//	// 输出: [16]byte
+func (Md5T) StringToBytes(str string) [16]byte {
+	return md5.Sum([]byte(str))
+}
+
+// BytesToBytes 计算字节切片的 MD5 并返回字节形式
+//
+// 参数:
+//   - data: 要计算 MD5 的字节数据
+//
+// 返回值:
+//   - md5Bytes: 16字节的 MD5 哈希值
+func (Md5T) BytesToBytes(data []byte) [16]byte {
+	return md5.Sum(data)
+}
+
+// VerifyString 验证字符串的 MD5 哈希值
+//
+// 参数:
+//   - str: 原始字符串
+//   - expectedHash: 预期的 MD5 哈希值
+//
+// 返回值:
+//   - bool: 验证结果,true 表示匹配
+//
+// 示例:
+//
+//	isValid := Md5.VerifyString("hello", "5d41402abc4b2a76b9719d911017c592")
+func (Md5T) VerifyString(str, expectedHash string) bool {
+	actualHash := Md5.String(str)
+	return actualHash == expectedHash
+}
+
+// VerifyBytes 验证字节数据的 MD5 哈希值
+//
+// 参数:
+//   - data: 原始字节数据
+//   - expectedHash: 预期的 MD5 哈希值
+//
+// 返回值:
+//   - bool: 验证结果
+func (Md5T) VerifyBytes(data []byte, expectedHash string) bool {
+	actualHash := Md5.Bytes(data)
+	return actualHash == expectedHash
+}
+
+// VerifyFile 验证文件的 MD5 哈希值
+//
+// 参数:
+//   - filePath: 文件路径
+//   - expectedHash: 预期的 MD5 哈希值
+//
+// 返回值:
+//   - bool: 验证结果
+//   - err: 错误信息
+//
+// 示例:
+//
+//	isValid, err := Md5.VerifyFile("file.txt", "expected-md5-hash")
+func (Md5T) VerifyFile(filePath, expectedHash string) (bool, error) {
+	actualHash, err := Md5.File(filePath)
+	if err != nil {
+		return false, err
+	}
+	return actualHash == expectedHash, nil
+}
+
+// StringWithSalt 使用盐值计算字符串的 MD5 哈希值
+//
+// 参数:
+//   - str: 原始字符串
+//   - salt: 盐值字符串
+//
+// 返回值:
+//   - md5String: 加盐后的 MD5 哈希值
+//
+// 示例:
+//
+//	hash := Md5.StringWithSalt("password", "random-salt")
+func (Md5T) StringWithSalt(str, salt string) string {
+	return Md5.String(str + salt)
+}
+
+// StringWithPrefix 计算带前缀的字符串 MD5
+//
+// 参数:
+//   - prefix: 前缀字符串
+//   - str: 原始字符串
+//
+// 返回值:
+//   - md5String: MD5 哈希值
+//
+// 示例:
+//
+//	hash := Md5.StringWithPrefix("user:", "12345")
+func (Md5T) StringWithPrefix(prefix, str string) string {
+	return Md5.String(prefix + str)
+}

+ 245 - 0
src/utilx/Time.go

@@ -0,0 +1,245 @@
+package utilx
+
+import (
+	"fmt"
+	"time"
+)
+
+// TimeT 时间工具类型,提供时区感知的时间计算功能
+type TimeT struct {
+	LocationName string // 时区名称,如 "Asia/Shanghai"、"America/New_York"
+}
+
+// DefaultTime 默认时间工具实例,使用上海时区
+var DefaultTime = TimeT{LocationName: "Asia/Shanghai"}
+
+// Loc 加载并返回时区对象
+//
+// 返回值:
+//   - *time.Location: 时区对象,如果时区无效则返回 UTC 时区
+//
+// 示例:
+//
+//	loc := timeTool.Loc()
+func (s TimeT) Loc() *time.Location {
+	loc, err := time.LoadLocation(s.LocationName)
+	if err != nil {
+		// 时区无效时返回 UTC 时区
+		return time.UTC
+	}
+	return loc
+}
+
+// parseTimeInLocation 在指定时区解析时间字符串的辅助函数
+func (s TimeT) parseTimeInLocation(layout, value string) (time.Time, error) {
+	t, err := time.ParseInLocation(layout, value, s.Loc())
+	if err != nil {
+		return time.Time{}, fmt.Errorf("解析时间失败: %w", err)
+	}
+	return t, nil
+}
+
+// getDayRange 获取某天的开始和结束时间(辅助函数)
+func (s TimeT) getDayRange(unixTimestamp int64) (startTime, endTime time.Time, err error) {
+	locTime := time.Unix(unixTimestamp, 0).In(s.Loc())
+
+	// 获取当天的开始时间(00:00:00)
+	startTime, err = s.parseTimeInLocation("2006-01-02", locTime.Format("2006-01-02"))
+	if err != nil {
+		return time.Time{}, time.Time{}, err
+	}
+
+	// 获取当天的结束时间(23:59:59)
+	endTime = startTime.Add(24*time.Hour - time.Second)
+
+	return startTime, endTime, nil
+}
+
+// getMonthRange 获取某月的开始和结束时间(辅助函数)
+func (s TimeT) getMonthRange(unixTimestamp int64) (startTime, endTime time.Time, err error) {
+	locTime := time.Unix(unixTimestamp, 0).In(s.Loc())
+
+	// 获取当月的开始时间
+	startTime = time.Date(locTime.Year(), locTime.Month(), 1, 0, 0, 0, 0, s.Loc())
+
+	// 获取下个月的第一天,然后减1秒得到当月最后一天
+	nextMonth := locTime.Month() + 1
+	year := locTime.Year()
+	if nextMonth > 12 {
+		year += 1
+		nextMonth = 1
+	}
+	endTime = time.Date(year, nextMonth, 1, 0, 0, 0, 0, s.Loc()).Add(-time.Second)
+
+	return startTime, endTime, nil
+}
+
+// LocDayStartDateStr 获取目标时间戳所在日的开始日期字符串(YYYY-MM-DD)
+//
+// 参数:
+//   - unixTimestamp: UTC 时间戳
+//
+// 返回值:
+//   - string: 日期字符串,格式为 "2006-01-02"
+//
+// 示例:
+//
+//	dateStr := timeTool.LocDayStartDateStr(1609459200) // "2021-01-01"
+func (s TimeT) LocDayStartDateStr(unixTimestamp int64) string {
+	startTime, _, err := s.getDayRange(unixTimestamp)
+	if err != nil {
+		return ""
+	}
+	return startTime.Format("2006-01-02")
+}
+
+// LocDayStartTimestamp 获取目标时间戳所在日的开始时间戳(00:00:00)
+//
+// 参数:
+//   - unixTimestamp: UTC 时间戳
+//
+// 返回值:
+//   - int64: 开始时间戳(UTC)
+func (s TimeT) LocDayStartTimestamp(unixTimestamp int64) int64 {
+	startTime, _, err := s.getDayRange(unixTimestamp)
+	if err != nil {
+		return 0
+	}
+	return startTime.Unix()
+}
+
+// LocDayEndTimestamp 获取目标时间戳所在日的结束时间戳(23:59:59)
+//
+// 参数:
+//   - unixTimestamp: UTC 时间戳
+//
+// 返回值:
+//   - int64: 结束时间戳(UTC)
+func (s TimeT) LocDayEndTimestamp(unixTimestamp int64) int64 {
+	_, endTime, err := s.getDayRange(unixTimestamp)
+	if err != nil {
+		return 0
+	}
+	return endTime.Unix()
+}
+
+// LocMonthStartDateStr 获取目标时间戳所在月的开始日期字符串(YYYY-MM)
+//
+// 参数:
+//   - unixTimestamp: UTC 时间戳
+//
+// 返回值:
+//   - string: 月份字符串,格式为 "2006-01"
+func (s TimeT) LocMonthStartDateStr(unixTimestamp int64) string {
+	startTime, _, err := s.getMonthRange(unixTimestamp)
+	if err != nil {
+		return ""
+	}
+	return startTime.Format("2006-01")
+}
+
+// LocMonthStartTimestamp 获取目标时间戳所在月的开始时间戳
+//
+// 参数:
+//   - unixTimestamp: UTC 时间戳
+//
+// 返回值:
+//   - int64: 月开始时间戳(UTC)
+func (s TimeT) LocMonthStartTimestamp(unixTimestamp int64) int64 {
+	startTime, _, err := s.getMonthRange(unixTimestamp)
+	if err != nil {
+		return 0
+	}
+	return startTime.Unix()
+}
+
+// LocMonthEndTimestamp 获取目标时间戳所在月的结束时间戳
+//
+// 参数:
+//   - unixTimestamp: UTC 时间戳
+//
+// 返回值:
+//   - int64: 月结束时间戳(UTC)
+func (s TimeT) LocMonthEndTimestamp(unixTimestamp int64) int64 {
+	_, endTime, err := s.getMonthRange(unixTimestamp)
+	if err != nil {
+		return 0
+	}
+	return endTime.Unix()
+}
+
+// ParseDayStartTimestamp 解析日期字符串并返回当天的开始时间戳
+//
+// 参数:
+//   - value: 日期字符串,格式为 "2006-01-02"
+//
+// 返回值:
+//   - int64: 开始时间戳(UTC)
+func (s TimeT) ParseDayStartTimestamp(value string) int64 {
+	t, err := s.parseTimeInLocation("2006-01-02", value)
+	if err != nil {
+		return 0
+	}
+	return t.Unix()
+}
+
+// ParseDayEndTimestamp 解析日期字符串并返回当天的结束时间戳
+//
+// 参数:
+//   - value: 日期字符串,格式为 "2006-01-02"
+//
+// 返回值:
+//   - int64: 结束时间戳(UTC)
+func (s TimeT) ParseDayEndTimestamp(value string) int64 {
+	t, err := s.parseTimeInLocation("2006-01-02", value)
+	if err != nil {
+		return 0
+	}
+	// 当天的结束时间是第二天的开始时间减1秒
+	return t.Add(24*time.Hour - time.Second).Unix()
+}
+
+// Now 获取当前时间的 UTC 时间戳
+//
+// 返回值:
+//   - int64: 当前 UTC 时间戳
+func (s TimeT) Now() int64 {
+	return time.Now().Unix()
+}
+
+// NowInLocation 获取当前时间在指定时区的时间对象
+//
+// 返回值:
+//   - time.Time: 当前时间(指定时区)
+func (s TimeT) NowInLocation() time.Time {
+	return time.Now().In(s.Loc())
+}
+
+// Format 格式化时间戳为指定格式的字符串
+//
+// 参数:
+//   - unixTimestamp: UTC 时间戳
+//   - layout: 时间格式,如 "2006-01-02 15:04:05"
+//
+// 返回值:
+//   - string: 格式化后的时间字符串
+func (s TimeT) Format(unixTimestamp int64, layout string) string {
+	return time.Unix(unixTimestamp, 0).In(s.Loc()).Format(layout)
+}
+
+// Parse 解析时间字符串为 UTC 时间戳
+//
+// 参数:
+//   - layout: 时间格式
+//   - value: 时间字符串
+//
+// 返回值:
+//   - int64: UTC 时间戳
+//   - error: 错误信息
+func (s TimeT) Parse(layout, value string) (int64, error) {
+	t, err := s.parseTimeInLocation(layout, value)
+	if err != nil {
+		return 0, err
+	}
+	return t.Unix(), nil
+}

+ 196 - 0
src/utilx/test/Time_test.go

@@ -0,0 +1,196 @@
+package test
+
+import (
+	"testing"
+	"time"
+
+	"git.ooo.ink/root/go-kit/src/utilx"
+)
+
+// Test_Time 测试 TimeT 类型的时间计算功能
+//
+// 本测试验证以下功能:
+// 1. 时区设置是否正确加载
+// 2. 日级别的开始/结束时间戳和日期字符串
+// 3. 月级别的开始/结束时间戳和日期字符串
+// 4. 日期字符串解析功能
+//
+// 测试用例说明:
+// • 使用 "Asia/Shanghai" 时区进行测试
+// • 验证当前时间所在日/月的范围计算
+// • 检查时间戳和日期字符串的格式正确性
+//
+// 预期结果:
+// • LocDayStartDateStr: 返回 "YYYY-MM-DD" 格式的日期字符串
+// • LocDayStartTimestamp: 返回当天 00:00:00 时间戳
+// • LocDayEndTimestamp: 返回当天 23:59:59 时间戳
+// • LocMonthStartDateStr: 返回 "YYYY-MM" 格式的月份开始日期
+// • LocMonthStartTimestamp: 返回月份第一天的 00:00:00 时间戳
+// • LocMonthEndTimestamp: 返回月份最后一天的 23:59:59 时间戳
+// • ParseDayStartTimestamp: 解析日期字符串返回开始时间戳
+// • ParseDayEndTimestamp: 解析日期字符串返回结束时间戳
+//
+// 示例输出:
+//
+//	Time_test.go:18: 2024-01-15
+//	Time_test.go:19: 1705276800
+//	Time_test.go:20: 1705363199
+//	Time_test.go:21: 2024-01
+//	Time_test.go:22: 1704038400
+//	Time_test.go:23: 1706716799
+func Test_Time(t *testing.T) {
+	// 创建上海时区的时间工具实例
+	var (
+		Time = utilx.TimeT{
+			LocationName: "Asia/Shanghai",
+		}
+		now = time.Now().Unix()
+	)
+
+	t.Log("=== 基础信息 ===")
+	t.Log("当前时间戳:", now)
+	t.Log("时区:", Time.Loc())
+
+	t.Log("\n=== 日级别时间计算 ===")
+	t.Log("日期字符串:", Time.LocDayStartDateStr(now))
+	t.Log("日开始时间戳:", Time.LocDayStartTimestamp(now))
+	t.Log("日结束时间戳:", Time.LocDayEndTimestamp(now))
+
+	t.Log("\n=== 月级别时间计算 ===")
+	t.Log("月份开始日期:", Time.LocMonthStartDateStr(now))
+	t.Log("月开始时间戳:", Time.LocMonthStartTimestamp(now))
+	t.Log("月结束时间戳:", Time.LocMonthEndTimestamp(now))
+
+	t.Log("\n=== 日期字符串解析 ===")
+	testDate := "2024-01-15"
+	t.Log("测试日期:", testDate)
+	t.Log("解析开始时间戳:", Time.ParseDayStartTimestamp(testDate))
+	t.Log("解析结束时间戳:", Time.ParseDayEndTimestamp(testDate))
+
+	// 验证时区加载
+	loc := Time.Loc()
+	if loc == nil {
+		t.Error("时区加载失败")
+	}
+
+	// 验证日期字符串格式
+	dayStartStr := Time.LocDayStartDateStr(now)
+	if len(dayStartStr) != 10 || dayStartStr[4] != '-' || dayStartStr[7] != '-' {
+		t.Errorf("日期字符串格式错误: %s", dayStartStr)
+	}
+
+	monthStartStr := Time.LocMonthStartDateStr(now)
+	if len(monthStartStr) != 7 || monthStartStr[4] != '-' {
+		t.Errorf("月份开始日期格式错误: %s", monthStartStr)
+	}
+
+	// 验证日级别时间戳范围合理性
+	dayStart := Time.LocDayStartTimestamp(now)
+	dayEnd := Time.LocDayEndTimestamp(now)
+
+	if dayStart >= dayEnd {
+		t.Errorf("日时间戳范围无效: 开始 %d >= 结束 %d", dayStart, dayEnd)
+	}
+
+	if dayEnd-dayStart != 24*3600-1 { // 正好24小时减1秒
+		t.Errorf("日时间范围不正确: 开始 %d, 结束 %d, 差值 %d", dayStart, dayEnd, dayEnd-dayStart)
+	}
+
+	// 验证月级别时间戳范围合理性
+	monthStart := Time.LocMonthStartTimestamp(now)
+	monthEnd := Time.LocMonthEndTimestamp(now)
+
+	if monthStart >= monthEnd {
+		t.Errorf("月份时间戳范围无效: 开始 %d >= 结束 %d", monthStart, monthEnd)
+	}
+
+	if monthEnd-monthStart < 28*24*3600 { // 至少28天
+		t.Errorf("月份时间范围过短: 开始 %d, 结束 %d", monthStart, monthEnd)
+	}
+
+	// 验证日期字符串解析
+	parsedDayStart := Time.ParseDayStartTimestamp(testDate)
+	parsedDayEnd := Time.ParseDayEndTimestamp(testDate)
+
+	if parsedDayStart == 0 || parsedDayEnd == 0 {
+		t.Error("日期字符串解析失败")
+	}
+
+	if parsedDayEnd-parsedDayStart != 24*3600-1 {
+		t.Errorf("解析后的日时间范围不正确: 开始 %d, 结束 %d", parsedDayStart, parsedDayEnd)
+	}
+
+	// 验证解析结果与直接计算的一致性
+	directDayStart := Time.LocDayStartTimestamp(parsedDayStart)
+	if directDayStart != parsedDayStart {
+		t.Errorf("解析与直接计算不一致: 解析 %d, 直接 %d", parsedDayStart, directDayStart)
+	}
+
+	t.Log("\n✅ 时间工具所有方法测试通过")
+}
+
+// Test_TimeEdgeCases 测试边界情况和异常处理
+func Test_TimeEdgeCases(t *testing.T) {
+	t.Log("=== 边界情况测试 ===")
+
+	// 测试无效时区
+	invalidTime := utilx.TimeT{LocationName: "Invalid/Timezone"}
+	loc := invalidTime.Loc()
+	if loc.String() != "UTC" {
+		t.Errorf("无效时区应返回UTC,实际返回: %s", loc.String())
+	}
+	t.Log("无效时区处理正确,返回:", loc.String())
+
+	// 测试零时间戳
+	zeroTime := utilx.TimeT{LocationName: "Asia/Shanghai"}
+	zeroTimestamp := int64(0)
+
+	dayStart := zeroTime.LocDayStartTimestamp(zeroTimestamp)
+	dayEnd := zeroTime.LocDayEndTimestamp(zeroTimestamp)
+
+	if dayStart == 0 || dayEnd == 0 {
+		t.Error("零时间戳处理失败")
+	}
+	t.Log("零时间戳处理正确,日开始:", dayStart, "日结束:", dayEnd)
+
+	// 测试无效日期字符串解析
+	invalidDate := "2024-13-45" // 无效日期
+	parsedStart := zeroTime.ParseDayStartTimestamp(invalidDate)
+	parsedEnd := zeroTime.ParseDayEndTimestamp(invalidDate)
+
+	if parsedStart != 0 || parsedEnd != 0 {
+		t.Error("无效日期字符串应返回0")
+	}
+	t.Log("无效日期字符串处理正确,返回0")
+
+	// 测试闰年2月
+	leapYearDate := "2024-02-29" // 闰年2月29日
+	leapTime := utilx.TimeT{LocationName: "Asia/Shanghai"}
+
+	leapStart := leapTime.ParseDayStartTimestamp(leapYearDate)
+	leapEnd := leapTime.ParseDayEndTimestamp(leapYearDate)
+
+	if leapStart == 0 || leapEnd == 0 {
+		t.Error("闰年日期解析失败")
+	}
+
+	// 验证闰年2月的时间范围
+	if leapEnd-leapStart != 24*3600-1 {
+		t.Errorf("闰年日期时间范围不正确: 开始 %d, 结束 %d", leapStart, leapEnd)
+	}
+	t.Log("闰年日期处理正确,开始:", leapStart, "结束:", leapEnd)
+
+	// 测试月份边界(12月到1月的转换)
+	decTimestamp := int64(1704067200) // 2024-01-01 00:00:00 UTC+8
+	decTime := utilx.TimeT{LocationName: "Asia/Shanghai"}
+
+	monthStart := decTime.LocMonthStartTimestamp(decTimestamp)
+	monthEnd := decTime.LocMonthEndTimestamp(decTimestamp)
+
+	if monthStart >= monthEnd {
+		t.Errorf("12月到1月转换失败: 开始 %d >= 结束 %d", monthStart, monthEnd)
+	}
+	t.Log("月份边界处理正确,月开始:", monthStart, "月结束:", monthEnd)
+
+	t.Log("\n✅ 边界情况测试通过")
+}